From 9d0aeb2cdd1630cdc2c6525a1f232bd98364d82b Mon Sep 17 00:00:00 2001 From: Guillaume Jobin Date: Tue, 23 Jun 2026 22:11:14 -0400 Subject: [PATCH 1/6] fix: bias yt-dlp format_sort toward board's HW-decoded codec (GH #3092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Pi 5 (HEVC-only hardware decode), download_youtube_asset was requesting H.264 via a hardcoded format_sort preference. The download succeeded, but normalize_video_asset then rejected the asset with "Video codec 'h264' is not hardware-decoded on this device. Supported: hevc." The capability detection already existed — it just wasn't consulted before the download. Fix: call _hw_decoded_codecs() before building ydl_opts and set the leading format_sort entry to the board's preferred codec. HEVC-only boards (Pi 5) now request vcodec:hevc; all others keep vcodec:h264. Unknown boards fall back to h264 so the download still proceeds. Adds four tests covering Pi 5 (HEVC), Pi 4 (H.264 preferred when both supported), Pi 3 (H.264 only), and unrecognised board (h264 fallback). Co-Authored-By: Claude Sonnet 4.6 --- src/anthias_server/celery_tasks.py | 29 +++++++--- tests/test_celery_tasks.py | 91 ++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 8 deletions(-) diff --git a/src/anthias_server/celery_tasks.py b/src/anthias_server/celery_tasks.py index 9a77fd395..37534c508 100755 --- a/src/anthias_server/celery_tasks.py +++ b/src/anthias_server/celery_tasks.py @@ -686,15 +686,28 @@ def download_youtube_asset(asset_id: str, uri: str) -> None: from yt_dlp import YoutubeDL from yt_dlp.utils import DownloadError + # Bias yt-dlp toward the codec the board can actually decode in + # hardware. HEVC-only boards (Pi 5) must not receive an H.264 + # stream — normalize_video_asset would reject it immediately with + # "Video codec 'h264' is not hardware-decoded on this device." + # Boards that support both codecs keep h264 as the preference: + # H.264 streams are more widely available on YouTube. Unknown + # boards fall back to h264 so the download still proceeds; + # normalize_video_asset will gate on the actual codec if needed. + from anthias_server.processing import _hw_decoded_codecs + _supported = _hw_decoded_codecs() + _preferred_vcodec = ( + 'hevc' if ('hevc' in _supported and 'h264' not in _supported) else 'h264' + ) + ydl_opts = { - # ``format_sort`` mirrors the previous CLI's `-S - # vcodec:h264,fps,res:1080,acodec:m4a` — bias toward h264 - # video and m4a audio, keep resolution at 1080p, prefer - # higher fps. yt-dlp still picks the *best matching* format, - # falling back to whatever is available if no exact match - # exists. Strict `format=` filters would reject videos that - # happen to have only vp9, which we don't want. - 'format_sort': ['vcodec:h264', 'fps', 'res:1080', 'acodec:m4a'], + # ``format_sort`` biases toward the board's preferred codec, + # m4a audio, 1080p resolution, and higher fps. yt-dlp still + # picks the *best matching* format, falling back to whatever + # is available if no exact match exists. Strict ``format=`` + # filters would reject videos that only have other codecs, + # which we don't want — normalize_video_asset handles that. + 'format_sort': [f'vcodec:{_preferred_vcodec}', 'fps', 'res:1080', 'acodec:m4a'], # Final filename — yt-dlp writes .part during the # download and renames on success. cleanup() recognises # .part / .info.json sidecars and skips them inside the 1h diff --git a/tests/test_celery_tasks.py b/tests/test_celery_tasks.py index fc1656feb..991c08d21 100644 --- a/tests/test_celery_tasks.py +++ b/tests/test_celery_tasks.py @@ -986,6 +986,97 @@ def test_download_youtube_asset_on_failure_writes_error_metadata() -> None: mock_notify.assert_called_once_with('yt-1') +# --------------------------------------------------------------------------- +# download_youtube_asset — per-board format_sort selection (GH #3092) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_download_youtube_asset_pi5_prefers_hevc_format( + fake_youtube_dl: mock.MagicMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Pi 5 is HEVC-only; yt-dlp must receive vcodec:hevc as its first + format_sort preference so the download avoids an H.264 stream that + normalize_video_asset would immediately reject with + "Video codec 'h264' is not hardware-decoded on this device." (GH #3092).""" + monkeypatch.setenv('DEVICE_TYPE', 'pi5') + _make_youtube_asset() + fake_youtube_dl.extract_info.return_value = {'title': 't', 'duration': 10} + with ( + mock.patch('anthias_server.app.consumers.notify_asset_update'), + mock.patch('anthias_server.processing.dispatch_normalize_video'), + ): + download_youtube_asset('yt-1', 'https://www.youtube.com/watch?v=abc') + + ydl_opts = fake_youtube_dl._cls.call_args.args[0] + assert ydl_opts['format_sort'] == ['vcodec:hevc', 'fps', 'res:1080', 'acodec:m4a'], ( + f"Pi 5 must bias yt-dlp toward HEVC; got: {ydl_opts['format_sort']!r}" + ) + + +@pytest.mark.django_db +def test_download_youtube_asset_pi4_prefers_h264_format( + fake_youtube_dl: mock.MagicMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Pi 4 supports both H.264 and HEVC; the task should still prefer + H.264 because H.264 streams are far more available on YouTube and + the board can decode either.""" + monkeypatch.setenv('DEVICE_TYPE', 'pi4-64') + _make_youtube_asset() + fake_youtube_dl.extract_info.return_value = {'title': 't', 'duration': 10} + with ( + mock.patch('anthias_server.app.consumers.notify_asset_update'), + mock.patch('anthias_server.processing.dispatch_normalize_video'), + ): + download_youtube_asset('yt-1', 'https://www.youtube.com/watch?v=abc') + + ydl_opts = fake_youtube_dl._cls.call_args.args[0] + assert ydl_opts['format_sort'][0] == 'vcodec:h264' + + +@pytest.mark.django_db +def test_download_youtube_asset_pi3_prefers_h264_format( + fake_youtube_dl: mock.MagicMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Pi 3 supports H.264 only; format_sort must request h264.""" + monkeypatch.setenv('DEVICE_TYPE', 'pi3') + _make_youtube_asset() + fake_youtube_dl.extract_info.return_value = {'title': 't', 'duration': 10} + with ( + mock.patch('anthias_server.app.consumers.notify_asset_update'), + mock.patch('anthias_server.processing.dispatch_normalize_video'), + ): + download_youtube_asset('yt-1', 'https://www.youtube.com/watch?v=abc') + + ydl_opts = fake_youtube_dl._cls.call_args.args[0] + assert ydl_opts['format_sort'][0] == 'vcodec:h264' + + +@pytest.mark.django_db +def test_download_youtube_asset_unknown_board_falls_back_to_h264( + fake_youtube_dl: mock.MagicMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An unrecognised DEVICE_TYPE yields an empty HW codec set; the + task must not crash and must fall back to the h264 preference so + the download still proceeds. normalize_video_asset will gate on + the actual codec if the board turns out not to support it.""" + monkeypatch.setenv('DEVICE_TYPE', 'unrecognised-board-xyz') + _make_youtube_asset() + fake_youtube_dl.extract_info.return_value = {'title': 't', 'duration': 10} + with ( + mock.patch('anthias_server.app.consumers.notify_asset_update'), + mock.patch('anthias_server.processing.dispatch_normalize_video'), + ): + download_youtube_asset('yt-1', 'https://www.youtube.com/watch?v=abc') + + ydl_opts = fake_youtube_dl._cls.call_args.args[0] + assert ydl_opts['format_sort'][0] == 'vcodec:h264' + + # --------------------------------------------------------------------------- # download_remote_video_asset — generic http(s) single-file video URLs # --------------------------------------------------------------------------- From 0f667e3ca96d6b721c07c0f56452a7346b748cf6 Mon Sep 17 00:00:00 2001 From: Guillaume Jobin Date: Tue, 23 Jun 2026 22:43:37 -0400 Subject: [PATCH 2/6] fix: accept H.264 software decode on Pi 5 so YouTube downloads work Pi 5's BCM2712 has no H.264 hardware decoder; the Cortex-A76 cores software-decode 1080p H.264 without frame drops. However YouTube rarely serves HEVC, so the previous HEVC-only codec gate on Pi 5 rejected every YouTube download regardless of format_sort preference. - processing.py: add h264 to pi5's accepted codec set alongside hevc - celery_tasks.py: simplify format_sort to prefer hevc on any board that has it (yt-dlp falls back to h264 on YouTube anyway) - test_processing.py: replace pi5+h264-rejection tests with pi3+hevc-rejection tests (mocked ffprobe, no ffmpeg needed); add new test asserting h264 IS accepted on pi5 via software decode - test_celery_tasks.py: update pi4 test to expect hevc preference Co-Authored-By: Claude Sonnet 4.6 --- src/anthias_server/celery_tasks.py | 7 +- src/anthias_server/processing.py | 6 +- tests/test_celery_tasks.py | 11 +-- tests/test_processing.py | 136 ++++++++++++++++++++--------- 4 files changed, 112 insertions(+), 48 deletions(-) diff --git a/src/anthias_server/celery_tasks.py b/src/anthias_server/celery_tasks.py index 37534c508..252ddc672 100755 --- a/src/anthias_server/celery_tasks.py +++ b/src/anthias_server/celery_tasks.py @@ -696,9 +696,10 @@ def download_youtube_asset(asset_id: str, uri: str) -> None: # normalize_video_asset will gate on the actual codec if needed. from anthias_server.processing import _hw_decoded_codecs _supported = _hw_decoded_codecs() - _preferred_vcodec = ( - 'hevc' if ('hevc' in _supported and 'h264' not in _supported) else 'h264' - ) + # Prefer HEVC when the board can decode it — it's the hardware path + # on Pi 5 and is more efficient than H.264 wherever it's available. + # Boards without HEVC support (Pi 2/3) fall back to H.264. + _preferred_vcodec = 'hevc' if 'hevc' in _supported else 'h264' ydl_opts = { # ``format_sort`` biases toward the board's preferred codec, diff --git a/src/anthias_server/processing.py b/src/anthias_server/processing.py index 8b3ed0e80..75a4236dd 100644 --- a/src/anthias_server/processing.py +++ b/src/anthias_server/processing.py @@ -885,7 +885,11 @@ def _ffprobe_summary(input_path: str) -> dict[str, Any]: # V4L2 bcm2835-codec), not the Qt5 GStreamer fbdev path. 'pi3-64': frozenset({'h264'}), 'pi4-64': frozenset({'h264', 'hevc'}), - 'pi5': frozenset({'hevc'}), + # hevc: hardware-decoded via v4l2-request (BCM2712 VideoCore VII). + # h264: software-decoded — Cortex-A76 handles 1080p H.264 without + # frame drops, and YouTube rarely serves HEVC so excluding h264 + # would block all YouTube downloads on Pi 5. + 'pi5': frozenset({'hevc', 'h264'}), 'rockpi4': frozenset({'h264', 'hevc'}), 'x86': frozenset({'h264', 'hevc'}), } diff --git a/tests/test_celery_tasks.py b/tests/test_celery_tasks.py index 991c08d21..2b50e5eee 100644 --- a/tests/test_celery_tasks.py +++ b/tests/test_celery_tasks.py @@ -1016,13 +1016,14 @@ def test_download_youtube_asset_pi5_prefers_hevc_format( @pytest.mark.django_db -def test_download_youtube_asset_pi4_prefers_h264_format( +def test_download_youtube_asset_pi4_prefers_hevc_format( fake_youtube_dl: mock.MagicMock, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Pi 4 supports both H.264 and HEVC; the task should still prefer - H.264 because H.264 streams are far more available on YouTube and - the board can decode either.""" + """Pi 4 supports both H.264 and HEVC hardware decode; HEVC is + preferred in format_sort as the more efficient codec. In practice + YouTube rarely serves HEVC so yt-dlp falls back to H.264, which + Pi 4 can also hardware-decode.""" monkeypatch.setenv('DEVICE_TYPE', 'pi4-64') _make_youtube_asset() fake_youtube_dl.extract_info.return_value = {'title': 't', 'duration': 10} @@ -1033,7 +1034,7 @@ def test_download_youtube_asset_pi4_prefers_h264_format( download_youtube_asset('yt-1', 'https://www.youtube.com/watch?v=abc') ydl_opts = fake_youtube_dl._cls.call_args.args[0] - assert ydl_opts['format_sort'][0] == 'vcodec:h264' + assert ydl_opts['format_sort'][0] == 'vcodec:hevc' @pytest.mark.django_db diff --git a/tests/test_processing.py b/tests/test_processing.py index db27b0520..4fcb1105a 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -632,83 +632,104 @@ def test_video_supported_codec_writes_metadata_and_clears_processing( assert path.exists(src) -@pytest_ffmpeg @pytest.mark.django_db def test_video_unsupported_codec_raises_with_ffmpeg_recipe( asset_dir: str, monkeypatch: pytest.MonkeyPatch ) -> None: - """An H.264 upload on a Pi 5 (HEVC only — Pi 5's mpv has no v4l2- - request H.264 hwdec) is rejected. The exception's message names - the rejected codec and supported set; its ``recipe`` attribute - carries an ffmpeg command pre-filled with the upload's filename - (taken from ``metadata.upload_name``, which the upload view - stashes at create time) so the operator can copy-paste it - verbatim.""" - monkeypatch.setenv('DEVICE_TYPE', 'pi5') + """A codec outside the board's HW decode set is rejected. The + exception's message names the rejected codec and supported set; + its ``recipe`` attribute carries an ffmpeg command pre-filled with + the upload's filename so the operator can copy-paste it verbatim. + + Pi 3 (H.264 only) is used here — an HEVC upload is the clearest + unsupported-codec case for that board.""" + monkeypatch.setenv('DEVICE_TYPE', 'pi3') src = path.join(asset_dir, 'sample.mp4') - _make_video(src, codec='libx264', container='mp4', audio='aac') + # Create a minimal placeholder — ffprobe is mocked below so the + # file content doesn't matter, only path.isfile() needs to pass. + with open(src, 'wb') as f: + f.write(b'\x00' * 16) asset = _make_processing_asset( - 'vid-h264-pi5', + 'vid-hevc-pi3', src, mimetype='video', metadata={'upload_name': 'beach-clip.mp4'}, ) + fake_summary = { + 'container': 'mp4', + 'video_codec': 'hevc', + 'video_width': 1920, + 'video_height': 1080, + 'video_fps': 30.0, + 'audio_codec': 'aac', + 'duration_seconds': 60, + } - with mock.patch.object(processing, '_notify'): + with ( + mock.patch.object(processing, '_notify'), + mock.patch.object( + processing, '_ffprobe_summary', return_value=fake_summary + ), + ): with pytest.raises(processing.UnsupportedVideoCodecError) as excinfo: processing._run_video_normalisation(asset) import shlex as _shlex msg = str(excinfo.value) - assert "'h264'" in msg - assert 'hevc' in msg - # Recipe is on the exception, not in the message body. + assert "'hevc'" in msg + assert 'h264' in msg recipe = excinfo.value.recipe - assert 'libx265' in recipe # Pi 5 supports HEVC only. - assert '-tag:v hvc1' in recipe - # The upload's filename appears in the recipe's input slot — - # operator can copy and paste it without hand-editing INPUT. + assert 'libx264' in recipe # Pi 3 supports H.264 — recipe encodes to H.264. tokens = _shlex.split(recipe) assert tokens[1] == '-i' assert tokens[2] == 'beach-clip.mp4' - # Output filename carries a ``.hevc.`` suffix so the recipe - # doesn't ask the operator to overwrite their source file. - assert tokens[-1] == 'beach-clip.hevc.mp4' + assert tokens[-1] == 'beach-clip.h264.mp4' - # The terminal-free alternative: HandBrake steps targeting the - # same codec (H.265 on a Pi 5), with a link to the download page. handbrake = excinfo.value.handbrake assert handbrake joined = ' '.join(handbrake) assert 'HandBrake' in joined assert processing.HANDBRAKE_URL in joined - assert 'H.265 (x265)' in joined - # Codec-only rejection: no resolution-limit step. + # H.264 board: the stock Fast 1080p30 preset is already H.264, + # no encoder-switch step needed. + assert 'H.265 (x265)' not in joined assert not any('Resolution Limit' in step for step in handbrake) - # Metadata was still written so the operator can see *what* they - # uploaded next to the error message in the asset list. asset.refresh_from_db() - assert asset.metadata.get('video_codec') == 'h264' - assert asset.metadata.get('video_width') == 32 + assert asset.metadata.get('video_codec') == 'hevc' + assert asset.metadata.get('video_width') == 1920 -@pytest_ffmpeg @pytest.mark.django_db def test_video_unsupported_codec_recipe_falls_back_to_upload_placeholder( asset_dir: str, monkeypatch: pytest.MonkeyPatch ) -> None: - """When the row has no ``metadata.upload_name`` (YouTube - downloads, pre-rebrand rows), the recipe uses a stable - ``upload`` placeholder so the operator still sees the - correct input extension to substitute.""" - monkeypatch.setenv('DEVICE_TYPE', 'pi5') + """When the row has no ``metadata.upload_name`` (YouTube downloads, + pre-rebrand rows), the recipe uses a stable ``upload`` + placeholder so the operator still sees the correct input extension + to substitute.""" + monkeypatch.setenv('DEVICE_TYPE', 'pi3') src = path.join(asset_dir, 'noname.mp4') - _make_video(src, codec='libx264', container='mp4', audio='aac') + with open(src, 'wb') as f: + f.write(b'\x00' * 16) asset = _make_processing_asset('vid-noname', src, mimetype='video') + fake_summary = { + 'container': 'mp4', + 'video_codec': 'hevc', + 'video_width': 1920, + 'video_height': 1080, + 'video_fps': 30.0, + 'audio_codec': 'aac', + 'duration_seconds': 60, + } - with mock.patch.object(processing, '_notify'): + with ( + mock.patch.object(processing, '_notify'), + mock.patch.object( + processing, '_ffprobe_summary', return_value=fake_summary + ), + ): with pytest.raises(processing.UnsupportedVideoCodecError) as excinfo: processing._run_video_normalisation(asset) @@ -718,7 +739,44 @@ def test_video_unsupported_codec_recipe_falls_back_to_upload_placeholder( tokens = _shlex.split(recipe) assert tokens[1] == '-i' assert tokens[2] == 'upload.mp4' - assert tokens[-1] == 'upload.hevc.mp4' + assert tokens[-1] == 'upload.h264.mp4' + + +@pytest.mark.django_db +def test_video_h264_accepted_on_pi5_software_decode( + asset_dir: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """H.264 is accepted on Pi 5 via software decode — the + Cortex-A76 handles 1080p H.264 without frame drops, and YouTube + rarely serves HEVC so blocking H.264 on Pi 5 would prevent all + YouTube downloads. (GH #3092)""" + monkeypatch.setenv('DEVICE_TYPE', 'pi5') + src = path.join(asset_dir, 'sample.mp4') + with open(src, 'wb') as f: + f.write(b'\x00' * 16) + asset = _make_processing_asset('vid-h264-pi5-sw', src, mimetype='video') + fake_summary = { + 'container': 'mp4', + 'video_codec': 'h264', + 'video_width': 1920, + 'video_height': 1080, + 'video_fps': 30.0, + 'audio_codec': 'aac', + 'duration_seconds': 60, + } + + with ( + mock.patch.object(processing, '_notify') as mock_notify, + mock.patch.object( + processing, '_ffprobe_summary', return_value=fake_summary + ), + ): + processing._run_video_normalisation(asset) + + asset.refresh_from_db() + assert asset.is_processing is False + assert asset.metadata.get('video_codec') == 'h264' + mock_notify.assert_called_once_with('vid-h264-pi5-sw') @pytest_ffmpeg From 69c66750b33248393e1d608f57d5393ad627ad50 Mon Sep 17 00:00:00 2001 From: Guillaume Jobin Date: Tue, 23 Jun 2026 23:12:31 -0400 Subject: [PATCH 3/6] tests: add Pi 5 unsupported-codec rejection coverage; restore recipe doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test_video_unsupported_codec_still_rejected_on_pi5 to verify that codecs outside Pi 5's accepted set (VP9, AV1, …) still raise UnsupportedVideoCodecError even after h264 was added as a software- decode fallback. Restores the removed-behavior coverage flagged by the code review for that path. Also restore _ffmpeg_reencode_recipe / _handbrake_steps to prefer h264 when it's in the accepted set (faster to encode; both codecs are hardware-decoded on Pi 4 where the set is {h264, hevc}). Update the docstrings to reflect the current logic. Co-Authored-By: Claude Sonnet 4.6 --- src/anthias_server/processing.py | 21 ++++++++-------- tests/test_processing.py | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/anthias_server/processing.py b/src/anthias_server/processing.py index 75a4236dd..ac3bc1278 100644 --- a/src/anthias_server/processing.py +++ b/src/anthias_server/processing.py @@ -949,10 +949,10 @@ def _ffmpeg_reencode_recipe( Prefers libx264 when H.264 is in the board's supported set — libx264 is roughly 5-10× faster than libx265 at comparable quality, which matters when the operator is doing the encode by - hand. Falls back to libx265 + ``-tag:v hvc1`` for Pi 5 (HEVC- - only board). Returns an empty string when the board has no HW - decode set at all — there's nothing the operator can transcode - to that would land in a supported pipe. + hand. Falls back to libx265 + ``-tag:v hvc1`` for HEVC-only boards. + Returns an empty string when the board has no HW decode set at all — + there's nothing the operator can transcode to that would land in a + supported pipe. ``source_filename``, when supplied, substitutes the bare upload filename (no path) for the ``INPUT`` placeholder and reuses its @@ -1029,10 +1029,11 @@ def _handbrake_steps(supported: frozenset[str]) -> list[str]: — there's no separate downscale step to spell out. The only board-specific tweak is the encoder: an HEVC-only board - (Pi 5) can't use the preset's default H.264, so the operator flips - ``Video Encoder`` to ``H.265 (x265)`` on the Video tab. HandBrake - ships no H.265-at-1080p MP4 preset, so the encoder swap is the - cleanest route to a 1080p HEVC MP4. + (Pi 5 before the H.264 software-decode fallback was added) can't + use the preset's default H.264, so the operator flips ``Video + Encoder`` to ``H.265 (x265)`` on the Video tab. HandBrake ships no + H.265-at-1080p MP4 preset, so the encoder swap is the cleanest + route to a 1080p HEVC MP4. Returns an empty list when the board has no HW decode set at all — there's nothing to transcode to, exactly as the recipe returns an @@ -1040,8 +1041,8 @@ def _handbrake_steps(supported: frozenset[str]) -> list[str]: list stands alone when surfaced as plain text via the v2 API. """ if 'h264' in supported: - # H.264 board (Pi 2/3/4, x86, ...): the stock preset already - # outputs the codec we want — no encoder change. + # H.264 board (Pi 2/3/4, Pi 5, x86, ...): the stock preset + # already outputs an accepted codec — no encoder change needed. prefers_h264 = True elif 'hevc' in supported: prefers_h264 = False diff --git a/tests/test_processing.py b/tests/test_processing.py index 4fcb1105a..95bfcfe48 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -779,6 +779,47 @@ def test_video_h264_accepted_on_pi5_software_decode( mock_notify.assert_called_once_with('vid-h264-pi5-sw') +@pytest.mark.django_db +def test_video_unsupported_codec_still_rejected_on_pi5( + asset_dir: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """VP9 / AV1 uploads on Pi 5 are still rejected even though H.264 + software-decode was added to the Pi 5 codec set. The rejection recipe + tells the operator to re-encode to H.264 (accepted via software decode — + faster to encode than HEVC and still plays fine on Cortex-A76).""" + monkeypatch.setenv('DEVICE_TYPE', 'pi5') + src = path.join(asset_dir, 'sample.mp4') + with open(src, 'wb') as f: + f.write(b'\x00' * 16) + asset = _make_processing_asset('vid-vp9-pi5', src, mimetype='video') + fake_summary = { + 'container': 'mp4', + 'video_codec': 'vp9', + 'video_width': 1920, + 'video_height': 1080, + 'video_fps': 30.0, + 'audio_codec': 'aac', + 'duration_seconds': 60, + } + + with ( + mock.patch.object(processing, '_notify'), + mock.patch.object( + processing, '_ffprobe_summary', return_value=fake_summary + ), + ): + with pytest.raises(processing.UnsupportedVideoCodecError) as excinfo: + processing._run_video_normalisation(asset) + + import shlex as _shlex + + recipe = excinfo.value.recipe + tokens = _shlex.split(recipe) + assert tokens[-1] == 'upload.h264.mp4' + assert 'libx264' in recipe + assert 'libx265' not in recipe + + @pytest_ffmpeg @pytest.mark.django_db def test_video_unsupported_codec_h264_board_recipe( From 9f16dd58897d310df642ddcb8fda96b4cc0a5c9e Mon Sep 17 00:00:00 2001 From: Guillaume Jobin Date: Tue, 23 Jun 2026 23:31:34 -0400 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20update=20stale=20comment=20=E2=80=94?= =?UTF-8?q?=20Pi=205=20accepts=20H.264=20via=20software=20decode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous comment said "HEVC-only boards (Pi 5) must not receive an H.264 stream", which was true before this PR but is now wrong: Pi 5 accepts H.264 via Cortex-A76 software decode since YouTube rarely serves HEVC. Update the comment to accurately describe the current behaviour. Co-Authored-By: Claude Sonnet 4.6 --- src/anthias_server/celery_tasks.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/anthias_server/celery_tasks.py b/src/anthias_server/celery_tasks.py index 252ddc672..5a4df213e 100755 --- a/src/anthias_server/celery_tasks.py +++ b/src/anthias_server/celery_tasks.py @@ -686,19 +686,15 @@ def download_youtube_asset(asset_id: str, uri: str) -> None: from yt_dlp import YoutubeDL from yt_dlp.utils import DownloadError - # Bias yt-dlp toward the codec the board can actually decode in - # hardware. HEVC-only boards (Pi 5) must not receive an H.264 - # stream — normalize_video_asset would reject it immediately with - # "Video codec 'h264' is not hardware-decoded on this device." - # Boards that support both codecs keep h264 as the preference: - # H.264 streams are more widely available on YouTube. Unknown - # boards fall back to h264 so the download still proceeds; - # normalize_video_asset will gate on the actual codec if needed. + # Bias yt-dlp toward the board's hardware-decoded codec so playback + # is as efficient as possible. Pi 5 prefers HEVC (VideoCore VII HW + # path) but also accepts H.264 via software decode on Cortex-A76 — + # YouTube rarely serves HEVC so H.264 is the realistic outcome. + # Boards without HEVC (Pi 2/3) fall back to H.264. Unknown boards + # default to H.264; normalize_video_asset gates on the actual codec. from anthias_server.processing import _hw_decoded_codecs _supported = _hw_decoded_codecs() - # Prefer HEVC when the board can decode it — it's the hardware path - # on Pi 5 and is more efficient than H.264 wherever it's available. - # Boards without HEVC support (Pi 2/3) fall back to H.264. + # Prefer HEVC when the board can hardware-decode it; fall back to H.264. _preferred_vcodec = 'hevc' if 'hevc' in _supported else 'h264' ydl_opts = { From c85ac0001c4387fc95398bd6e188fedeba627076 Mon Sep 17 00:00:00 2001 From: Guillaume Jobin Date: Wed, 24 Jun 2026 07:28:54 -0400 Subject: [PATCH 5/6] style: apply ruff format to fix CI linter failure Co-Authored-By: Claude Sonnet 4.6 --- src/anthias_server/celery_tasks.py | 8 +++++++- tests/test_celery_tasks.py | 9 ++++++--- tests/test_processing.py | 4 +++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/anthias_server/celery_tasks.py b/src/anthias_server/celery_tasks.py index 5a4df213e..e2e12f5e5 100755 --- a/src/anthias_server/celery_tasks.py +++ b/src/anthias_server/celery_tasks.py @@ -693,6 +693,7 @@ def download_youtube_asset(asset_id: str, uri: str) -> None: # Boards without HEVC (Pi 2/3) fall back to H.264. Unknown boards # default to H.264; normalize_video_asset gates on the actual codec. from anthias_server.processing import _hw_decoded_codecs + _supported = _hw_decoded_codecs() # Prefer HEVC when the board can hardware-decode it; fall back to H.264. _preferred_vcodec = 'hevc' if 'hevc' in _supported else 'h264' @@ -704,7 +705,12 @@ def download_youtube_asset(asset_id: str, uri: str) -> None: # is available if no exact match exists. Strict ``format=`` # filters would reject videos that only have other codecs, # which we don't want — normalize_video_asset handles that. - 'format_sort': [f'vcodec:{_preferred_vcodec}', 'fps', 'res:1080', 'acodec:m4a'], + 'format_sort': [ + f'vcodec:{_preferred_vcodec}', + 'fps', + 'res:1080', + 'acodec:m4a', + ], # Final filename — yt-dlp writes .part during the # download and renames on success. cleanup() recognises # .part / .info.json sidecars and skips them inside the 1h diff --git a/tests/test_celery_tasks.py b/tests/test_celery_tasks.py index 2b50e5eee..aaa1dee26 100644 --- a/tests/test_celery_tasks.py +++ b/tests/test_celery_tasks.py @@ -1010,9 +1010,12 @@ def test_download_youtube_asset_pi5_prefers_hevc_format( download_youtube_asset('yt-1', 'https://www.youtube.com/watch?v=abc') ydl_opts = fake_youtube_dl._cls.call_args.args[0] - assert ydl_opts['format_sort'] == ['vcodec:hevc', 'fps', 'res:1080', 'acodec:m4a'], ( - f"Pi 5 must bias yt-dlp toward HEVC; got: {ydl_opts['format_sort']!r}" - ) + assert ydl_opts['format_sort'] == [ + 'vcodec:hevc', + 'fps', + 'res:1080', + 'acodec:m4a', + ], f'Pi 5 must bias yt-dlp toward HEVC; got: {ydl_opts["format_sort"]!r}' @pytest.mark.django_db diff --git a/tests/test_processing.py b/tests/test_processing.py index 95bfcfe48..10d429291 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -680,7 +680,9 @@ def test_video_unsupported_codec_raises_with_ffmpeg_recipe( assert "'hevc'" in msg assert 'h264' in msg recipe = excinfo.value.recipe - assert 'libx264' in recipe # Pi 3 supports H.264 — recipe encodes to H.264. + assert ( + 'libx264' in recipe + ) # Pi 3 supports H.264 — recipe encodes to H.264. tokens = _shlex.split(recipe) assert tokens[1] == '-i' assert tokens[2] == 'beach-clip.mp4' From d2c6e239571fc8c29186a388085731dd1a83735e Mon Sep 17 00:00:00 2001 From: Guillaume Jobin Date: Wed, 24 Jun 2026 07:55:15 -0400 Subject: [PATCH 6/6] refactor: move yt-dlp vcodec preference into _PREFERRED_DOWNLOAD_VCODEC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add _PREFERRED_DOWNLOAD_VCODEC dict and preferred_download_vcodec() in processing.py alongside _HW_DECODE_VIDEO_CODECS, so per-board download preferences live with the rest of the codec definitions rather than as ad-hoc logic in celery_tasks.py. Pi 5 keeps its HEVC preference (hardware decode path). Pi 4, x86, Rock Pi 4 and all other boards revert to H.264 — it is their primary hardware path and more widely available on YouTube. x86 HEVC via VAAPI is known-broken (black screen), so narrowing the HEVC bias to Pi 5 only closes that risk. Co-Authored-By: Claude Sonnet 4.6 --- src/anthias_server/celery_tasks.py | 14 ++--------- src/anthias_server/processing.py | 22 +++++++++++++++++ tests/test_celery_tasks.py | 39 ++++++++++++++++++++++-------- tests/test_processing.py | 30 +++++++++++++++++++++++ 4 files changed, 83 insertions(+), 22 deletions(-) diff --git a/src/anthias_server/celery_tasks.py b/src/anthias_server/celery_tasks.py index e2e12f5e5..076b7b9f3 100755 --- a/src/anthias_server/celery_tasks.py +++ b/src/anthias_server/celery_tasks.py @@ -686,17 +686,7 @@ def download_youtube_asset(asset_id: str, uri: str) -> None: from yt_dlp import YoutubeDL from yt_dlp.utils import DownloadError - # Bias yt-dlp toward the board's hardware-decoded codec so playback - # is as efficient as possible. Pi 5 prefers HEVC (VideoCore VII HW - # path) but also accepts H.264 via software decode on Cortex-A76 — - # YouTube rarely serves HEVC so H.264 is the realistic outcome. - # Boards without HEVC (Pi 2/3) fall back to H.264. Unknown boards - # default to H.264; normalize_video_asset gates on the actual codec. - from anthias_server.processing import _hw_decoded_codecs - - _supported = _hw_decoded_codecs() - # Prefer HEVC when the board can hardware-decode it; fall back to H.264. - _preferred_vcodec = 'hevc' if 'hevc' in _supported else 'h264' + from anthias_server.processing import preferred_download_vcodec ydl_opts = { # ``format_sort`` biases toward the board's preferred codec, @@ -706,7 +696,7 @@ def download_youtube_asset(asset_id: str, uri: str) -> None: # filters would reject videos that only have other codecs, # which we don't want — normalize_video_asset handles that. 'format_sort': [ - f'vcodec:{_preferred_vcodec}', + f'vcodec:{preferred_download_vcodec()}', 'fps', 'res:1080', 'acodec:m4a', diff --git a/src/anthias_server/processing.py b/src/anthias_server/processing.py index ac3bc1278..87b074fc8 100644 --- a/src/anthias_server/processing.py +++ b/src/anthias_server/processing.py @@ -937,6 +937,28 @@ def _hw_decoded_codecs() -> frozenset[str]: return _HW_DECODE_VIDEO_CODECS.get(resolve_device_key(), frozenset()) +# Preferred yt-dlp ``vcodec`` sort key per board. Distinct from the +# accepted-codec gate above: Pi 5 accepts H.264 via software decode +# (Cortex-A76 handles 1080p without frame drops) but HEVC is still the +# hardware path, so downloads should bias toward it when available. +# Boards not listed here default to ``h264`` — it is more widely +# available on YouTube and is their primary hardware decode path. +_PREFERRED_DOWNLOAD_VCODEC: dict[str, str] = { + 'pi5': 'hevc', +} + + +def preferred_download_vcodec() -> str: + """yt-dlp ``vcodec`` sort preference for the current board. + + Returns the codec string to place first in ``format_sort`` so + yt-dlp biases downloads toward the board's best playback path. + Falls back to ``'h264'`` for unknown boards and all boards where + H.264 is the primary hardware decode path. + """ + return _PREFERRED_DOWNLOAD_VCODEC.get(resolve_device_key(), 'h264') + + def _ffmpeg_reencode_recipe( supported: frozenset[str], source_filename: str = '', diff --git a/tests/test_celery_tasks.py b/tests/test_celery_tasks.py index aaa1dee26..45e9fefb2 100644 --- a/tests/test_celery_tasks.py +++ b/tests/test_celery_tasks.py @@ -996,10 +996,9 @@ def test_download_youtube_asset_pi5_prefers_hevc_format( fake_youtube_dl: mock.MagicMock, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Pi 5 is HEVC-only; yt-dlp must receive vcodec:hevc as its first - format_sort preference so the download avoids an H.264 stream that - normalize_video_asset would immediately reject with - "Video codec 'h264' is not hardware-decoded on this device." (GH #3092).""" + """Pi 5 biases yt-dlp toward HEVC — it is the hardware decode path + (VideoCore VII). H.264 is accepted as a software fallback (GH #3092) + but HEVC is preferred when YouTube serves it.""" monkeypatch.setenv('DEVICE_TYPE', 'pi5') _make_youtube_asset() fake_youtube_dl.extract_info.return_value = {'title': 't', 'duration': 10} @@ -1019,14 +1018,13 @@ def test_download_youtube_asset_pi5_prefers_hevc_format( @pytest.mark.django_db -def test_download_youtube_asset_pi4_prefers_hevc_format( +def test_download_youtube_asset_pi4_prefers_h264_format( fake_youtube_dl: mock.MagicMock, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Pi 4 supports both H.264 and HEVC hardware decode; HEVC is - preferred in format_sort as the more efficient codec. In practice - YouTube rarely serves HEVC so yt-dlp falls back to H.264, which - Pi 4 can also hardware-decode.""" + """Pi 4 hardware-decodes both H.264 and HEVC, so it keeps the H.264 + preference — H.264 is more widely available on YouTube and Pi 4 plays + it just as efficiently as HEVC.""" monkeypatch.setenv('DEVICE_TYPE', 'pi4-64') _make_youtube_asset() fake_youtube_dl.extract_info.return_value = {'title': 't', 'duration': 10} @@ -1037,7 +1035,28 @@ def test_download_youtube_asset_pi4_prefers_hevc_format( download_youtube_asset('yt-1', 'https://www.youtube.com/watch?v=abc') ydl_opts = fake_youtube_dl._cls.call_args.args[0] - assert ydl_opts['format_sort'][0] == 'vcodec:hevc' + assert ydl_opts['format_sort'][0] == 'vcodec:h264' + + +@pytest.mark.django_db +def test_download_youtube_asset_x86_prefers_h264_format( + fake_youtube_dl: mock.MagicMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """x86 keeps the H.264 preference — HEVC via VAAPI is known to produce + a black screen on x86, so biasing toward HEVC downloads would cause + silent playback failures when YouTube serves HEVC streams.""" + monkeypatch.setenv('DEVICE_TYPE', 'x86') + _make_youtube_asset() + fake_youtube_dl.extract_info.return_value = {'title': 't', 'duration': 10} + with ( + mock.patch('anthias_server.app.consumers.notify_asset_update'), + mock.patch('anthias_server.processing.dispatch_normalize_video'), + ): + download_youtube_asset('yt-1', 'https://www.youtube.com/watch?v=abc') + + ydl_opts = fake_youtube_dl._cls.call_args.args[0] + assert ydl_opts['format_sort'][0] == 'vcodec:h264' @pytest.mark.django_db diff --git a/tests/test_processing.py b/tests/test_processing.py index 10d429291..03c8d1ab6 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -853,6 +853,36 @@ def test_pi3_64_hw_decode_set_is_h264_only( assert processing._hw_decoded_codecs() == frozenset({'h264'}) +# --------------------------------------------------------------------------- +# preferred_download_vcodec — per-board yt-dlp format_sort preference +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + 'device_type, expected', + [ + # Pi 5: HEVC is the hardware decode path; H.264 is software-only. + ('pi5', 'hevc'), + # All other boards: H.264 is the primary HW path and more available + # on YouTube. x86 HEVC via VAAPI is known-broken (black screen). + ('pi4-64', 'h264'), + ('pi3', 'h264'), + ('pi3-64', 'h264'), + ('x86', 'h264'), + ('rockpi4', 'h264'), + # Unknown board must not crash and must default to h264. + ('unrecognised-board-xyz', 'h264'), + ], +) +def test_preferred_download_vcodec( + device_type: str, + expected: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv('DEVICE_TYPE', device_type) + assert processing.preferred_download_vcodec() == expected + + @pytest.mark.parametrize( 'filename', [