from __future__ import annotations
|
|
import csv
|
from datetime import datetime, timedelta, timezone
|
import hashlib
|
import importlib.util
|
import io
|
import json
|
import os
|
from pathlib import Path
|
import re
|
import shutil
|
import subprocess
|
import sys
|
import tempfile
|
import textwrap
|
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 palette(marker_bin: int | None = None, coverage: float = 0.20):
|
histogram = np.full(slides.PALETTE_BIN_COUNT, 1 / slides.PALETTE_BIN_COUNT, np.float32)
|
if marker_bin is not None:
|
histogram.fill((1.0 - coverage) / (slides.PALETTE_BIN_COUNT - 1))
|
histogram[marker_bin] = coverage
|
histogram /= float(np.sum(histogram, dtype=np.float64))
|
return histogram
|
|
|
def feature(
|
index: int,
|
array: np.ndarray,
|
score: float = 0.2,
|
sharpness: float | None = None,
|
page_palette: np.ndarray | 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(),
|
palette_histogram=(palette() if page_palette is None else page_palette.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,
|
page_palette: np.ndarray | None = None,
|
):
|
return slides.PageCandidate(
|
timestamp,
|
array.astype(np.uint8).copy(),
|
palette() if page_palette is None else page_palette.copy(),
|
phash,
|
score,
|
1.0,
|
)
|
|
|
def logical_page(order: int, page_palette: np.ndarray | None = None):
|
page_feature = np.full((8, 8), order % 256, np.uint8)
|
return slides.LogicalPage(
|
logical_anchor_feature=page_feature.copy(),
|
current_feature=page_feature,
|
current_palette=palette() if page_palette is None else page_palette.copy(),
|
current_hash=order,
|
current_timestamp=float(order),
|
current_score=0.2,
|
first_seen_order=order,
|
)
|
|
|
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(), palette(), 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 MainSequenceTests(unittest.TestCase):
|
def test_palette_histogram_distinguishes_dark_blue_from_black(self):
|
image = np.array(
|
[
|
[[16, 16, 48], [16, 16, 48]],
|
[[16, 16, 16], [16, 16, 16]],
|
],
|
dtype=np.uint8,
|
)
|
histogram = slides._palette_histogram(image)
|
self.assertEqual(histogram.shape, (512,))
|
self.assertTrue(np.all(np.isfinite(histogram)))
|
self.assertAlmostEqual(float(np.sum(histogram)), 1.0, places=6)
|
self.assertEqual(float(histogram[1]), 0.5)
|
self.assertEqual(float(histogram[0]), 0.5)
|
|
def test_selects_old_pages_002_through_038_and_keeps_two_gaps(self):
|
marker_pages = {2, *range(4, 9), *range(10, 39)}
|
pages = [
|
logical_page(index - 1, palette(1) if index in marker_pages else palette())
|
for index in range(1, 54)
|
]
|
selected = slides.select_main_sequence(pages)
|
self.assertEqual(selected, pages[1:38])
|
self.assertIs(selected[1], pages[2]) # old slide_003: first one-page gap
|
self.assertIs(selected[7], pages[8]) # old slide_009: second one-page gap
|
self.assertEqual([page.first_seen_order for page in selected], list(range(1, 38)))
|
|
def test_conservative_gates_keep_all_candidates(self):
|
cases = {
|
"markers": [palette(1) if index < 7 else palette() for index in range(10)],
|
"share": [palette(1) if 3 <= index <= 10 else palette() for index in range(20)],
|
"density": [
|
palette(1) if index in {0, 2, 4, 6, 7, 8, 9, 10} else palette()
|
for index in range(11)
|
],
|
}
|
for name, histograms in cases.items():
|
with self.subTest(name=name):
|
pages = [logical_page(index, value) for index, value in enumerate(histograms)]
|
selected = slides.select_main_sequence(pages)
|
self.assertEqual(selected, pages)
|
self.assertTrue(all(left is right for left, right in zip(selected, pages)))
|
|
def test_all_theme_pages_are_preserved(self):
|
pages = [logical_page(index, palette(7)) for index in range(12)]
|
selected = slides.select_main_sequence(pages)
|
self.assertEqual(selected, pages)
|
self.assertTrue(all(left is right for left, right in zip(selected, pages)))
|
|
def test_invalid_palette_is_explicit_failure(self):
|
invalid = [
|
np.zeros(5, np.float32),
|
np.full(512, np.nan, np.float32),
|
np.zeros(512, np.float32),
|
]
|
for value in invalid:
|
with self.subTest(shape=value.shape, finite=bool(np.all(np.isfinite(value)))):
|
with self.assertRaises(slides.SlideExtractionError):
|
slides.select_main_sequence([logical_page(0, value)])
|
|
|
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)
|
input_index = scan.index("-i")
|
self.assertLess(scan.index("-threads"), input_index)
|
self.assertEqual(scan[scan.index("-threads") + 1], str(slides.MEDIA_DECODE_THREADS))
|
self.assertEqual(scan[scan.index("-filter_threads") + 1], str(slides.MEDIA_FILTER_THREADS))
|
extract = slides._extract_command("ffmpeg", Path("x.mp4"), 12.0, Path("x.png"))
|
self.assertEqual(extract[extract.index("-ss") + 1], "12.000")
|
self.assertLess(extract.index("-threads"), extract.index("-i"))
|
self.assertEqual(feature(9, np.zeros((2, 2), np.uint8)).relative_seconds, 9.0)
|
|
def test_hardware_commands_place_decoder_and_threads_before_input(self):
|
for codec, decoder in (("h264", "h264_cuvid"), ("hevc", "hevc_cuvid")):
|
with self.subTest(codec=codec):
|
self.assertEqual(slides._decoder_for_codec(codec, "yuv420p"), decoder)
|
command = slides._scan_command(
|
"ffmpeg", Path(f"{codec}.mp4"), 320, 180, decoder
|
)
|
input_index = command.index("-i")
|
for option in ("-hwaccel", "-hwaccel_output_format", "-c:v", "-threads"):
|
self.assertLess(command.index(option), input_index)
|
self.assertEqual(command[command.index("-hwaccel") + 1], "cuda")
|
self.assertEqual(command[command.index("-c:v") + 1], decoder)
|
self.assertEqual(
|
command[command.index("-threads") + 1],
|
str(slides.MEDIA_DECODE_THREADS),
|
)
|
self.assertIn("hwdownload,format=nv12", command[command.index("-vf") + 1])
|
self.assertEqual(slides._decoder_for_codec("h264", "gbrp"), "h264_cuvid")
|
self.assertEqual(slides._decoder_for_codec("hevc", "p010le"), "hevc_cuvid")
|
self.assertIsNone(slides._decoder_for_codec("vp9", "yuv420p"))
|
|
def test_hardware_external_failures_have_typed_boundary(self):
|
class FakeStdout:
|
def __init__(self, payloads):
|
self.payloads = list(payloads)
|
|
def read(self, size):
|
return self.payloads.pop(0) if self.payloads else b""
|
|
def close(self):
|
return None
|
|
class FakeProcess:
|
def __init__(self, payloads, code):
|
self.stdout = FakeStdout(payloads)
|
self.code = code
|
|
def wait(self, timeout=None):
|
return self.code
|
|
def poll(self):
|
return self.code
|
|
def terminate(self):
|
return None
|
|
def kill(self):
|
return None
|
|
info = slides.VideoInfo(320, 180, 10.0, "h264", "yuv420p")
|
cases = (
|
("nonzero", FakeProcess([], 7), "exit=7"),
|
("zero", FakeProcess([], 0), "未输出任何完整"),
|
("partial", FakeProcess([b"x", b""], 0), "输出半帧"),
|
)
|
with tempfile.TemporaryDirectory() as raw, mock.patch.object(
|
slides, "_which_or_error", return_value="ffmpeg"
|
):
|
root = Path(raw)
|
with mock.patch.object(slides.subprocess, "Popen", side_effect=OSError("start denied")):
|
with self.assertRaisesRegex(slides.HardwareDecodePathError, "启动失败"):
|
slides._scan_video_once(Path("x.mp4"), root, info, decoder="h264_cuvid")
|
for name, process, pattern in cases:
|
with self.subTest(name=name), mock.patch.object(
|
slides.subprocess, "Popen", return_value=process
|
):
|
with self.assertRaisesRegex(slides.HardwareDecodePathError, pattern):
|
slides._scan_video_once(
|
Path("x.mp4"), root, info, decoder="h264_cuvid"
|
)
|
|
def test_hardware_cleanup_failure_does_not_trigger_cpu_fallback(self):
|
info = slides.VideoInfo(320, 180, 10.0, "h264", "yuv420p")
|
hardware_error = slides.HardwareDecodePathError("hardware-root")
|
slides._attach_cleanup_note(hardware_error, "cleanup-failed")
|
with tempfile.TemporaryDirectory() as raw, mock.patch.object(
|
slides, "_scan_video_once", side_effect=hardware_error
|
) as scan:
|
with self.assertRaises(slides.HardwareDecodePathError) as caught:
|
slides.scan_video(Path("x.mp4"), Path(raw), info)
|
self.assertIs(caught.exception, hardware_error)
|
self.assertEqual(scan.call_count, 1)
|
self.assertIn("cleanup-failed", "\n".join(caught.exception.__notes__))
|
|
def test_active_errors_keep_identity_when_process_cleanup_fails(self):
|
frame_bytes = 320 * 180 * 3
|
|
class Stream:
|
def __init__(self, error=None, partial=False):
|
self.error = error
|
self.partial = partial
|
self.calls = 0
|
|
def read(self, size):
|
self.calls += 1
|
if self.error is not None:
|
raise self.error
|
if self.partial:
|
return b"x" if self.calls == 1 else b""
|
return b"\0" * frame_bytes if self.calls == 1 else b""
|
|
def close(self):
|
return None
|
|
class FakeProcess:
|
def __init__(self, stream):
|
self.stdout = stream
|
|
def wait(self, timeout=None):
|
return 0
|
|
def poll(self):
|
return None
|
|
def terminate(self):
|
return None
|
|
cleanup = OSError("cleanup-failed")
|
info = slides.VideoInfo(320, 180, 10.0, "h264", "yuv420p")
|
cases = (
|
("business", None, ValueError("business-root")),
|
("cpu", None, slides.SlideExtractionError("cpu-root")),
|
("base", KeyboardInterrupt(), None),
|
)
|
for name, read_error, feature_error in cases:
|
root_error = read_error or feature_error
|
stream = Stream(error=read_error)
|
patches = [
|
mock.patch.object(slides, "_which_or_error", return_value="ffmpeg"),
|
mock.patch.object(slides.subprocess, "Popen", return_value=FakeProcess(stream)),
|
mock.patch.object(slides, "_attempt_reap", return_value=cleanup),
|
]
|
if feature_error is not None:
|
patches.append(mock.patch.object(slides, "_gray_feature", side_effect=feature_error))
|
with self.subTest(name=name), tempfile.TemporaryDirectory() as raw:
|
for item in patches:
|
item.start()
|
try:
|
with self.assertRaises(type(root_error)) as caught:
|
slides._scan_video_once(
|
Path("x.mp4"), Path(raw), info, decoder=None
|
)
|
self.assertIs(caught.exception, root_error)
|
self.assertIn("cleanup-failed", "\n".join(caught.exception.__notes__))
|
finally:
|
for item in reversed(patches):
|
item.stop()
|
|
partial_process = FakeProcess(Stream(partial=True))
|
with tempfile.TemporaryDirectory() as raw, mock.patch.object(
|
slides, "_which_or_error", return_value="ffmpeg"
|
), mock.patch.object(slides.subprocess, "Popen", return_value=partial_process), mock.patch.object(
|
slides, "_attempt_reap", return_value=cleanup
|
):
|
with self.assertRaises(slides.HardwareDecodePathError) as caught:
|
slides._scan_video_once(
|
Path("x.mp4"), Path(raw), info, decoder="h264_cuvid"
|
)
|
hardware_error = caught.exception
|
self.assertTrue(getattr(hardware_error, "_media_cleanup_failed", False))
|
self.assertIn("cleanup-failed", "\n".join(hardware_error.__notes__))
|
with tempfile.TemporaryDirectory() as raw, mock.patch.object(
|
slides, "_scan_video_once", side_effect=hardware_error
|
) as scan:
|
with self.assertRaises(slides.HardwareDecodePathError) as caught_again:
|
slides.scan_video(Path("x.mp4"), Path(raw), info)
|
self.assertIs(caught_again.exception, hardware_error)
|
self.assertEqual(scan.call_count, 1)
|
|
def test_product_popen_uses_below_normal_creation_flags(self):
|
class FakeProcess:
|
stdout = None
|
|
def wait(self, timeout=None):
|
return 0
|
|
def poll(self):
|
return 0
|
|
with tempfile.TemporaryDirectory() as raw, mock.patch.object(
|
slides, "_subprocess_creation_flags", return_value=12345
|
), mock.patch.object(slides.subprocess, "Popen", return_value=FakeProcess()) as popen:
|
slides._run_file_command(["tool"], Path(raw) / "stderr.log", "stage")
|
self.assertEqual(popen.call_args.kwargs["creationflags"], 12345)
|
|
def test_fallback_only_handles_hardware_error_and_never_retries_cpu(self):
|
info = slides.VideoInfo(320, 180, 10.0, "h264", "yuv420p")
|
page = logical_page(0)
|
success = slides.ScanResult((page,), 10, 2, (("new_page", 1),), None)
|
hardware_error = slides.HardwareDecodePathError("decoder failed")
|
with tempfile.TemporaryDirectory() as raw, mock.patch.object(
|
slides, "_scan_video_once", side_effect=[hardware_error, success]
|
) as scan, mock.patch("sys.stderr", new_callable=io.StringIO) as stderr:
|
self.assertEqual(slides.scan_video(Path("x.mp4"), Path(raw), info), [page])
|
self.assertEqual([call.kwargs["decoder"] for call in scan.call_args_list], [
|
"h264_cuvid", None
|
])
|
self.assertIn("decoder failed", stderr.getvalue())
|
|
cpu_error = slides.SlideExtractionError("cpu root cause")
|
with tempfile.TemporaryDirectory() as raw, mock.patch.object(
|
slides, "_scan_video_once", side_effect=[hardware_error, cpu_error]
|
) as scan:
|
with self.assertRaises(slides.SlideExtractionError) as caught:
|
slides.scan_video(Path("x.mp4"), Path(raw), info)
|
self.assertIs(caught.exception, cpu_error)
|
self.assertEqual(scan.call_count, 2)
|
|
def test_algorithm_empty_and_baseexception_never_fall_back(self):
|
info = slides.VideoInfo(320, 180, 10.0, "h264", "yuv420p")
|
empty = slides.ScanResult((), 10, 0, (), "h264_cuvid")
|
with tempfile.TemporaryDirectory() as raw, mock.patch.object(
|
slides, "_scan_video_once", return_value=empty
|
) as scan:
|
with self.assertRaisesRegex(slides.SlideExtractionError, "未发现"):
|
slides.scan_video(Path("x.mp4"), Path(raw), info)
|
scan.assert_called_once()
|
for error in (RuntimeError("classifier failed"), KeyboardInterrupt(), SystemExit(9)):
|
with self.subTest(error=type(error).__name__), tempfile.TemporaryDirectory() as raw, mock.patch.object(
|
slides, "_scan_video_once", side_effect=error
|
) as scan:
|
with self.assertRaises(type(error)) as caught:
|
slides.scan_video(Path("x.mp4"), Path(raw), info)
|
self.assertIs(caught.exception, error)
|
scan.assert_called_once()
|
|
def test_algorithm_exception_inside_hardware_scan_preserves_original_type(self):
|
frame_bytes = 320 * 180 * 3
|
|
class FullFrameStream:
|
def __init__(self):
|
self.done = False
|
|
def read(self, size):
|
if self.done:
|
return b""
|
self.done = True
|
return b"\0" * frame_bytes
|
|
def close(self):
|
return None
|
|
class FakeProcess:
|
stdout = FullFrameStream()
|
|
def wait(self, timeout=None):
|
return 0
|
|
def poll(self):
|
return 0
|
|
root_error = ValueError("feature root cause")
|
info = slides.VideoInfo(320, 180, 10.0, "h264", "yuv420p")
|
with tempfile.TemporaryDirectory() as raw, mock.patch.object(
|
slides, "_which_or_error", return_value="ffmpeg"
|
), mock.patch.object(slides.subprocess, "Popen", return_value=FakeProcess()), mock.patch.object(
|
slides, "_gray_feature", side_effect=root_error
|
):
|
with self.assertRaises(ValueError) as caught:
|
slides._scan_video_once(
|
Path("x.mp4"), Path(raw), info, decoder="h264_cuvid"
|
)
|
self.assertIs(caught.exception, root_error)
|
|
@unittest.skipUnless(shutil.which("powershell"), "Windows PowerShell is required")
|
def test_resource_sampler_parser_rejects_cpu_boundaries_and_empty_windows(self):
|
sampler = Path(__file__).resolve().parent / "monitor_media_host_responsiveness.ps1"
|
|
fields = (
|
"timestamp_utc", "previous_valid_timestamp_utc", "interval_seconds",
|
"counter_warmup", "cpu_valid", "host_cpu_percent",
|
"scan_phase_active", "scan_ffmpeg_present", "scan_ffmpeg_exit_observed",
|
"scan_ffmpeg_pid", "scan_ffmpeg_command_line", "decoder",
|
"logical_processor_count", "task_cpu_percent", "task_working_set_bytes",
|
"task_pids", "root_priority", "ffmpeg_priorities", "ffmpeg_command_lines",
|
"gpu_query_status", "gpu_query_raw_rows", "gpu_utilization_percent",
|
"gpu_decoder_utilization_percent", "gpu_memory_used_mib",
|
"nvidia_smi_compute_query_status", "nvidia_smi_compute_raw_rows",
|
"nvidia_smi_compute_task_rows",
|
"gpu_process_memory_query_status", "gpu_process_memory_instances",
|
"task_gpu_dedicated_memory_mib",
|
)
|
scan_command = (
|
"ffmpeg -hwaccel cuda -hwaccel_output_format cuda "
|
"-c:v h264_cuvid -threads 4 "
|
"-filter_threads 2 -filter_complex_threads 2 -i x.mp4 -map 0:v:0 "
|
"-vf hwdownload,format=nv12,fps=fps=1:start_time=0 "
|
"-pix_fmt rgb24 -f rawvideo -"
|
)
|
|
def make_row(current, previous, interval, cpu, *, warmup, present, exited):
|
return {
|
"timestamp_utc": current.isoformat().replace("+00:00", "Z"),
|
"previous_valid_timestamp_utc": (
|
"" if previous is None else previous.isoformat().replace("+00:00", "Z")
|
),
|
"interval_seconds": f"{interval:.6f}",
|
"counter_warmup": str(warmup).lower(),
|
"cpu_valid": str(not warmup).lower(),
|
"host_cpu_percent": "" if warmup else f"{cpu:.6f}",
|
"scan_phase_active": "true",
|
"scan_ffmpeg_present": str(present).lower(),
|
"scan_ffmpeg_exit_observed": str(exited).lower(),
|
"scan_ffmpeg_pid": "4242",
|
"scan_ffmpeg_command_line": scan_command,
|
"decoder": "h264_cuvid",
|
"logical_processor_count": "44",
|
"task_cpu_percent": "" if warmup else "12.5",
|
"task_working_set_bytes": "123456789",
|
"task_pids": "101;4242",
|
"root_priority": "BelowNormal",
|
"ffmpeg_priorities": (
|
"[]" if not present else '[{"pid":4242,"priority":"BelowNormal"}]'
|
),
|
"ffmpeg_command_lines": (
|
'[{"pid":4242,"command_line":' + json.dumps(scan_command) + "}]"
|
),
|
"gpu_query_status": "ok",
|
"gpu_query_raw_rows": '["20, 31, 2048"]',
|
"gpu_utilization_percent": "20",
|
"gpu_decoder_utilization_percent": "31",
|
"gpu_memory_used_mib": "2048",
|
"nvidia_smi_compute_query_status": "ok",
|
"nvidia_smi_compute_raw_rows": '["4242, N/A"]',
|
"nvidia_smi_compute_task_rows": '["4242, N/A"]',
|
"gpu_process_memory_query_status": "ok",
|
"gpu_process_memory_instances": (
|
'[{"pid":4242,"instance_name":"pid_4242_luid_0",'
|
'"counter_path":"path","dedicated_usage_bytes":1048576}]'
|
),
|
"task_gpu_dedicated_memory_mib": "1",
|
}
|
|
def analyze_case(root, name, intervals, cpu_values, mutate=None, omit_field=None):
|
case = root / name
|
case.mkdir()
|
samples = case / "samples.csv"
|
current = datetime(2026, 8, 4, tzinfo=timezone.utc)
|
rows = [make_row(current, None, 0, 0, warmup=True, present=True, exited=False)]
|
previous = current
|
for interval, cpu in zip(intervals, cpu_values):
|
current += timedelta(seconds=interval)
|
rows.append(make_row(
|
current, previous, interval, cpu,
|
warmup=False, present=True, exited=False,
|
))
|
previous = current
|
current += timedelta(seconds=0.5)
|
rows.append(make_row(
|
current, previous, 0.5, 20.0,
|
warmup=False, present=False, exited=True,
|
))
|
if mutate is not None:
|
mutate(rows)
|
output_fields = tuple(field for field in fields if field != omit_field)
|
with samples.open("w", newline="", encoding="utf-8") as handle:
|
writer = csv.DictWriter(handle, fieldnames=output_fields, extrasaction="ignore")
|
writer.writeheader()
|
writer.writerows(rows)
|
evidence = case / "evidence"
|
result = subprocess.run(
|
[
|
shutil.which("powershell"), "-NoProfile", "-File", str(sampler),
|
"-AnalyzeSamplesPath", str(samples), "-EvidenceDirectory", str(evidence),
|
],
|
capture_output=True,
|
text=True,
|
timeout=60,
|
)
|
self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
|
return json.loads((evidence / "resource_summary.json").read_text(encoding="utf-8-sig"))
|
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw)
|
low = analyze_case(root, "low", [0.5] * 22, [84.9] * 22)
|
self.assertTrue(low["sampling_valid"])
|
self.assertGreater(low["window_count"], 0)
|
self.assertTrue(low["cpu_gate_pass"])
|
|
equal = analyze_case(root, "equal", [0.5] * 22, [85.0] * 22)
|
self.assertTrue(equal["sampling_valid"])
|
self.assertFalse(equal["all_windows_host_cpu_strictly_below_85"])
|
self.assertFalse(equal["cpu_gate_pass"])
|
|
exact_ten = analyze_case(
|
root, "exact-ten", [0.5] * 21, [85.0] * 20 + [20.0]
|
)
|
self.assertEqual(exact_ten["maximum_high_cpu_segment_seconds"], 10.0)
|
self.assertFalse(exact_ten["cpu_gate_pass"])
|
|
below_ten = analyze_case(
|
root, "below-ten", [0.49995] * 20 + [0.5], [85.0] * 20 + [20.0]
|
)
|
self.assertEqual(below_ten["maximum_high_cpu_segment_seconds"], 9.999)
|
|
sparse = analyze_case(root, "sparse", [1.0] * 12, [20.0] * 12)
|
self.assertFalse(sparse["sampling_valid"])
|
self.assertTrue(any("window_density_invalid" in item for item in sparse["sampling_problems"]))
|
|
zero_window = analyze_case(root, "zero-window", [0.5] * 8, [20.0] * 8)
|
self.assertFalse(zero_window["sampling_valid"])
|
self.assertEqual(zero_window["window_count"], 0)
|
|
gap = analyze_case(
|
root, "gap", [0.5] * 10 + [1.6] + [0.5] * 10, [20.0] * 21
|
)
|
self.assertFalse(gap["sampling_valid"])
|
self.assertGreater(gap["maximum_observed_interval_seconds"], 1.5)
|
|
reversed_time = analyze_case(
|
root, "time-reversal", [0.5] * 12, [20.0] * 12,
|
mutate=lambda rows: rows[5].__setitem__("timestamp_utc", rows[3]["timestamp_utc"]),
|
)
|
self.assertFalse(reversed_time["sampling_valid"])
|
self.assertTrue(any(
|
"timestamp_not_strictly_increasing" in item
|
for item in reversed_time["sampling_problems"]
|
))
|
|
interval_mismatch = analyze_case(
|
root, "interval-mismatch", [0.5] * 12, [20.0] * 12,
|
mutate=lambda rows: rows[4].__setitem__("interval_seconds", "9.0"),
|
)
|
self.assertFalse(interval_mismatch["sampling_valid"])
|
self.assertTrue(any(
|
"interval_mismatch" in item for item in interval_mismatch["sampling_problems"]
|
))
|
|
previous_mismatch = analyze_case(
|
root, "previous-mismatch", [0.5] * 12, [20.0] * 12,
|
mutate=lambda rows: rows[4].__setitem__(
|
"previous_valid_timestamp_utc", rows[1]["timestamp_utc"]
|
),
|
)
|
self.assertFalse(previous_mismatch["sampling_valid"])
|
self.assertTrue(any(
|
"previous_timestamp_mismatch" in item
|
for item in previous_mismatch["sampling_problems"]
|
))
|
|
for omitted in (
|
"gpu_query_raw_rows", "task_pids", "ffmpeg_command_lines",
|
"nvidia_smi_compute_task_rows",
|
):
|
with self.subTest(omitted=omitted):
|
missing_field = analyze_case(
|
root, f"missing-{omitted}", [0.5] * 12, [20.0] * 12,
|
omit_field=omitted,
|
)
|
self.assertFalse(missing_field["sampling_valid"])
|
self.assertIn(
|
f"required_column_missing_{omitted}",
|
missing_field["sampling_problems"],
|
)
|
|
first_not_present = analyze_case(
|
root, "first-coverage", [0.5] * 12, [20.0] * 12,
|
mutate=lambda rows: rows[0].__setitem__("scan_ffmpeg_present", "false"),
|
)
|
self.assertFalse(first_not_present["sampling_valid"])
|
self.assertIn("scan_phase_first_row_not_present", first_not_present["sampling_problems"])
|
|
last_not_exit = analyze_case(
|
root, "last-coverage", [0.5] * 12, [20.0] * 12,
|
mutate=lambda rows: rows[-1].__setitem__("scan_ffmpeg_exit_observed", "false"),
|
)
|
self.assertFalse(last_not_exit["sampling_valid"])
|
self.assertIn("scan_phase_last_row_not_exit_observation", last_not_exit["sampling_problems"])
|
|
argv_rewrites = {
|
"missing-output-format": lambda value: value.replace(
|
"-hwaccel_output_format cuda ", ""
|
),
|
"wrong-output-format": lambda value: value.replace(
|
"-hwaccel_output_format cuda", "-hwaccel_output_format qsv"
|
),
|
"late-output-format": lambda value: value.replace(
|
"-hwaccel_output_format cuda ", ""
|
).replace("-i x.mp4", "-i x.mp4 -hwaccel_output_format cuda"),
|
"missing-hwdownload": lambda value: value.replace(
|
"hwdownload,format=nv12,", ""
|
),
|
}
|
for name, rewrite in argv_rewrites.items():
|
with self.subTest(argv_case=name):
|
invalid_argv = analyze_case(
|
root, f"invalid-{name}", [0.5] * 12, [20.0] * 12,
|
mutate=lambda rows, transform=rewrite: [
|
row.__setitem__(
|
"scan_ffmpeg_command_line",
|
transform(row["scan_ffmpeg_command_line"]),
|
)
|
for row in rows
|
],
|
)
|
self.assertFalse(invalid_argv["sampling_valid"])
|
self.assertIn("nvdec_scan_argv_invalid", invalid_argv["sampling_problems"])
|
|
@unittest.skipUnless(
|
os.name == "nt" and shutil.which("powershell") and shutil.which("nvidia-smi"),
|
"Windows PowerShell and nvidia-smi are required",
|
)
|
def test_resource_sampler_run_rejects_forged_nvdec_and_excludes_page_ffmpeg(self):
|
sampler = Path(__file__).resolve().parent / "monitor_media_host_responsiveness.ps1"
|
sampler_text = sampler.read_text(encoding="utf-8")
|
process_start = sampler_text.index("[void]$Process.Start()")
|
self.assertLess(
|
sampler_text.index("$HostCpuCounter = New-Object"), process_start
|
)
|
self.assertLess(sampler_text.index("$HostCpuCounter.NextValue()"), process_start)
|
self.assertLess(
|
sampler_text.index("$GpuMemoryCategory = New-Object"), process_start
|
)
|
self.assertLess(sampler_text.index("$GpuMemoryCategory.GetInstanceNames()"), process_start)
|
self.assertLess(
|
sampler_text.index('$StartInfo.EnvironmentVariables["MBX_MEDIA_SAMPLER_READY"]'),
|
process_start,
|
)
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw)
|
fake_ffmpeg = root / "ffmpeg.exe"
|
shutil.copy2(sys.executable, fake_ffmpeg)
|
child = root / "fake_ffmpeg_child.py"
|
child.write_text(
|
"import sys, time\n"
|
"time.sleep(float(sys.argv[sys.argv.index('--sleep') + 1]))\n",
|
encoding="utf-8",
|
)
|
target = root / "stub_media_target.py"
|
target.write_text(
|
textwrap.dedent(
|
f"""
|
import ctypes
|
import os
|
from pathlib import Path
|
import subprocess
|
import sys
|
import time
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stderr.reconfigure(encoding="utf-8")
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
kernel32.SetPriorityClass(kernel32.GetCurrentProcess(), 0x00004000)
|
creationflags = 0x00004000
|
if os.environ.get("MBX_MEDIA_SAMPLER_READY") != "1":
|
raise RuntimeError("sampler-ready handshake missing")
|
fake_ffmpeg = Path({str(fake_ffmpeg)!r})
|
child = Path({str(child)!r})
|
scan = subprocess.Popen([
|
str(fake_ffmpeg), str(child), "--sleep", "7.0",
|
"-hwaccel", "cuda", "-c:v", "h264_cuvid",
|
"-threads", "4", "-filter_threads", "2",
|
"-filter_complex_threads", "2", "-i", "fake.mp4",
|
"-map", "0:v:0", "-vf",
|
"hwdownload,format=nv12,setpts=PTS-STARTPTS,"
|
"fps=fps=1:start_time=0,scale=320:180",
|
"-pix_fmt", "rgb24", "-f", "rawvideo", "-",
|
], creationflags=creationflags)
|
scan.wait()
|
print("警告:NVDEC 不可用;改用受限 CPU。", file=sys.stderr, flush=True)
|
print(
|
"扫描解码路径:NVDEC/CUDA (h264_cuvid);解码线程=4;滤镜线程=2",
|
flush=True,
|
)
|
time.sleep(1.5)
|
for index in range(24):
|
churn = subprocess.Popen([
|
str(fake_ffmpeg), str(child), "--sleep", "0.04",
|
"-threads", "4", "-i", "fake.mp4",
|
"-frames:v", "1", "-n", f"race-{{index}}.png",
|
], creationflags=creationflags)
|
churn.wait()
|
page = subprocess.Popen([
|
str(fake_ffmpeg), str(child), "--sleep", "4.0",
|
"-threads", "4", "-i", "fake.mp4",
|
"-frames:v", "1", "-n", "fake.png",
|
], creationflags=creationflags)
|
page.wait()
|
"""
|
).lstrip(),
|
encoding="utf-8",
|
)
|
source = root / "source.stub"
|
source.write_bytes(b"read-only source fingerprint")
|
evidence = root / "evidence"
|
result = subprocess.run(
|
[
|
shutil.which("powershell"), "-NoProfile", "-File", str(sampler),
|
"-PythonPath", sys.executable,
|
"-ScriptPath", str(target),
|
"-VideoPath", str(source),
|
"-OutputPath", str(root / "unused-output"),
|
"-EvidenceDirectory", str(evidence),
|
],
|
capture_output=True,
|
text=True,
|
encoding="utf-8",
|
errors="replace",
|
env={
|
**os.environ,
|
"PYTHONIOENCODING": "utf-8",
|
"PYTHONUTF8": "1",
|
"MBX_MEDIA_SAMPLER_TEST_CIM_DELAY_MS": "100",
|
"MBX_MEDIA_SAMPLER_TEST_FAIL_SAMPLE_ONCE": "1",
|
},
|
timeout=90,
|
)
|
self.assertEqual(result.returncode, 3, result.stderr + result.stdout)
|
run_summary = json.loads(
|
(evidence / "run_summary.json").read_text(encoding="utf-8-sig")
|
)
|
self.assertEqual(run_summary["target_exit_code"], 0)
|
self.assertFalse(run_summary["resource_acceptance_pass"])
|
self.assertTrue(
|
run_summary["nvdec_command_log_evidence"],
|
json.dumps(
|
{
|
"run_summary": run_summary,
|
"stdout": (evidence / "stdout.log").read_text("utf-8"),
|
},
|
ensure_ascii=False,
|
),
|
)
|
self.assertFalse(run_summary["nvdec_scan_argv_evidence"])
|
self.assertGreater(run_summary["command_line_disappearance_race_count"], 0)
|
self.assertEqual(run_summary["injected_sample_failure_count"], 1)
|
self.assertEqual(
|
len(run_summary["sampling_errors"]), 1, run_summary["sampling_errors"]
|
)
|
self.assertIn("injected post-scan sample failure", run_summary["sampling_errors"][0])
|
self.assertTrue(run_summary["source_unchanged"])
|
self.assertIn("NVDEC/CUDA (h264_cuvid)", (evidence / "stdout.log").read_text("utf-8"))
|
self.assertIn("改用受限 CPU", (evidence / "stderr.log").read_text("utf-8"))
|
|
with (evidence / "resource_samples.csv").open(
|
newline="", encoding="utf-8-sig"
|
) as handle:
|
rows = list(csv.DictReader(handle))
|
resource_summary = json.loads(
|
(evidence / "resource_summary.json").read_text(encoding="utf-8-sig")
|
)
|
self.assertFalse(resource_summary["sampling_valid"], resource_summary)
|
self.assertEqual(
|
resource_summary["sampling_problems"], ["nvdec_scan_argv_invalid"]
|
)
|
self.assertTrue(resource_summary["all_windows_host_cpu_strictly_below_85"])
|
self.assertTrue(resource_summary["window_count"] > 0)
|
self.assertFalse(any(
|
"previous_timestamp" in problem or "interval_mismatch" in problem
|
for problem in resource_summary["sampling_problems"]
|
))
|
for index, row in enumerate(rows):
|
if index == 0:
|
self.assertEqual(row["previous_valid_timestamp_utc"], "")
|
self.assertAlmostEqual(float(row["interval_seconds"]), 0.0, places=6)
|
continue
|
current = datetime.fromisoformat(row["timestamp_utc"].replace("Z", "+00:00"))
|
previous = datetime.fromisoformat(
|
rows[index - 1]["timestamp_utc"].replace("Z", "+00:00")
|
)
|
reported_previous = datetime.fromisoformat(
|
row["previous_valid_timestamp_utc"].replace("Z", "+00:00")
|
)
|
self.assertLess(abs((reported_previous - previous).total_seconds()), 0.001)
|
self.assertLess(
|
abs(float(row["interval_seconds"]) - (current - previous).total_seconds()),
|
0.001,
|
)
|
active_indexes = [
|
index for index, row in enumerate(rows)
|
if row["scan_phase_active"].lower() == "true"
|
]
|
self.assertGreaterEqual(
|
len(active_indexes), 2,
|
json.dumps({"rows": rows, "run_summary": run_summary}, ensure_ascii=False),
|
)
|
self.assertEqual(len({rows[index]["scan_ffmpeg_pid"] for index in active_indexes}), 1)
|
last_active = active_indexes[-1]
|
self.assertEqual(rows[last_active]["scan_ffmpeg_present"].lower(), "false")
|
self.assertEqual(rows[last_active]["scan_ffmpeg_exit_observed"].lower(), "true")
|
later_rows = rows[last_active + 1 :]
|
self.assertTrue(later_rows)
|
self.assertTrue(any("-frames:v" in row["ffmpeg_command_lines"] for row in later_rows))
|
self.assertTrue(all(row["scan_phase_active"].lower() == "false" for row in later_rows))
|
|
@unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "FFmpeg is required")
|
def test_h264_h265_nvdec_and_cpu_product_chains_are_equivalent(self):
|
ffmpeg = shutil.which("ffmpeg")
|
capabilities = subprocess.check_output(
|
[ffmpeg, "-hide_banner", "-encoders"], text=True, errors="replace"
|
) + subprocess.check_output(
|
[ffmpeg, "-hide_banner", "-decoders"], text=True, errors="replace"
|
)
|
required = ("libx264", "libx265", "h264_cuvid", "hevc_cuvid")
|
missing = [name for name in required if name not in capabilities]
|
if missing:
|
self.skipTest(f"missing FFmpeg capabilities: {missing}")
|
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw)
|
frames_dir = root / "frames"
|
frames_dir.mkdir()
|
size = (320, 180)
|
|
def page(kind, animated=False):
|
colors = {"a": (245, 245, 240), "b": (20, 45, 85), "c": (232, 210, 70)}
|
image = Image.new("RGB", size, colors[kind])
|
draw = ImageDraw.Draw(image)
|
draw.rectangle((0, 0, 319, 30), fill=(25, 90, 160))
|
draw.rectangle((30, 55, 285, 70), fill=(30, 30, 30))
|
if kind != "a" or animated:
|
draw.rectangle((30, 95, 250, 110), fill=(35, 35, 35))
|
if animated:
|
draw.rectangle((260, 140, 280, 160), fill=(210, 30, 30))
|
return image
|
|
transition_colors = ((100, 100, 100), (120, 25, 130), (20, 150, 90), (180, 65, 20))
|
frames = [page("a")] * 3 + [Image.new("RGB", size, transition_colors[0])]
|
frames += [page("a", True)] * 3 + [Image.new("RGB", size, transition_colors[1])]
|
frames += [page("b")] * 3 + [Image.new("RGB", size, transition_colors[2])]
|
frames += [page("a", True)] * 3 + [Image.new("RGB", size, transition_colors[3])]
|
frames += [page("c")] * 3
|
for index, image in enumerate(frames):
|
image.save(frames_dir / f"frame_{index:03d}.png")
|
|
codec_settings = {
|
"h264": ("libx264", "h264_cuvid"),
|
"hevc": ("libx265", "hevc_cuvid"),
|
}
|
evidence = []
|
for codec, (encoder, decoder) in codec_settings.items():
|
source = root / f"sequence-{codec}.mp4"
|
command = [
|
ffmpeg, "-hide_banner", "-loglevel", "error", "-framerate", "1",
|
"-i", str(frames_dir / "frame_%03d.png"), "-threads", "4",
|
"-c:v", encoder, "-preset", "ultrafast",
|
]
|
if codec == "h264":
|
command.extend(("-qp", "0"))
|
else:
|
command.extend(("-x265-params", "lossless=1:pools=4"))
|
command.extend(("-pix_fmt", "yuv420p", "-n", str(source)))
|
subprocess.run(command, check=True, timeout=120)
|
probe_staging = root / f"probe-{codec}"
|
probe_staging.mkdir()
|
info = slides.probe_video(source, probe_staging)
|
self.assertEqual(info.codec_name, codec)
|
self.assertEqual(info.pixel_format, "yuv420p")
|
|
hardware_staging = root / f"scan-{codec}-hardware"
|
cpu_staging = root / f"scan-{codec}-cpu"
|
hardware_staging.mkdir()
|
cpu_staging.mkdir()
|
hardware = slides._scan_video_once(
|
source, hardware_staging, info, decoder=decoder
|
)
|
cpu = slides._scan_video_once(source, cpu_staging, info, decoder=None)
|
self.assertEqual(hardware.sample_count, cpu.sample_count)
|
self.assertEqual(hardware.stable_candidate_count, cpu.stable_candidate_count)
|
self.assertEqual(hardware.classification_counts, cpu.classification_counts)
|
self.assertEqual(len(hardware.pages), len(cpu.pages))
|
hardware_selected = slides.select_main_sequence(hardware.pages)
|
cpu_selected = slides.select_main_sequence(cpu.pages)
|
self.assertEqual(
|
[page.first_seen_order for page in hardware_selected],
|
[page.first_seen_order for page in cpu_selected],
|
)
|
self.assertEqual(len(hardware_selected), len(cpu_selected))
|
self.assertGreaterEqual(len(hardware_selected), 3)
|
for hardware_page, cpu_page in zip(hardware_selected, cpu_selected):
|
self.assertAlmostEqual(
|
hardware_page.current_timestamp,
|
cpu_page.current_timestamp,
|
delta=0.001,
|
)
|
|
product_summaries = []
|
for label, selected in (("hardware", hardware_selected), ("cpu", cpu_selected)):
|
output = root / f"product-{codec}-{label}"
|
output.mkdir()
|
pngs = slides.extract_original_pages(source, output, info, selected)
|
pdf = output / "slides.pdf"
|
slides.write_image_pdf(pngs, pdf)
|
png_summary = []
|
for path in pngs:
|
with Image.open(path) as image:
|
png_summary.append((path.name, image.mode, image.size))
|
product_summaries.append((png_summary, pdf_rgb_pages(pdf)))
|
self.assertEqual(product_summaries[0][0], product_summaries[1][0])
|
self.assertEqual(
|
[page[0] for page in product_summaries[0][1]],
|
[page[0] for page in product_summaries[1][1]],
|
)
|
self.assertEqual(
|
len(product_summaries[0][1]), len(product_summaries[1][1])
|
)
|
hardware_command = slides._scan_command(
|
ffmpeg, source, *slides._scan_size(info), decoder
|
)
|
cpu_command = slides._scan_command(
|
ffmpeg, source, *slides._scan_size(info), None
|
)
|
evidence.extend((
|
{
|
"codec": codec,
|
"path": "nvdec",
|
"decoder": decoder,
|
"argv": hardware_command,
|
"samples": hardware.sample_count,
|
"stable_candidates": hardware.stable_candidate_count,
|
"classifications": dict(hardware.classification_counts),
|
"selected_pages": len(hardware_selected),
|
"timestamps": [page.current_timestamp for page in hardware_selected],
|
},
|
{
|
"codec": codec,
|
"path": "cpu",
|
"decoder": None,
|
"argv": cpu_command,
|
"samples": cpu.sample_count,
|
"stable_candidates": cpu.stable_candidate_count,
|
"classifications": dict(cpu.classification_counts),
|
"selected_pages": len(cpu_selected),
|
"timestamps": [page.current_timestamp for page in cpu_selected],
|
},
|
))
|
print("MEDIA_HOST_SYNTHETIC_EVIDENCE=" + json.dumps(evidence, ensure_ascii=False))
|
|
@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 = logical_page(0)
|
with mock.patch.object(slides, "probe_video", return_value=slides.VideoInfo(640, 360, 10)), mock.patch.object(
|
slides, "scan_video", return_value=[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_main_sequence_filter_runs_once_before_original_extraction(self):
|
with tempfile.TemporaryDirectory() as raw:
|
root = Path(raw)
|
source = root / "source.mp4"
|
source.write_bytes(b"stub")
|
output = root / "result"
|
candidates = [logical_page(index, palette(1)) for index in range(10)]
|
selected = candidates[1:9]
|
with mock.patch.object(
|
slides, "probe_video", return_value=slides.VideoInfo(640, 360, 10)
|
), mock.patch.object(
|
slides, "scan_video", return_value=candidates
|
), mock.patch.object(
|
slides, "select_main_sequence", return_value=selected
|
) as sequence_filter, mock.patch.object(
|
slides, "extract_original_pages", return_value=[]
|
) as original_extraction, mock.patch.object(slides, "write_image_pdf"):
|
self.assertEqual(slides.extract_slides(str(source), str(output)), output)
|
sequence_filter.assert_called_once_with(candidates)
|
self.assertIs(original_extraction.call_args.args[3], selected)
|
|
def test_selected_pages_are_renumbered_continuously(self):
|
with tempfile.TemporaryDirectory() as raw:
|
staging = Path(raw)
|
selected = [logical_page(index, palette(1)) for index in range(3, 6)]
|
|
def write_png(command, stderr_path, stage):
|
destination = Path(command[-1])
|
Image.new("RGB", (64, 36), (20, 40, 80)).save(destination)
|
|
with mock.patch.object(slides, "_which_or_error", return_value="ffmpeg"), mock.patch.object(
|
slides, "_run_file_command", side_effect=write_png
|
):
|
outputs = slides.extract_original_pages(
|
Path("source.mp4"), staging, slides.VideoInfo(64, 36, 10), selected
|
)
|
self.assertEqual([path.name for path in outputs], [
|
"slide_001.png", "slide_002.png", "slide_003.png"
|
])
|
|
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=[logical_page(0)]
|
), 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()
|