Ctrl K
ComunidadGuías…

tests/test_frame_capture.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""按时间点抽单帧、把若干帧拼成一张接触印相。九宫格与时间轴预览共用这一份。"""
from __future__ import annotations

import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

from PIL import Image

from peach import frame_capture
from peach.ffmpeg import FFmpegResolver
from support.conditions import missing_prerequisite


class _Completed:
    def __init__(self, stderr: bytes = b""):
        self.stderr = stderr
        self.returncode = 0


class CaptureFrameTests(unittest.TestCase):
    def test_hardware_decode_failure_retries_software_and_keeps_both_receipts(self):
        with tempfile.TemporaryDirectory() as directory:
            root = Path(directory).resolve()
            destination = root / 'frame.jpg'
            calls = []
            def run(command, **kwargs):
                calls.append(command)
                if '-hwaccel' in command:
                    return subprocess.CompletedProcess(command, 1, stderr=b'unsupported hardware decoder')
                Image.new('RGB', (480, 270)).save(command[-1], format='JPEG')
                return subprocess.CompletedProcess(command, 0, stderr=b'')
            for options in (None, ('-hwaccel', 'videotoolbox'), ()):
                calls.clear()
                detail = {}
                with self.subTest(decode_options=options), \
                        patch('peach.hardware_transcode.frame_decode_options', return_value=('-hwaccel', 'videotoolbox')) as probe, \
                        patch.object(frame_capture.subprocess, 'run', side_effect=run):
                    self.assertTrue(frame_capture.capture_with_retry(
                        'ffmpeg', 'sample.mp4', 1, destination, diagnostics=detail, decode_options=options))
                expected = [False] if options == () else [True, False]
                self.assertEqual(len(calls), len(expected))
                self.assertNotIn('-hwaccel', calls[-1])
                self.assertEqual([attempt['hardware_decode'] for attempt in detail['attempts']], expected)
                if options is not None:
                    probe.assert_not_called()

    def setUp(self):
        self.root = Path(tempfile.mkdtemp(prefix="frame-capture-")).resolve()
        self.addCleanup(lambda: shutil.rmtree(self.root, ignore_errors=True))
        self.commands: list[list[str]] = []

    def _run(self, produce: bool = True, stderr: bytes = b""):
        def fake(command, **options):
            self.assertEqual(options["creationflags"],
                             subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
            self.commands.append(list(command))
            if produce:
                Image.new('RGB', (480, 270), 'green').save(command[-1], format='JPEG')
            return _Completed(stderr)
        return patch.object(frame_capture.subprocess, "run", fake)

    def test_the_seek_happens_before_the_input_so_only_that_part_is_decoded(self):
        """`-ss` 放到 `-i` 后面就是从头解码到那个时间点,两小时的片子抽最后一帧要几分钟。"""
        with self._run():
            ok, _ = frame_capture.capture_frame("ffmpeg", "movie.mp4", 61.5,
                                                self.root / "one.jpg", width=320)
        self.assertTrue(ok)
        command = self.commands[0]
        self.assertLess(command.index("-ss"), command.index("-i"))
        self.assertNotIn("bt709", command)

    def test_a_frame_too_small_to_be_a_picture_does_not_count_as_taken(self):
        """命令退出 0 不等于出了图。写了个几百字节的壳,页面上就是一块灰。"""
        def fake(command, **_kwargs):
            Path(command[-1]).write_bytes(b"x" * 64)
            return _Completed()
        with patch.object(frame_capture.subprocess, "run", fake):
            ok, _ = frame_capture.capture_frame("ffmpeg", "movie.mp4", 1.0,
                                                self.root / "small.jpg")
        self.assertFalse(ok)

    def test_a_timeout_is_reported_as_a_failure_not_an_exception(self):
        def fake(_command, **_kwargs):
            raise subprocess.TimeoutExpired("ffmpeg", 45)
        with patch.object(frame_capture.subprocess, "run", fake):
            ok, stderr = frame_capture.capture_frame("ffmpeg", "movie.mp4", 1.0,
                                                     self.root / "late.jpg")
        self.assertFalse(ok)
        self.assertEqual(stderr, "ffmpeg timeout")

    def test_small_decodable_frame_counts_as_a_picture(self):
        def fake(command, **_kwargs):
            Image.new('RGB', (32, 32), 'black').save(command[-1], format='JPEG')
            return _Completed()
        destination = self.root / 'small-valid.jpg'
        with patch.object(frame_capture.subprocess, 'run', fake):
            ok, _ = frame_capture.capture_frame('ffmpeg', 'movie.mp4', 1, destination)
        self.assertTrue(ok)
        self.assertLess(destination.stat().st_size, 1024)

    def test_nonzero_exit_rejects_and_removes_a_partial_picture(self):
        def fake(command, **_kwargs):
            Image.new('RGB', (480, 270)).save(command[-1], format='JPEG')
            result = _Completed(b'decoder failed')
            result.returncode = 1
            return result
        detail = {}
        destination = self.root / 'partial.jpg'
        with patch.object(frame_capture.subprocess, 'run', fake):
            self.assertFalse(frame_capture.capture_with_retry(
                'ffmpeg', 'movie.mp4', 5, destination, diagnostics=detail))
        self.assertFalse(destination.exists())
        self.assertEqual(detail['returncode'], 1)
        self.assertEqual(detail['stderr'], 'decoder failed')
        self.assertEqual(detail['timestamp_seconds'], 5)

    def test_timeout_preserves_stderr_and_cleans_the_output(self):
        destination = self.root / 'timed-out.jpg'
        def fake(*_args, **_kwargs):
            destination.write_bytes(b'partial')
            raise subprocess.TimeoutExpired('ffmpeg', 45, stderr=b'decode stalled')
        detail = {}
        with patch.object(frame_capture.subprocess, 'run', fake):
            self.assertFalse(frame_capture.capture_with_retry(
                'ffmpeg', 'movie.mp4', 8, destination, diagnostics=detail))
        self.assertFalse(destination.exists())
        self.assertEqual(detail['reason'], 'timeout')
        self.assertEqual(detail['stderr'], 'decode stalled')
        self.assertEqual(len(detail['attempts']), 1)

    def test_color_retry_keeps_both_attempts(self):
        detail = {}
        with self._run(produce=False, stderr=b'Unsupported color primaries: reserved'):
            self.assertFalse(frame_capture.capture_with_retry(
                'ffmpeg', 'movie.mp4', 1, self.root / 'color.jpg', diagnostics=detail))
        self.assertEqual([item['color_override'] for item in detail['attempts']], [False, True])

    def test_an_existing_frame_cannot_mask_an_empty_output(self):
        destination = self.root / 'stale.jpg'
        Image.new('RGB', (480, 270)).save(destination)
        detail = {}
        with self._run(produce=False):
            self.assertFalse(frame_capture.capture_with_retry(
                'ffmpeg', 'movie.mp4', 1, destination, diagnostics=detail))
        self.assertEqual(detail['reason'], 'no_frame')
        self.assertFalse(destination.exists())

    def test_only_bad_color_metadata_is_worth_a_second_try(self):
        """无条件重试会让网盘超时这类必然失败的文件每帧白跑第二次:单帧最坏耗时从
        45 秒翻到 90 秒,9 帧就是 13.5 分钟。"""
        for stderr, attempts in (
            (b"[swscale] Unsupported color primaries: reserved", 2),
            (b"color_trc reserved is invalid", 2),
            (b"Error opening input: Input/output error", 1),
            (b"", 1),
        ):
            self.commands = []
            with self._run(produce=False, stderr=stderr), patch('peach.error_reporting.subprocess_failure'):
                ok = frame_capture.capture_with_retry("ffmpeg", "movie.mp4", 1.0,
                                                      self.root / "retry.jpg")
            self.assertFalse(ok)
            self.assertEqual(len(self.commands), attempts, stderr)
            if attempts == 2:
                self.assertIn("bt709", self.commands[1])


class TileFramesTests(unittest.TestCase):
    def setUp(self):
        self.root = Path(tempfile.mkdtemp(prefix="frame-tile-")).resolve()
        self.addCleanup(lambda: shutil.rmtree(self.root, ignore_errors=True))

    def test_the_grid_size_reaches_the_filter(self):
        seen: list[list[str]] = []

        def fake(command, **options):
            self.assertEqual(options["creationflags"],
                             subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
            seen.append(list(command))
            Image.new('RGB', (96, 96), 'black').save(command[-1], format='JPEG')
            return _Completed()

        with patch.object(frame_capture.subprocess, "run", fake):
            made = frame_capture.tile_frames("ffmpeg", str(self.root / "s%03d.jpg"), 10, 7,
                                             self.root / "out" / "000.jpg")
        self.assertTrue(made)
        self.assertIn("tile=10x7", seen[0])

    def test_a_tile_that_did_not_land_is_not_reported_as_made(self):
        with patch.object(frame_capture.subprocess, "run",
                          lambda command, **_kwargs: _Completed()), \
                patch('peach.error_reporting.subprocess_failure') as report:
            made = frame_capture.tile_frames("ffmpeg", str(self.root / "s%03d.jpg"), 3, 3,
                                             self.root / "out" / "000.jpg")
        self.assertFalse(made)
        self.assertEqual(report.call_args.kwargs['reason'], 'tile_unavailable')


class FFmpegCaptureIntegrationTests(unittest.TestCase):
    def test_anamorphic_video_probe_frames_and_grid_use_display_geometry(self):
        from peach import media_probe
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary).resolve()
            resolver = FFmpegResolver(root)
            ffmpeg, ffprobe = resolver.ffmpeg(), resolver.ffprobe()
            if ffmpeg is None or ffprobe is None:
                missing_prerequisite('非方形像素回归需要 FFmpeg 与 ffprobe')
            source = root / 'anamorphic.mp4'
            subprocess.run([str(ffmpeg.path), '-y', '-v', 'error', '-f', 'lavfi', '-i',
                'color=c=green:s=720x1280:r=5:d=1', '-vf', 'setsar=256/81:max=1000',
                '-c:v', 'libx264', '-pix_fmt', 'yuv420p', str(source)],
                capture_output=True, check=True, timeout=20)
            measured = media_probe.measure(str(ffprobe.path), 1, str(source))
            self.assertEqual(measured[1:3], (2276, 1280))
            self.assertEqual(measured[7:9], ('横屏', '2K'))
            for index in range(9):
                self.assertTrue(frame_capture.capture_with_retry(str(ffmpeg.path), str(source), .1,
                    root / f's{index:02d}.jpg', width=480, decode_options=()))
            with Image.open(root / 's00.jpg') as frame:
                self.assertEqual(frame.size, (480, 270))
            target = root / 'grid.jpg'
            self.assertTrue(frame_capture.tile_frames(str(ffmpeg.path), str(root / 's%02d.jpg'), 3, 3, target))
            with Image.open(target) as grid:
                self.assertEqual(grid.size, (1440, 810))

    def test_av1_software_decode_uses_the_desktop_release_gate(self):
        from scripts.smoke_desktop import assert_av1_decoder
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary).resolve()
            choice = FFmpegResolver(root).ffmpeg()
            if choice is None:
                missing_prerequisite('AV1 解码回归需要 FFmpeg')
            assert_av1_decoder(choice.path, root)

    def test_real_small_jpegs_and_unicode_paths_produce_a_grid(self):
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary).resolve()
            choice = FFmpegResolver(root).ffmpeg()
            if choice is None:
                missing_prerequisite('抽帧回归需要 FFmpeg')
            ffmpeg = str(choice.path)
            source = root / '中文 空格 (1).mp4'
            subprocess.run([ffmpeg, '-y', '-v', 'error', '-f', 'lavfi', '-i',
                            'color=c=black:s=320x180:r=5:d=1', '-c:v', 'mpeg4', str(source)],
                           capture_output=True, check=True, timeout=20)
            for index in range(9):
                detail = {}
                self.assertTrue(frame_capture.capture_with_retry(
                    ffmpeg, str(source), .1, root / f's{index:02d}.jpg', diagnostics=detail), detail)
            self.assertLess((root / 's00.jpg').stat().st_size, 1024)
            target = root / 'grid.jpg'
            self.assertTrue(frame_capture.tile_frames(ffmpeg, str(root / 's%02d.jpg'), 3, 3, target))
            with Image.open(target) as image:
                self.assertEqual(image.size, (1440, 810))
            detail = {}
            self.assertFalse(frame_capture.capture_with_retry(
                ffmpeg, str(source), 10, root / 'beyond.jpg', diagnostics=detail))
            self.assertIn(detail['reason'], ('no_frame', 'ffmpeg_error'))
            self.assertIsInstance(detail['returncode'], int)


if __name__ == "__main__":
    unittest.main()