from __future__ import annotations
|
|
import hashlib
|
import importlib.util
|
import io
|
import os
|
from pathlib import Path
|
import re
|
import shutil
|
import subprocess
|
import sys
|
import tempfile
|
import unittest
|
from unittest import mock
|
import zlib
|
|
import numpy as np
|
from PIL import Image, ImageDraw
|
|
|
MODULE_PATH = Path(__file__).resolve().parents[1] / "extract_ppt_slides.py"
|
SPEC = importlib.util.spec_from_file_location("extract_ppt_slides", MODULE_PATH)
|
assert SPEC and SPEC.loader
|
slides = importlib.util.module_from_spec(SPEC)
|
sys.modules[SPEC.name] = slides
|
SPEC.loader.exec_module(slides)
|
|
|
def feature(index: int, array: np.ndarray, score: float = 0.2, sharpness: float | None = None):
|
value = int(np.mean(array))
|
phash = int.from_bytes(hashlib.sha256(array.tobytes()).digest()[:8], "big")
|
return slides.FrameFeature(
|
sample_index=index,
|
relative_seconds=float(index),
|
gray=array.astype(np.uint8).copy(),
|
perceptual_hash=phash,
|
content_score=score,
|
sharpness=float(value if sharpness is None else sharpness),
|
)
|
|
|
def candidate(timestamp: float, array: np.ndarray, score: float, phash: int = 0):
|
return slides.PageCandidate(timestamp, array.astype(np.uint8).copy(), phash, score, 1.0)
|
|
|
def sha256(path: Path) -> str:
|
digest = hashlib.sha256()
|
with path.open("rb") as handle:
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
digest.update(chunk)
|
return digest.hexdigest().upper()
|
|
|
def pdf_rgb_pages(path: Path):
|
data = path.read_bytes()
|
objects = {
|
int(match.group(1)): match.group(2)
|
for match in re.finditer(rb"(?m)^(\d+) 0 obj\n(.*?)\nendobj\n", data, re.DOTALL)
|
}
|
pages_body = objects[2]
|
kids_match = re.search(rb"/Kids \[(.*?)\]", pages_body, re.DOTALL)
|
assert kids_match
|
page_ids = [int(value) for value in re.findall(rb"(\d+) 0 R", kids_match.group(1))]
|
result = []
|
for page_id in page_ids:
|
page = objects[page_id]
|
media = re.search(rb"/MediaBox \[0 0 (\d+) (\d+)\]", page)
|
image_ref = re.search(rb"/Im0 (\d+) 0 R", page)
|
assert media and image_ref
|
image_body = objects[int(image_ref.group(1))]
|
length = int(re.search(rb"/Length (\d+)", image_body).group(1))
|
marker = b"stream\n"
|
start = image_body.index(marker) + len(marker)
|
compressed = image_body[start : start + length]
|
result.append(((int(media.group(1)), int(media.group(2))), zlib.decompress(compressed)))
|
return result
|
|
|
class ShortReadStream:
|
def __init__(self, chunks):
|
self.chunks = list(chunks)
|
|
def read(self, size):
|
if not self.chunks:
|
return b""
|
chunk = self.chunks.pop(0)
|
if len(chunk) <= size:
|
return chunk
|
self.chunks.insert(0, chunk[size:])
|
return chunk[:size]
|
|
|
class ReadExactTests(unittest.TestCase):
|
def test_short_reads_clean_eof_and_partial_eof(self):
|
self.assertEqual(slides.read_exact(ShortReadStream([b"a", b"bc", b"def"]), 6), b"abcdef")
|
self.assertIsNone(slides.read_exact(ShortReadStream([]), 6))
|
with self.assertRaisesRegex(slides.SlideExtractionError, "半帧 EOF"):
|
slides.read_exact(ShortReadStream([b"abc"]), 6)
|
|
|
class SegmentAndClassifierTests(unittest.TestCase):
|
def test_anchor_drift_emits_only_old_and_new_plateaus(self):
|
detector = slides.StableSegmentDetector()
|
emitted = []
|
arrays = [np.full((8, 8), 20, np.uint8)] * 3
|
arrays += [np.full((8, 8), value, np.uint8) for value in (22, 24, 26, 28, 30, 32)]
|
arrays += [np.full((8, 8), 80, np.uint8)] * 3
|
for index, array in enumerate(arrays):
|
value = detector.add(feature(index, array, score=index / 100, sharpness=index))
|
if value:
|
emitted.append(value)
|
final = detector.flush()
|
if final:
|
emitted.append(final)
|
self.assertEqual([round(float(np.mean(item.feature))) for item in emitted], [20, 80])
|
|
def test_animation_increase_decrease_and_equal_score(self):
|
classifier = slides.PageClassifier()
|
base = np.zeros((8, 8), np.uint8)
|
added = base.copy()
|
added[:, :2] = 20
|
self.assertEqual(classifier.consume(candidate(1, base, 0.20)), "new_page")
|
self.assertEqual(classifier.consume(candidate(2, added, 0.30)), "animation_updated")
|
self.assertEqual(classifier.pages[0].current_timestamp, 2)
|
reduced = base.copy()
|
self.assertEqual(classifier.consume(candidate(3, reduced, 0.10)), "animation_retained")
|
self.assertEqual(classifier.pages[0].current_timestamp, 2)
|
self.assertEqual(
|
classifier.consume(candidate(4, added, 0.30 + slides.CONTENT_SCORE_TOLERANCE / 2)),
|
"animation_updated",
|
)
|
self.assertEqual(classifier.pages[0].current_timestamp, 4)
|
|
def test_a_b_a_keeps_first_order(self):
|
classifier = slides.PageClassifier()
|
a = np.zeros((8, 8), np.uint8)
|
b = np.full((8, 8), 255, np.uint8)
|
classifier.consume(candidate(1, a, 0.2, 0))
|
classifier.consume(candidate(2, b, 0.2, (1 << 64) - 1))
|
action = classifier.consume(candidate(3, a, 0.2, 0))
|
self.assertEqual(action, "historical_duplicate")
|
self.assertEqual(len(classifier.pages), 2)
|
self.assertEqual([page.first_seen_order for page in classifier.pages], [0, 1])
|
|
def test_recent_animation_has_priority_over_historical_duplicate(self):
|
classifier = slides.PageClassifier()
|
historical = np.zeros((8, 8), np.uint8)
|
recent = np.full((8, 8), 4, np.uint8)
|
classifier.consume(candidate(1, historical, 0.20, 0))
|
classifier.pages.append(
|
slides.LogicalPage(recent.copy(), recent.copy(), 0, 2.0, 0.20, 1)
|
)
|
action = classifier.consume(candidate(3, historical, 0.22, 0))
|
self.assertEqual(action, "animation_updated")
|
self.assertEqual(len(classifier.pages), 2)
|
|
|
class ProcessAndCommandTests(unittest.TestCase):
|
def test_command_freezes_time_mapping(self):
|
scan = slides._scan_command("ffmpeg", Path("x.mp4"), 320, 180)
|
self.assertIn("setpts=PTS-STARTPTS,fps=fps=1:start_time=0,scale=320:180:flags=bilinear", scan)
|
extract = slides._extract_command("ffmpeg", Path("x.mp4"), 12.0, Path("x.png"))
|
self.assertEqual(extract[extract.index("-ss") + 1], "12.000")
|
self.assertEqual(feature(9, np.zeros((2, 2), np.uint8)).relative_seconds, 9.0)
|
|
@unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "FFmpeg is required")
|
def test_vfr_nonzero_pts_scan_and_exact_seek_share_relative_time(self):
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw)
|
video = root / "vfr-nonzero.mkv"
|
subprocess.run(
|
[
|
shutil.which("ffmpeg"), "-hide_banner", "-loglevel", "error",
|
"-f", "lavfi", "-i", "color=red:size=64x64:rate=2:duration=2",
|
"-f", "lavfi", "-i", "color=green:size=64x64:rate=3:duration=2",
|
"-f", "lavfi", "-i", "color=blue:size=64x64:rate=4:duration=2",
|
"-filter_complex", "[0:v][1:v][2:v]concat=n=3:v=1:a=0,setpts=PTS+5/TB[v]",
|
"-map", "[v]", "-fps_mode", "vfr", "-c:v", "ffv1", str(video),
|
],
|
check=True,
|
)
|
start_time = float(
|
subprocess.check_output(
|
[
|
shutil.which("ffprobe"), "-v", "error", "-select_streams", "v:0",
|
"-show_entries", "stream=start_time", "-of", "default=nw=1:nk=1", str(video),
|
],
|
text=True,
|
).strip()
|
)
|
self.assertGreaterEqual(start_time, 5.0)
|
|
scan_process = subprocess.Popen(
|
slides._scan_command(shutil.which("ffmpeg"), video, 64, 64),
|
stdin=subprocess.DEVNULL,
|
stdout=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
)
|
assert scan_process.stdout is not None
|
scanned = []
|
while True:
|
payload = slides.read_exact(scan_process.stdout, 64 * 64 * 3)
|
if payload is None:
|
break
|
scanned.append(np.frombuffer(payload, np.uint8).reshape((64, 64, 3)))
|
scan_process.stdout.close()
|
stderr = scan_process.stderr.read() if scan_process.stderr else b""
|
if scan_process.stderr:
|
scan_process.stderr.close()
|
self.assertEqual(scan_process.wait(), 0, stderr.decode(errors="replace"))
|
self.assertGreaterEqual(len(scanned), 5)
|
|
extracted = []
|
for timestamp in (0.0, 2.0, 4.0):
|
path = root / f"at-{timestamp:.0f}.png"
|
slides._run_file_command(
|
slides._extract_command(shutil.which("ffmpeg"), video, timestamp, path),
|
root / f"at-{timestamp:.0f}.stderr.log",
|
"VFR 定位",
|
)
|
with Image.open(path) as image:
|
extracted.append(np.asarray(image.convert("RGB"), np.uint8))
|
|
for sequence in ((scanned[0], scanned[2], scanned[4]), tuple(extracted)):
|
means = [np.mean(item, axis=(0, 1)) for item in sequence]
|
self.assertEqual([int(np.argmax(value)) for value in means], [0, 1, 2])
|
|
@unittest.skipUnless(shutil.which("ffmpeg"), "FFmpeg is required")
|
def test_large_stderr_does_not_deadlock_and_nonzero_is_reported(self):
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw)
|
ok_log = root / "ok.log"
|
slides._run_file_command(
|
[sys.executable, "-c", "import sys; sys.stderr.write('x'*300000)"],
|
ok_log,
|
"桩命令",
|
)
|
self.assertEqual(ok_log.stat().st_size, 300000)
|
bad_log = root / "bad.log"
|
with self.assertRaisesRegex(slides.SlideExtractionError, "exit=7"):
|
slides._run_file_command(
|
[sys.executable, "-c", "import sys; sys.stderr.write('boom'); raise SystemExit(7)"],
|
bad_log,
|
"桩命令",
|
)
|
|
def test_keyboard_interrupt_reaps_file_process_and_preserves_type(self):
|
events = []
|
handles = []
|
|
class FakeProcess:
|
stdout = None
|
|
def __init__(self):
|
self.wait_calls = 0
|
self.running = True
|
|
def wait(self, timeout=None):
|
self.wait_calls += 1
|
events.append(("wait", timeout))
|
if self.wait_calls == 1:
|
raise KeyboardInterrupt()
|
self.running = False
|
return 0
|
|
def poll(self):
|
return None if self.running else 0
|
|
def terminate(self):
|
events.append(("terminate", None))
|
|
def kill(self):
|
events.append(("kill", None))
|
|
def make_process(*args, **kwargs):
|
handles.append(kwargs["stderr"])
|
return FakeProcess()
|
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw)
|
source = root / "source.mp4"
|
source.write_bytes(b"stub")
|
output = root / "result"
|
page_feature = np.zeros((8, 8), np.uint8)
|
logical_page = slides.LogicalPage(page_feature, page_feature, 0, 1.0, 0.2, 0)
|
with mock.patch.object(slides, "probe_video", return_value=slides.VideoInfo(640, 360, 10)), mock.patch.object(
|
slides, "scan_video", return_value=[logical_page]
|
), mock.patch.object(slides, "_which_or_error", return_value="ffmpeg"), mock.patch.object(
|
slides.subprocess, "Popen", side_effect=make_process
|
):
|
with self.assertRaises(KeyboardInterrupt):
|
slides.extract_slides(str(source), str(output))
|
self.assertFalse(output.exists())
|
self.assertFalse(any(root.glob(".result.staging-*")))
|
self.assertTrue(handles[0].closed)
|
self.assertEqual(events[1][0], "terminate")
|
self.assertEqual(events[2][0], "wait")
|
|
def test_keyboard_interrupt_reaps_scan_process_and_preserves_type(self):
|
events = []
|
|
class Stdout:
|
def read(self, size):
|
raise KeyboardInterrupt()
|
|
def close(self):
|
events.append("close")
|
|
class FakeProcess:
|
def __init__(self):
|
self.stdout = Stdout()
|
self.running = True
|
|
def wait(self, timeout=None):
|
events.append("wait")
|
self.running = False
|
return 0
|
|
def poll(self):
|
return None if self.running else 0
|
|
def terminate(self):
|
events.append("terminate")
|
|
def kill(self):
|
events.append("kill")
|
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw)
|
source = root / "source.mp4"
|
source.write_bytes(b"stub")
|
output = root / "result"
|
with mock.patch.object(
|
slides.subprocess, "Popen", return_value=FakeProcess()
|
), mock.patch.object(slides, "_which_or_error", return_value="ffmpeg"), mock.patch.object(
|
slides, "probe_video", return_value=slides.VideoInfo(640, 360, 10)
|
):
|
with self.assertRaises(KeyboardInterrupt):
|
slides.extract_slides(str(source), str(output))
|
self.assertFalse(output.exists())
|
self.assertFalse(any(root.glob(".result.staging-*")))
|
self.assertEqual(events[:3], ["close", "terminate", "wait"])
|
|
def test_stderr_open_failure_is_wrapped_with_stage(self):
|
with tempfile.TemporaryDirectory() as raw, mock.patch.object(
|
slides.Path, "open", side_effect=OSError("denied")
|
):
|
with self.assertRaisesRegex(slides.SlideExtractionError, "桩阶段失败:denied"):
|
slides._run_file_command(["fake"], Path(raw) / "stderr.log", "桩阶段")
|
|
def test_cleanup_error_does_not_replace_keyboard_interrupt(self):
|
class FakeProcess:
|
stdout = None
|
|
def wait(self, timeout=None):
|
raise KeyboardInterrupt()
|
|
def poll(self):
|
return None
|
|
def terminate(self):
|
raise OSError("cleanup denied")
|
|
with tempfile.TemporaryDirectory() as raw, mock.patch.object(
|
slides.subprocess, "Popen", return_value=FakeProcess()
|
):
|
with self.assertRaises(KeyboardInterrupt):
|
slides._run_file_command(["fake"], Path(raw) / "stderr.log", "桩阶段")
|
|
def test_stderr_close_error_preserves_interrupt_in_both_helpers(self):
|
class CloseFailingHandle:
|
def close(self):
|
raise OSError("stderr close denied")
|
|
def flush(self):
|
return None
|
|
class FileProcess:
|
stdout = None
|
|
def __init__(self):
|
self.calls = 0
|
|
def wait(self, timeout=None):
|
self.calls += 1
|
if self.calls == 1:
|
raise KeyboardInterrupt()
|
return 0
|
|
def poll(self):
|
return None
|
|
def terminate(self):
|
return None
|
|
class ScanStdout:
|
def read(self, size):
|
raise KeyboardInterrupt()
|
|
def close(self):
|
return None
|
|
class ScanProcess:
|
def __init__(self):
|
self.stdout = ScanStdout()
|
|
def wait(self, timeout=None):
|
return 0
|
|
def poll(self):
|
return None
|
|
def terminate(self):
|
return None
|
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw)
|
with mock.patch.object(slides.Path, "open", return_value=CloseFailingHandle()), mock.patch.object(
|
slides.subprocess, "Popen", return_value=FileProcess()
|
):
|
with self.assertRaises(KeyboardInterrupt):
|
slides._run_file_command(["fake"], root / "file.stderr.log", "文件阶段")
|
with mock.patch.object(slides.Path, "open", return_value=CloseFailingHandle()), mock.patch.object(
|
slides.subprocess, "Popen", return_value=ScanProcess()
|
), mock.patch.object(slides, "_which_or_error", return_value="ffmpeg"):
|
with self.assertRaises(KeyboardInterrupt):
|
slides.scan_video(Path("x.mp4"), root, slides.VideoInfo(640, 360, 10))
|
|
def test_stderr_close_error_is_staged_on_normal_and_ordinary_failure(self):
|
class CloseFailingHandle:
|
def close(self):
|
raise OSError("stderr close denied")
|
|
def flush(self):
|
return None
|
|
class FakeProcess:
|
stdout = None
|
|
def __init__(self, code):
|
self.code = code
|
|
def wait(self, timeout=None):
|
return self.code
|
|
def poll(self):
|
return self.code
|
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw)
|
with mock.patch.object(slides.Path, "open", return_value=CloseFailingHandle()), mock.patch.object(
|
slides.subprocess, "Popen", return_value=FakeProcess(0)
|
):
|
with self.assertRaisesRegex(slides.SlideExtractionError, "文件阶段关闭 stderr 失败"):
|
slides._run_file_command(["fake"], root / "normal.log", "文件阶段")
|
with mock.patch.object(slides.Path, "open", return_value=CloseFailingHandle()), mock.patch.object(
|
slides.subprocess, "Popen", return_value=FakeProcess(7)
|
), mock.patch.object(slides, "_tail", return_value="boom"):
|
with self.assertRaisesRegex(slides.SlideExtractionError, "文件阶段失败(exit=7)"):
|
slides._run_file_command(["fake"], root / "failed.log", "文件阶段")
|
|
|
class PdfTests(unittest.TestCase):
|
def test_pdf_rgb_content_and_order(self):
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw)
|
paths = []
|
for index, color in enumerate(((255, 20, 20), (20, 40, 255))):
|
path = root / f"{index}.png"
|
Image.new("RGB", (17 + index, 11 + index), color).save(path)
|
paths.append(path)
|
pdf = root / "slides.pdf"
|
slides.write_image_pdf(paths, pdf)
|
decoded = pdf_rgb_pages(pdf)
|
self.assertEqual([item[0] for item in decoded], [(17, 11), (18, 12)])
|
for path, (_, rgb) in zip(paths, decoded):
|
with Image.open(path) as image:
|
self.assertEqual(hashlib.sha256(rgb).digest(), hashlib.sha256(image.convert("RGB").tobytes()).digest())
|
pdfinfo = shutil.which("pdfinfo")
|
if pdfinfo and Path(pdfinfo).suffix.lower() not in {".cmd", ".bat"}:
|
result = subprocess.run([pdfinfo, str(pdf)], capture_output=True, text=True, check=True)
|
self.assertIn("Pages: 2", result.stdout)
|
|
def test_twenty_pages_keep_one_decoded_image_active(self):
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw)
|
paths = []
|
for index in range(20):
|
path = root / f"{index:02d}.png"
|
Image.new("RGB", (12, 8), (index, index * 2, index * 3)).save(path)
|
paths.append(path)
|
active = 0
|
maximum = 0
|
|
class CountedOpen:
|
def __init__(self, path):
|
self.image = Image.open(path)
|
|
def __enter__(self):
|
nonlocal active, maximum
|
active += 1
|
maximum = max(maximum, active)
|
return self.image
|
|
def __exit__(self, exc_type, exc, traceback):
|
nonlocal active
|
self.image.close()
|
active -= 1
|
|
pdf = root / "twenty.pdf"
|
slides.write_image_pdf(paths, pdf, image_opener=CountedOpen)
|
self.assertEqual(maximum, 1)
|
self.assertEqual(active, 0)
|
self.assertEqual(len(pdf_rgb_pages(pdf)), 20)
|
|
|
class SafetyAndIntegrationTests(unittest.TestCase):
|
def test_existing_output_is_rejected_before_external_process(self):
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw)
|
source = root / "source.mp4"
|
source.write_bytes(b"not a video")
|
for kind in ("directory", "file"):
|
with self.subTest(kind=kind):
|
output = root / f"existing-{kind}"
|
if kind == "directory":
|
output.mkdir()
|
else:
|
output.write_bytes(b"occupied")
|
with mock.patch.object(slides.subprocess, "Popen") as popen:
|
with self.assertRaisesRegex(slides.SlideExtractionError, "默认不覆盖"):
|
slides.extract_slides(str(source), str(output))
|
popen.assert_not_called()
|
|
def test_live_and_broken_output_links_are_rejected_by_lexical_precheck(self):
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw)
|
source = root / "source.mp4"
|
source.write_bytes(b"stub")
|
for link_kind in ("live", "broken"):
|
with self.subTest(link_kind=link_kind):
|
output = slides._absolute_lexical(root / f"{link_kind}-link")
|
original_lexists = slides._lexists
|
|
def controlled_lexists(path):
|
return True if path == output else original_lexists(path)
|
|
with mock.patch.object(slides, "_lexists", side_effect=controlled_lexists), mock.patch.object(
|
slides.subprocess, "Popen"
|
) as popen:
|
with self.assertRaisesRegex(slides.SlideExtractionError, "默认不覆盖"):
|
slides.extract_slides(str(source), str(output))
|
popen.assert_not_called()
|
|
def test_input_symlink_is_rejected_before_external_process(self):
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw)
|
source = slides._absolute_lexical(root / "source-link.mp4")
|
with mock.patch.object(slides, "_is_symlink", side_effect=lambda path: path == source), mock.patch.object(
|
slides.subprocess, "Popen"
|
) as popen:
|
with self.assertRaisesRegex(slides.SlideExtractionError, "拒绝符号链接"):
|
slides.extract_slides(str(source), str(root / "result"))
|
popen.assert_not_called()
|
|
def test_baseexception_removes_staging_and_leaves_no_formal_output(self):
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw)
|
source = root / "source.mp4"
|
source.write_bytes(b"stub")
|
output = root / "result"
|
with mock.patch.object(slides, "probe_video", side_effect=KeyboardInterrupt()):
|
with self.assertRaises(KeyboardInterrupt):
|
slides.extract_slides(str(source), str(output))
|
self.assertFalse(output.exists())
|
self.assertFalse(any(root.glob(".result.staging-*")))
|
|
def test_commit_conflict_is_wrapped_and_staging_is_removed(self):
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw)
|
source = root / "source.mp4"
|
source.write_bytes(b"stub")
|
output = root / "result"
|
with mock.patch.object(slides, "probe_video", return_value=slides.VideoInfo(640, 360, 10)), mock.patch.object(
|
slides, "scan_video", return_value=[]
|
), mock.patch.object(slides, "extract_original_pages", return_value=[]), mock.patch.object(
|
slides, "write_image_pdf"
|
), mock.patch.object(slides.os, "rename", side_effect=FileExistsError("race")):
|
with self.assertRaisesRegex(slides.SlideExtractionError, "提交正式输出目录失败:race"):
|
slides.extract_slides(str(source), str(output))
|
self.assertFalse(output.exists())
|
self.assertFalse(any(root.glob(".result.staging-*")))
|
|
@unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "FFmpeg is required")
|
def test_chinese_path_synthetic_video_end_to_end(self):
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw) / "中文路径"
|
root.mkdir()
|
frame_root = root / "frames"
|
frame_root.mkdir()
|
size = (640, 360)
|
|
def page_a(complete: bool):
|
image = Image.new("RGB", size, "white")
|
draw = ImageDraw.Draw(image)
|
draw.rectangle((0, 0, 639, 65), fill=(26, 78, 145))
|
draw.rectangle((55, 100, 585, 125), fill=(30, 30, 30))
|
if complete:
|
draw.rectangle((55, 165, 470, 190), fill=(30, 30, 30))
|
draw.rectangle((55, 230, 540, 255), fill=(30, 30, 30))
|
draw.rectangle((570, 320, 580, 330), fill=(220, 20, 20))
|
return image
|
|
def page_b():
|
image = Image.new("RGB", size, (18, 34, 68))
|
draw = ImageDraw.Draw(image)
|
draw.rectangle((65, 75, 575, 285), fill=(230, 190, 45))
|
draw.rectangle((110, 125, 530, 155), fill=(18, 34, 68))
|
return image
|
|
frames = [page_a(False)] * 3
|
frames += [Image.new("RGB", size, (127, 127, 127))]
|
frames += [page_a(True)] * 3
|
frames += [Image.new("RGB", size, (90, 20, 130))]
|
frames += [page_b()] * 3
|
frames += [Image.new("RGB", size, (20, 150, 90))]
|
frames += [page_a(True)] * 3
|
for index, image in enumerate(frames):
|
image.save(frame_root / f"frame_{index:03d}.png")
|
|
source = root / "合成会议录屏.mp4"
|
subprocess.run(
|
[
|
shutil.which("ffmpeg"), "-hide_banner", "-loglevel", "error", "-framerate", "1",
|
"-i", str(frame_root / "frame_%03d.png"), "-c:v", "libx264rgb", "-crf", "0", "-pix_fmt", "rgb24", str(source),
|
],
|
check=True,
|
)
|
before = (source.stat().st_size, source.stat().st_ctime_ns, source.stat().st_mtime_ns, sha256(source))
|
output = root / "验收输出"
|
result = subprocess.run(
|
[sys.executable, str(MODULE_PATH), str(source), "--output", str(output)],
|
capture_output=True,
|
text=True,
|
timeout=120,
|
)
|
self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
|
self.assertEqual(
|
{entry.name for entry in output.iterdir()},
|
{"pages", "合成会议录屏.slides.pdf"},
|
)
|
pngs = sorted((output / "pages").glob("slide_*.png"))
|
self.assertEqual(len(pngs), 2, result.stdout)
|
for png in pngs:
|
with Image.open(png) as image:
|
self.assertEqual(image.size, size)
|
expected = [page_a(True), page_b()]
|
for actual_path, expected_image in zip(pngs, expected):
|
with Image.open(actual_path) as actual:
|
self.assertEqual(
|
hashlib.sha256(actual.convert("RGB").tobytes()).digest(),
|
hashlib.sha256(expected_image.tobytes()).digest(),
|
)
|
decoded = pdf_rgb_pages(output / "合成会议录屏.slides.pdf")
|
self.assertEqual(len(decoded), 2)
|
for png, (_, rgb) in zip(pngs, decoded):
|
with Image.open(png) as image:
|
self.assertEqual(hashlib.sha256(rgb).digest(), hashlib.sha256(image.convert("RGB").tobytes()).digest())
|
after = (source.stat().st_size, source.stat().st_ctime_ns, source.stat().st_mtime_ns, sha256(source))
|
self.assertEqual(after, before)
|
self.assertFalse(any(root.glob(".验收输出.staging-*")))
|
|
|
if __name__ == "__main__":
|
unittest.main()
|