"""HLS 分片:关键帧对齐、时间戳连续、播放列表报真实时长。""" import asyncio import tempfile import unittest from pathlib import Path from unittest import mock from peach.mp4index import ( composition_offsets_present, keyframe_seconds, movie_seconds, segment_plan, ) from peach.hardware_transcode import HardwareEncoder from peach.segments import HlsSegmentService, StreamSessionRegistry, build_hls_playlist from support.mp4 import minimal_mp4 class Mp4IndexTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.root = Path(self.tmp.name) def tearDown(self): self.tmp.cleanup() def write(self, payload: bytes, name="clip.mp4") -> Path: path = self.root / name path.write_bytes(payload) return path def test_keyframe_times_come_from_the_sync_sample_table(self): # 1000 刻度、每样本 40 刻度(25fps),每 25 个样本一个关键帧 = 每秒一个。 path = self.write(minimal_mp4(timescale=1000, sample_delta=40, samples=250, keyframe_every=25)) self.assertEqual(keyframe_seconds(path)[:5], [0.0, 1.0, 2.0, 3.0, 4.0]) def test_moov_at_the_end_is_still_found(self): """非 faststart 的文件把 moov 放在结尾;顺序读到它可能要拉几个 GB。""" full = minimal_mp4(timescale=1000, sample_delta=40, samples=100, keyframe_every=25) moov_start = full.index(b"moov") - 4 moov_size = int.from_bytes(full[moov_start:moov_start + 4], "big") moov = full[moov_start:moov_start + moov_size] rest = full[:moov_start] + full[moov_start + moov_size:] self.assertEqual(keyframe_seconds(self.write(rest + moov))[:3], [0.0, 1.0, 2.0]) def test_unparsable_input_returns_none_so_callers_fall_back_to_range(self): self.assertIsNone(keyframe_seconds(self.write(b"not an mp4 at all"))) self.assertIsNone(keyframe_seconds(self.root / "missing.mp4")) def test_composition_offsets_are_read_from_the_video_track(self): """有 B 帧却没有 ctts 的片源要改走转码,所以这张表在不在必须答得准。""" plain = self.write(minimal_mp4( timescale=1000, sample_delta=40, samples=100, keyframe_every=25)) reordered = self.write(minimal_mp4( timescale=1000, sample_delta=40, samples=100, keyframe_every=25, composition_offsets=True), name="reordered.mp4") self.assertIs(composition_offsets_present(plain), False) self.assertIs(composition_offsets_present(reordered), True) def test_unreadable_moov_answers_none_instead_of_guessing(self): """读不出 moov 就不知道有没有这张表;猜「没有」会把好片源推去转码。""" self.assertIsNone(composition_offsets_present(self.write(b"not an mp4 at all"))) self.assertIsNone(composition_offsets_present(self.root / "missing.mp4")) def test_movie_duration_is_read_from_a_truncated_file_head(self): """远端视频只取文件头:`moov` 被截在一半也读得出 `mvhd`,放在结尾的读不出。""" full = minimal_mp4(timescale=1000, sample_delta=40, samples=250, keyframe_every=25) head = full[:full.index(b"mvhd") + 120] self.assertEqual(movie_seconds(head), 10.0) moov_start = full.index(b"moov") - 4 moov_size = int.from_bytes(full[moov_start:moov_start + 4], "big") tail_moov = full[:moov_start] + full[moov_start + moov_size:] + full[moov_start:][:moov_size] self.assertIsNone(movie_seconds(tail_moov[:len(head)])) self.assertIsNone(movie_seconds(b"not a video")) def test_plan_snaps_to_keyframes_and_covers_the_whole_duration(self): keyframes = [round(value * 8.33, 2) for value in range(12)] plan = segment_plan(keyframes, 100.0, 6) self.assertTrue(all(start in keyframes for start, _ in plan), "每段起点都必须落在真实关键帧上,否则 -c copy 切不准") self.assertTrue(all(length >= 6 for _, length in plan[:-1])) self.assertAlmostEqual(plan[0][0], 0.0) self.assertAlmostEqual(plan[-1][0] + plan[-1][1], 100.0, places=6) def test_plan_starts_at_zero_even_when_the_first_keyframe_is_late(self): plan = segment_plan([3.0, 9.0, 15.0], 20.0, 6) self.assertAlmostEqual(plan[0][0], 0.0) self.assertAlmostEqual(plan[-1][0] + plan[-1][1], 20.0, places=6) def test_playlist_reports_real_segment_durations(self): """按 6 秒等分声明会和关键帧实际切点对不上,播放器进度条就错位。""" plan = [(0.0, 8.33), (8.33, 8.33), (16.66, 3.34)] playlist = build_hls_playlist(plan, lambda index: f"/s/{index}.ts") self.assertIn("#EXTINF:8.330,", playlist) self.assertIn("#EXTINF:3.340,", playlist) self.assertIn("#EXT-X-TARGETDURATION:8", playlist) self.assertNotIn("#EXTINF:6.000,", playlist) self.assertEqual(playlist.count("/s/"), 3) self.assertIn("#EXT-X-ENDLIST", playlist) if __name__ == "__main__": unittest.main() class SegmentCommandTests(unittest.TestCase): """片段的时间窗必须写成绝对终点,否则只有开头两段能产出。 真实故障:`-copyts` 保留原始时间轴后,`-t` 被 FFmpeg 当成绝对结束时刻而非片段时长。 每段的 `-t` 都约等于一个片段长,于是起点超过它的片段一律「已经过期」,FFmpeg 以 退出码 0、空 stderr 写出 0 字节,服务端只报一句没有内容的 ffmpeg failed。 实测 asset 6562:片段 0 与 1 返回 200,片段 2 和 300 全是 503,播到约 20 秒就断, 也拖不动。缓存目录里三个资产都只留下 0.ts 和 1.ts,没有更靠后的片段。 """ def _command_for(self, index: int, transcode: bool = False) -> list[str]: captured: list[list[str]] = [] class _Process: returncode = 0 async def communicate(self): return b"", b"" async def fake_exec(*command, **_kwargs): captured.append(list(command)) return _Process() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) source = root / "clip.mp4" source.write_bytes(bytes(1024)) service = HlsSegmentService( resolver=mock.Mock(ffmpeg=lambda: type("C", (), {"path": "ffmpeg"})), work_root=root / "cache", ) # 计划固定为等长片段,免得测试依赖真实关键帧表。 service._plans[service.fingerprint(source)] = [ (round(n * 9.993, 3), 9.993) for n in range(400) ] async def drive(): with mock.patch("asyncio.create_subprocess_exec", fake_exec): # 产物由假进程负责写,否则会走到「0 字节」的失败分支。 original = service.cached_path def cached_path(*args, **kwargs): target = original(*args, **kwargs) target.parent.mkdir(parents=True, exist_ok=True) return target with mock.patch.object(service, "cached_path", cached_path): try: await service.generate( source, round(index * 9.993, 3), 9.993, asset_id=6562, index=index, session="s", registry=StreamSessionRegistry(), transcode=transcode, ) except Exception: pass asyncio.run(drive()) self.assertTrue(captured, "没有真的调用 FFmpeg") return captured[0] def test_conversion_slice_has_bounded_duration_and_absolute_output_timeline(self): command = self._command_for(100, transcode=True) self.assertEqual(command[command.index('-c:v')+1], 'libx264') self.assertEqual(command[command.index('-t')+1], '9.993') self.assertEqual(command[command.index('-output_ts_offset')+1], '999.300') self.assertNotIn('-copyts', command) def test_silent_ffmpeg_failure_still_says_what_was_observed(self): """FFmpeg 可以退出码 0、stderr 全空却写出 0 字节,只报 ffmpeg failed 等于没报。""" from peach.segments import SegmentUnavailable class _Process: returncode = 0 async def communicate(self): return b"", b"" async def fake_exec(*_command, **_kwargs): return _Process() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) source = root / "clip.mp4" source.write_bytes(bytes(1024)) service = HlsSegmentService( resolver=mock.Mock(ffmpeg=lambda: type("C", (), {"path": "ffmpeg"})), work_root=root / "cache", ) async def drive(): with mock.patch("asyncio.create_subprocess_exec", fake_exec): await service.generate( source, 2997.995, 9.993, asset_id=6562, index=300, session="s", registry=StreamSessionRegistry(), ) with self.assertRaises(SegmentUnavailable) as caught: asyncio.run(drive()) message = str(caught.exception) self.assertIn("returncode=0", message) self.assertIn("2997.995", message) self.assertIn("3007.988", message) def test_every_segment_asks_for_an_absolute_end_not_a_length(self): for index in (0, 1, 2, 300): command = self._command_for(index) self.assertIn("-copyts", command) self.assertNotIn("-t", command, f"片段 {index} 仍在用相对时长") start = float(command[command.index("-ss") + 1]) end = float(command[command.index("-to") + 1]) self.assertAlmostEqual(end - start, 9.993, places=2) # 决定性判据:终点随片段前移,而不是所有片段都停在第一段的末尾。 self.assertGreater(end, start) self.assertAlmostEqual(end, round(index * 9.993, 3) + 9.993, places=2) def test_concurrent_requests_generate_one_copy_of_the_same_segment(self): """播放器预取与重连会同时要同一段:同目标只许一个 FFmpeg,其余等它写完直接用缓存。""" calls = 0 release = None class _Process: returncode = 0 async def communicate(self): await release.wait() return b"", b"" async def fake_exec(*command, **_kwargs): nonlocal calls calls += 1 Path(command[-1]).write_bytes(b"segment") return _Process() async def drive(): nonlocal release release = asyncio.Event() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) source = root / "clip.mp4" source.write_bytes(bytes(1024)) service = HlsSegmentService( resolver=mock.Mock(ffmpeg=lambda: type("C", (), {"path": "ffmpeg"})), work_root=root / "cache", ) registry = StreamSessionRegistry() with mock.patch("asyncio.create_subprocess_exec", fake_exec): tasks = [asyncio.create_task(service.generate( source, 0, 6, asset_id=6562, index=0, session=f"s{n}", registry=registry, )) for n in range(3)] await asyncio.sleep(0) release.set() results = await asyncio.gather(*tasks) self.assertEqual(len({str(item) for item in results}), 1) self.assertEqual(results[0].read_bytes(), b"segment") self.assertEqual(service._generation_locks, {}, "锁在没人用时要从字典里消失") asyncio.run(drive()) self.assertEqual(calls, 1) def test_source_stats_and_cache_eviction_stay_off_the_event_loop(self): """片源 stat 可能卡在网盘上,淘汰要把缓存目录列一遍:都不在事件循环上做,淘汰按新写入字节攒够才跑。""" on_loop, evictions = [], [] def running() -> bool: try: asyncio.get_running_loop() except RuntimeError: return False return True class _Process: returncode = 0 async def communicate(self): return b"", b"" async def fake_exec(*command, **_kwargs): Path(command[-1]).write_bytes(bytes(100)) return _Process() async def drive(): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) source = root / "clip.mp4" source.write_bytes(b"x") # 上限 4000 字节,攒够十六分之一(250)才淘汰:每片 100,第三片之后跑一次。 service = HlsSegmentService( resolver=mock.Mock(ffmpeg=lambda: type("C", (), {"path": "ffmpeg"})), work_root=root / "cache", cache_bytes=4000, ) fingerprint, evict = service.fingerprint, service._evict service.fingerprint = lambda path: (on_loop.append(running()), fingerprint(path))[1] service._evict = lambda: (evictions.append(running()), evict())[1] registry = StreamSessionRegistry() with mock.patch("asyncio.create_subprocess_exec", fake_exec): for index in range(5): await service.generate(source, index * 6, 6, asset_id=1, index=index, session="s", registry=registry) await service.generate(source, 0, 6, asset_id=1, index=0, session="s", registry=registry) asyncio.run(drive()) self.assertTrue(on_loop) self.assertNotIn(True, on_loop, "片源 stat 不能在事件循环上") self.assertEqual(evictions, [False]) def test_hardware_encode_failure_falls_back_to_libx264_for_the_slice(self): """没有 NVENC 的机器分片不能缺席:CUDA 解码、软件解码两条 NVENC 路都失败后走 libx264。""" captured: list[list[str]] = self._transcode_commands("h264") self.assertEqual([c[c.index("-c:v") + 1] for c in captured], ["h264_nvenc", "h264_nvenc", "libx264"]) self.assertIn("-hwaccel", captured[0]) self.assertNotIn("-hwaccel", captured[1]) for command in captured: self.assertEqual(command[command.index("-output_ts_offset") + 1], "12.000") self.assertEqual(command[command.index("-t") + 1], "6.000") self.assertEqual(command[command.index("-f") + 1], "mpegts") def test_a_codec_nvdec_garbles_is_decoded_in_software(self): """VC-1 交给 NVDEC 会解出绿帧,而 FFmpeg 退出码 0、stderr 全空,事后查不出来。 实测 `MIAD573_02.wmv`(vc1、1080p):`-hwaccel cuda` 出来的首个分片开头 21 帧 在黑场与纯绿之间交替,同一段软件解码后色度全程正常。名单之外一律软件解码, 编码探测不出来的也是——省下的 CPU 抵不上一段没人察觉的坏画面。 """ for codec in ("vc1", "wmv3", ""): with self.subTest(codec=codec): captured = self._transcode_commands(codec) self.assertEqual([c[c.index("-c:v") + 1] for c in captured], ["h264_nvenc", "libx264"]) self.assertNotIn("-hwaccel", captured[0]) def _transcode_commands(self, codec: str) -> list[list[str]]: """NVENC 全程失败时,这个编码的片源实际跑过的命令。""" captured: list[list[str]] = [] class _Process: def __init__(self, returncode): self.returncode = returncode async def communicate(self): return b"", (b"nvenc unavailable" if self.returncode else b"") async def fake_exec(*command, **_kwargs): captured.append(list(command)) if "h264_nvenc" in command: return _Process(1) Path(command[-1]).write_bytes(b"segment") return _Process(0) async def drive(): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) source = root / "clip.mp4" source.write_bytes(bytes(1024)) service = HlsSegmentService( resolver=mock.Mock(ffmpeg=lambda: type("C", (), {"path": "ffmpeg"})), work_root=root / "cache", prefer_hardware=True, ) with mock.patch("asyncio.create_subprocess_exec", fake_exec), \ mock.patch("peach.hardware_transcode.HardwareTranscoder._candidates", return_value=(HardwareEncoder("nvenc"),)), \ mock.patch("peach.hardware_transcode.HardwareTranscoder._probe", return_value=True), \ mock.patch("peach.segments._probe_video_codec", return_value=codec): target = await service.generate( source, 12.0, 6.0, asset_id=6562, index=2, session="s", registry=StreamSessionRegistry(), transcode=True, ) self.assertEqual(target.read_bytes(), b"segment") asyncio.run(drive()) return captured class PlanCacheTests(unittest.TestCase): """计划缓存一满就 clear() 等于没有缓存;改成 LRU 后热条目要能活下来。""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.root = Path(self.tmp.name) self.service = HlsSegmentService( resolver=mock.Mock(ffmpeg=lambda: None), work_root=self.root / "cache") def tearDown(self): self.tmp.cleanup() def _source(self, number: int) -> Path: path = self.root / f"clip{number}.mp4" path.write_bytes(b"x") return path def _plan(self, number: int) -> None: with mock.patch("peach.segments.keyframe_seconds", return_value=[0.0]): self.service.plan(self._source(number), 60.0) def _held_sources(self) -> set[str]: return {key[0] for key in self.service._plans} def test_cache_keeps_at_most_the_limit_and_evicts_the_oldest_access(self): from peach.segments import PLAN_CACHE_LIMIT for number in range(PLAN_CACHE_LIMIT): self._plan(number) self.assertEqual(len(self.service._plans), PLAN_CACHE_LIMIT) # 命中一次让 0 号变成最近访问,再插入新条目:被逐出的必须是 1 号。 self._plan(0) self._plan(PLAN_CACHE_LIMIT) self.assertEqual(len(self.service._plans), PLAN_CACHE_LIMIT) self.assertIn(str(self._source(0)), self._held_sources()) self.assertNotIn(str(self._source(1)), self._held_sources())