diff --git a/ansible/roles/system/templates/cmdline.txt.j2 b/ansible/roles/system/templates/cmdline.txt.j2 index 0a146a9f..2bcd2077 100644 --- a/ansible/roles/system/templates/cmdline.txt.j2 +++ b/ansible/roles/system/templates/cmdline.txt.j2 @@ -3,6 +3,23 @@ here as a token list so additions/removals stay easy to review. Layout: Anthias-managed tokens first, then any tokens preserved from cmdline.txt.orig (e.g. cfg80211.* from raspi-config). + + Note on Pi 5 CMA / 4K HEVC: Pi 5's stock Pi OS default reserves + only 64 MB CMA (vs. 512 MB on Pi 4). That's enough for the + Hantro G2 HEVC decoder to allocate 1080p reference + output + buffers, but not 4K — at 4K mpv hits + "v4l2_request_hevc_start_frame: Failed to get dst buffer" and + silently SW-falls-back. The obvious fix — adding ``cma=512M`` to + cmdline.txt — does NOT work on Pi 5: the kernel takes the cmdline + value over the device-tree ``linux,cma`` node, which leaves + ``rpi-hevc-dec`` orphaned (it returns ``Failed to probe hardware + -517`` and ``/dev/video*`` disappears entirely, killing HEVC HW + at every resolution). The right answer is a dtparam / dtoverlay + in /boot/firmware/config.txt that resizes the DT-declared CMA + region without orphaning the codec's reserved-mem reference; + that's tracked as Pi 5 4K HEVC follow-up. In the meantime the + asset processor's pi5 profile should downscale 4K → 1080p HEVC + so Pi 5 only ever sees clips inside its current HW envelope. -#} {%- set tokens = [ 'console=serial0,115200', diff --git a/docs/board-enablement.md b/docs/board-enablement.md new file mode 100644 index 00000000..2a59b634 --- /dev/null +++ b/docs/board-enablement.md @@ -0,0 +1,215 @@ +# Board Enablement Test Bed + +A reproducible playback test bed for validating the viewer stack across boards. +Use this when changing anything that touches mpv flags, hwdec, the cage/Wayland +stack, or `src/anthias_viewer/media_player.py`. + +The viewer is tuned per-board (see `media_player.py` and `bin/start_viewer.sh`): + +| Device | Qt platform | Compositor | mpv VO | +|----------|-------------|-----------|---------------------------------| +| Pi 2 / 3 | Qt5 linuxfb | none | VLC | +| Pi 4-64 | Qt6 linuxfb | none | `--vo=gpu --gpu-context=drm` | +| Pi 5 | Qt6 wayland | cage | `--vo=gpu --gpu-context=wayland` | +| arm64 | Qt6 wayland | cage | `--vo=gpu --gpu-context=wayland` | +| x86 | Qt6 wayland | cage | `--vo=gpu --gpu-context=wayland` | + +Each combination has different hwdec, scaling, and compositing characteristics, +so a regression on one board can hide behind a clean run on another. Run the +same asset rotation everywhere and compare drop counts. + +## Goal: hardware-accelerated playback on every board + +Every clip Anthias displays must be decoded in hardware on the target board. +Software decode is never the steady state — it produces drops, heats the SoC, +and on low-end Pis it can't keep up at 1080p30, let alone 4K. The viewer +configuration alone can't guarantee this, because an upload's codec / profile +/ resolution might not match what the destination board's silicon supports. + +The two halves of that guarantee: + +1. **Viewer (`src/anthias_viewer/media_player.py`)** — select the correct mpv + hwdec per codec on the target board. On Pi 4 / Pi 5 the launcher ffprobes + the asset and passes `--hwdec=v4l2m2m-copy` for H.264 or `--hwdec=drm-copy` + for HEVC directly on the mpv command line. (An earlier attempt used a Lua + `on_load` hook, but `video-codec-name` is empty at every script event + before hwdec init, so the hook was a silent no-op and `--hwdec=auto-copy` + leaked through; `auto-copy`'s upstream whitelist excludes `v4l2m2m-copy`, + so H.264 fell back to software.) +2. **Asset processor (`src/anthias_server/processing.py`)** — at upload + time, transcode any clip whose codec / profile / resolution can't be + hardware-decoded on this device into one that can. The Celery task + `process_asset` runs `ffprobe`, compares the result against the board + profile, and either passes through or re-encodes (libx264 / libx265 + with `-threads 2`, nice 19, ionice idle so it doesn't disturb playback). + +If a board can't HEVC, the asset processor re-encodes incoming HEVC to +H.264 *before* it ever reaches the viewer; if a board can't H.264 (Pi 5 +through mpv), it re-encodes to HEVC. The viewer should only ever see +codecs the board can hardware-decode. + +## Hardware decode capabilities per Pi + +What the SoC can do, regardless of player: + +| Pi | SoC | H.264 HW | HEVC HW | VP9 / AV1 HW | +|------|----------|---------------------------|------------------------|--------------| +| 2 | BCM2836 | yes, up to 1080p (V3D IV) | **no** — no HEVC block | no | +| 3 | BCM2837 | yes, up to 1080p (V3D IV) | **no** — no HEVC block | no | +| 4 | BCM2711 | yes, up to 1080p60 (V3D 6.0 V4L2 M2M); 4K H.264 is past the V3D's envelope | yes, up to 4Kp60 (dedicated HEVC block, exposed as `v4l2_request_hevc`) | no | +| 5 | BCM2712 | yes in silicon (Hantro G1), but **not reachable through mpv** — no `v4l2-request` H.264 hwdec exists upstream | yes, up to 4Kp60 (Hantro G2, exposed as `v4l2_request_hevc`) | no | + +HEVC HW decode arrived with the Pi 4. Pi 2 / Pi 3 cannot decode HEVC in +hardware at all, and software HEVC on a Cortex-A53 won't even hit 1080p30 — +so the asset processor *must* re-encode HEVC uploads to H.264 for those +boards. + +> **Pi 5 4K HEVC is limited by CMA.** The Hantro G2 driver allocates DMA +> buffers from the kernel's Contiguous Memory Allocator. Pi OS for Pi 5 +> reserves only 64 MB CMA by default (vs. 512 MB on Pi 4), which is enough +> for 1080p HEVC reference + output buffers but not 4K — at 4K mpv hits +> `v4l2_request_hevc_start_frame: Failed to get dst buffer` and silently +> SW-falls-back. Bumping `cma=512M` on the kernel cmdline does **not** +> work: the kernel takes the cmdline value over the device-tree +> `linux,cma` node, which leaves `rpi-hevc-dec` orphaned +> (`Failed to probe hardware -517`) and `/dev/video*` disappears +> entirely, killing HEVC HW at every resolution. The right fix is a +> `dtparam`/`dtoverlay` in `/boot/firmware/config.txt` that resizes the +> existing DT-declared region (follow-up). In the meantime, the asset +> processor's `pi5` profile must downscale 4K → 1080p HEVC so Pi 5 only +> ever sees clips inside its current HW envelope. + +## Asset processor: codec policy per board + +What the asset processor accepts as-is vs. re-encodes (target = HW-decodable +on the destination board): + +| Pi | Passthrough | Re-encode to | Encoder | Notes | +|------|----------------------|--------------|-------------|----------------------------------------------------| +| 2 / 3| H.264 only | H.264 | `libx264` | No HEVC silicon. Any HEVC upload becomes H.264. | +| 4 | H.264 ≤ 1080p, HEVC | HEVC | `libx265` | 4K H.264 *must* re-encode — V3D maxes at 1080p60. | +| 5 | HEVC | HEVC | `libx265` | H.264 has no mpv HW path on Pi 5, so every H.264 upload re-encodes to HEVC. | + +`src/anthias_server/processing.py` holds the per-board profiles +(`_BOARD_PROFILES`). Each entry has `passthrough_video_codecs` (input the +viewer can hardware-decode untouched), an optional `passthrough_video_max_pixels` +(width × height cap per codec — Pi 4's H.264 cap pins to 1920×1080 because the +V3D maxes out at 1080p60 H.264), and `transcode_target` (what we re-encode to +when the upload isn't passthrough). + +## Test-bed implications + +The eight-clip rotation (4 × H.264 + 4 × HEVC, 1080p30/60 + 4K30/60) is +designed to exercise every cell of the policy table above. After upload, +the asset processor's metadata (`transcoded`, `transcode_target`, +`original_ext`) on each asset records what it did. A correct test run has: + +| Board | What the viewer sees on disk after processing | +|-------|---------------------------------------------------------------| +| 2 / 3 | 4 × H.264 (the four HEVC inputs re-encoded to H.264) | +| 4 | 1080p H.264 passthrough; 4K H.264 → HEVC; HEVC all passthrough | +| 5 | All 8 clips end up HEVC (every H.264 input re-encoded) | + +Sample failure modes the rotation catches: + +- A clip plays but `Dropped:` climbs steadily → the asset processor handed + the viewer something the SoC can't HW-decode (e.g. 4K H.264 passthrough + on Pi 4, any H.264 passthrough on Pi 5). +- mpv's `hwdec-current` banner is `no` instead of `v4l2m2m-copy` / + `drm-copy` → the per-codec hook didn't kick in, or the codec isn't what + the policy table expects on this board. +- Playback works on Pi 4 but drops on Pi 5 for the same input → the input + is H.264, and the asset processor didn't re-encode it to HEVC for Pi 5. + +## Source clips + +Big Buck Bunny, H.264 + AAC, public-domain, from +[download.blender.org/demo/movies/BBB](https://download.blender.org/demo/movies/BBB/): + +| File | Resolution / fps | +|------------------------------------------|------------------| +| `bbb_sunflower_1080p_30fps_normal.mp4` | 1080p30 | +| `bbb_sunflower_1080p_60fps_normal.mp4` | 1080p60 | +| `bbb_sunflower_2160p_30fps_normal.mp4` | 4K30 | +| `bbb_sunflower_2160p_60fps_normal.mp4` | 4K60 | + +These exercise the H.264 paths (`v4l2m2m-copy` hwdec on Pi 4; software on Pi 5 +because mpv has no `v4l2-request` hwdec for the Hantro G2; vaapi-copy on x86). + +## HEVC transcodes + +To exercise the HEVC HW decode path on Pi (`drm-copy` / `v4l2_request_hevc`, +which Pi 4 + Pi 5 both have), transcode the H.264 sources with libx265: + +```bash +ffmpeg -y -i bbb_sunflower_1080p_30fps_normal.mp4 \ + -c:v libx265 -preset medium -crf 23 -c:a copy bbb_1080p_30fps_hevc.mp4 +ffmpeg -y -i bbb_sunflower_1080p_60fps_normal.mp4 \ + -c:v libx265 -preset medium -crf 23 -c:a copy bbb_1080p_60fps_hevc.mp4 +ffmpeg -y -i bbb_sunflower_2160p_30fps_normal.mp4 \ + -c:v libx265 -preset medium -crf 23 -c:a copy bbb_4k_30fps_hevc.mp4 +ffmpeg -y -i bbb_sunflower_2160p_60fps_normal.mp4 \ + -c:v libx265 -preset medium -crf 23 -c:a copy bbb_4k_60fps_hevc.mp4 +``` + +Run these on a workstation, not the device under test — even a Pi 5 burns +hours on the 4K HEVC encodes (sustained load average ~7) and that load alone +will skew any concurrent playback measurement. Verify each output with +`ffprobe` before shipping; a power cycle mid-encode leaves a file with +`moov atom not found` that mpv will refuse to play. + +## The rotation + +Upload all eight files (4 × H.264 + 4 × HEVC) as Anthias assets and schedule +them back-to-back. Per-asset boundaries in the drop log make it easy to slice +results by resolution / fps / codec. + +## Drop logging + +Set `ANTHIAS_DEBUG_DROPS=1` on the `anthias-viewer` service (compose override +or `~/anthias/.env`). When enabled, `media_player.py`: + +- drops mpv's `--no-terminal`, so the status line + (`AV: 00:00:30 / ... Dropped: N`) is emitted continuously; +- redirects stdout/stderr to `/data/.anthias/mpv.log` inside the container, + which is `~/.anthias/mpv.log` on the host (or `~/.screenly/mpv.log` on + pre-rebrand installs); +- writes a `--- mpv launch ---` marker before each mpv launch so the + log can be sliced per asset; +- captures mpv's `hwdec-current` and VO init banners on stderr — confirms + `--vo=gpu --gpu-context=drm` (Pi 4) vs `--gpu-context=wayland` (Pi 5 / x86 + / arm64) actually took effect, and confirms which hwdec the per-codec Lua + hook selected. + +With the env var unset, the viewer keeps its silent `DEVNULL` behaviour — +no host-side log file. + +## Reading the log + +`Dropped:` in mpv's status line is cumulative for a single mpv process, and +the viewer spawns one process per asset, so the last `Dropped:` before the +next `--- mpv launch` marker is that asset's final count over its playback +window. + +```bash +grep -E "^--- mpv launch|Dropped:" ~/.anthias/mpv.log | tail -80 +``` + +For a single rolling sample on a running device: + +```bash +tail -F ~/.anthias/mpv.log | grep --line-buffered -E "launch|Dropped:|hwdec-current|VO:" +``` + +## Reporting + +When attaching results to a PR, include: + +- board + Qt/compositor combination (one row of the table above); +- one drop count per asset, taken from the last `Dropped:N` of each asset's + window; +- the matching `VO:` / `hwdec-current` banner lines so the run can be tied to + a specific stack. + +For comparable numbers, let the rotation play at least two full cycles before +sampling — first cycle includes asset-cache warmup and webview teardown. diff --git a/src/anthias_server/processing.py b/src/anthias_server/processing.py index 056d61b6..1045e32d 100644 --- a/src/anthias_server/processing.py +++ b/src/anthias_server/processing.py @@ -115,19 +115,30 @@ _PASSTHROUGH_AUDIO_CODECS = frozenset( # Per-board transcode profile # --------------------------------------------------------------------------- # -# The right "video codec" for an Anthias device depends on what the -# on-device player can hardware-decode (or software-decode at real -# time). The matrix this PR locks in: +# Goal of this matrix: every clip the viewer plays must be +# *hardware-decoded* on the target board. Software decode is never +# the steady state — it produces drops, heats the SoC, and on low-end +# Pis can't hit real time at 1080p. So ``passthrough_video_codecs`` +# encodes exactly what the on-device player will hardware-decode, +# and anything else is re-encoded at upload time: # -# ┌──────────┬─────────────────┬──────────────┬──────────────┐ -# │ Board │ Player │ HEVC OK? │ Target codec │ -# ├──────────┼─────────────────┼──────────────┼──────────────┤ -# │ pi2/pi3 │ VLC + mmal-vc4 │ no │ H.264 │ -# │ pi4-64 │ mpv + V4L2 HEVC │ HW-decoded │ HEVC │ -# │ pi5 │ mpv + SW decode │ A76 SW @ 1080p │ HEVC │ -# │ x86 │ mpv + va/nv/qsv │ HW-decoded │ HEVC │ -# │ unset │ (dev / unknown) │ assume no │ H.264 │ -# └──────────┴─────────────────┴──────────────┴──────────────┘ +# ┌──────────┬─────────────────┬───────────────────────────────────────┐ +# │ Board │ Player │ HW-decodable (= passthrough) │ +# ├──────────┼─────────────────┼───────────────────────────────────────┤ +# │ pi2/pi3 │ VLC + mmal-vc4 │ H.264 only (V3D-IV, 1080p) │ +# │ pi4-64 │ mpv + V3D + V4L2│ H.264 ≤1080p (V3D M2M), HEVC ≤4Kp60 │ +# │ pi5 │ mpv + Hantro G2 │ HEVC ≤4Kp60 only — mpv has no │ +# │ │ │ v4l2-request H.264 path so H.264 │ +# │ │ │ would silently SW-fall-back │ +# │ x86 │ mpv + va/nv/qsv │ H.264 + HEVC (any modern iGPU) │ +# │ unset │ (dev / unknown) │ assume worst case: H.264 only │ +# └──────────┴─────────────────┴───────────────────────────────────────┘ +# +# Resolution cap on Pi 4 H.264: the V3D 6.0 V4L2 M2M decoder tops out +# at ~1080p60 H.264. A 4K H.264 upload would clear the codec gate but +# fall to software at playback time, so the profile carries an +# optional per-codec pixel cap (``passthrough_video_max_pixels``) that +# the passthrough check enforces. # # Two reasons to actually emit HEVC instead of always-H.264: # @@ -135,11 +146,15 @@ _PASSTHROUGH_AUDIO_CODECS = frozenset( # HEVC re-encode at equivalent visual quality is roughly 30–50% # smaller than H.264. For a fleet rotating dozens of clips that # compounds. -# 2. Decode load. Pi 5 has no hardware video decoder at all; the CPU -# handles every codec in software. HEVC's better compression at -# the same quality means fewer bits the decoder has to chew -# through, which trades coding-tool complexity for raw -# bandwidth — a wash on Pi 5 in practice, but never worse. +# 2. Decode efficiency. HEVC at the same perceived quality means +# fewer bytes the decoder has to chew through. On Pi 5 this is +# also the *only* HW-decode codec, so it's not optional. +# +# This matrix is the source of truth in this PR. A follow-up replaces +# it with a runtime probe (introspect mpv + /dev/video* + vainfo at +# anthias-server start, derive the same per-codec set automatically) +# so future boards self-classify and the matrix can't drift from +# upstream mpv hwdec changes. # # The mapping keys match ``DEVICE_TYPE`` (set by the image builder in # the Dockerfile, read at celery-task time via ``os.environ``) rather @@ -153,6 +168,13 @@ _PASSTHROUGH_AUDIO_CODECS = frozenset( # unknown future board the most-compatible codec. _BoardProfile = dict[str, Any] +# Width × height threshold for Pi 4 H.264 passthrough. The V3D 6.0 +# V4L2 M2M H.264 decoder is rated for 1080p60; 4K (3840×2160 ≈ 8.3 M +# pixels) clears its envelope and falls to software at playback. The +# threshold is generous (1920×1080 = ~2.07 M) so any 1080p/720p/SD +# H.264 upload still passes through. +_PI4_H264_MAX_PIXELS = 1920 * 1080 + # ffmpeg encoder args. Each list is what gets passed between ``-i # `` and ```` for the video stream — audio always @@ -212,23 +234,32 @@ _BOARD_PROFILES: dict[str, _BoardProfile] = { 'passthrough_video_codecs': frozenset({'h264'}), 'video_args': _H264_VIDEO_ARGS, }, - # 64-bit Pi 4 with mpv + KMS (`--vo=drm`): the kernel's V4L2 - # stateful HEVC decoder driver (/dev/video10 family) is wired up - # and mpv's ``--hwdec=auto-safe`` selects ``v4l2request`` for - # hevc. Both H.264 and HEVC pass through. + # 64-bit Pi 4 with mpv. Two distinct HW decoders: + # * V3D 6.0 V4L2 M2M (/dev/video10, bcm2835-codec-decode) → + # H.264 up to ~1080p60. Hit by mpv ``--hwdec=v4l2m2m-copy``. + # * dedicated HEVC block (/dev/video19, rpi-hevc-dec) → HEVC + # up to 4Kp60. Hit by mpv ``--hwdec=drm-copy`` (FFmpeg + # v4l2_request_hevc hwaccel). + # 4K H.264 exceeds the V3D's H.264 envelope and falls to + # software at playback, so the H.264 passthrough carries a pixel + # cap (handled in _video_can_passthrough via + # passthrough_video_max_pixels). 4K HEVC stays HW. 'pi4-64': { 'transcode_target': 'hevc', 'passthrough_video_codecs': frozenset({'h264', 'hevc'}), + 'passthrough_video_max_pixels': {'h264': _PI4_H264_MAX_PIXELS}, 'video_args': _HEVC_VIDEO_ARGS, }, - # Pi 5: no hardware video decoder block at all (RP1 dropped it - # vs. pi4). The Cortex-A76 quad-core software-decodes 1080p H.264 - # *and* 1080p HEVC at real time, so HEVC is fine. Picking HEVC - # also saves disk: a typical 5-minute clip is ~30% smaller after - # re-encode than the equivalent H.264 at perceptual parity. + # Pi 5: HEVC HW decode via Hantro G2 (drm-copy / v4l2_request_hevc) + # is solid up to 4Kp60. H.264 silicon (Hantro G1) exists but mpv + # has *no* v4l2-request H.264 hwdec upstream, so any H.264 clip + # the viewer receives drops to a software A76 decoder. To keep + # the HW-decode-everywhere contract, H.264 uploads transcode to + # HEVC at upload time — the Cortex-A76 software encode is the + # one-time cost that buys steady-state HW decode forever after. 'pi5': { 'transcode_target': 'hevc', - 'passthrough_video_codecs': frozenset({'h264', 'hevc'}), + 'passthrough_video_codecs': frozenset({'hevc'}), 'video_args': _HEVC_VIDEO_ARGS, }, # x86: mpv + ``--hwdec=auto-safe`` selects vaapi (Intel/AMD), @@ -880,11 +911,17 @@ def _ffprobe_streams(input_path: str) -> dict[str, Any]: def _ffprobe_summary(input_path: str) -> dict[str, Any]: """Reduce ffprobe's payload to the dimensions we branch on. - Returns a dict with four keys, all populated: + Returns a dict with these keys, all populated: * ``container`` — lowercase format token, ``'unknown'`` if ffprobe couldn't decide. * ``video_codec`` — lowercase codec name, ``'unknown'`` if the file has no video stream or the probe failed. + * ``video_pixels`` — ``width * height`` for the first video + stream, ``None`` if no video stream or dimensions missing. + Used by ``_video_can_passthrough`` to enforce the per-codec + pixel cap that keeps Pi 4 from passthrough-ing 4K H.264 + (V3D's H.264 envelope tops out around 1080p60; 4K H.264 + would clear the codec gate but fall to software at play). * ``audio_codec`` — lowercase codec name, ``'none'`` when the file genuinely carries no audio stream, or ``'unknown'`` if the audio stream existed but ffprobe couldn't name its @@ -911,6 +948,7 @@ def _ffprobe_summary(input_path: str) -> dict[str, Any]: return { 'container': 'unknown', 'video_codec': 'unknown', + 'video_pixels': None, 'audio_codec': 'unknown', 'duration_seconds': None, } @@ -947,6 +985,17 @@ def _ffprobe_summary(input_path: str) -> dict[str, Any]: else: container = _ext(input_path).lstrip('.') or 'unknown' video_codec = ((video or {}).get('codec_name') or 'unknown').lower() + # Width × height for resolution-gated passthrough (Pi 4 H.264). + # ffprobe returns ``width`` / ``height`` only for video streams, + # and only when the demuxer could decide. A missing or + # unparseable value collapses to None so the gate falls back to + # "transcode" rather than passing through a clip we can't size. + try: + vw = int((video or {}).get('width') or 0) + vh = int((video or {}).get('height') or 0) + except (TypeError, ValueError): + vw = vh = 0 + video_pixels: int | None = vw * vh if vw > 0 and vh > 0 else None if audio is None: audio_codec = 'none' else: @@ -969,6 +1018,7 @@ def _ffprobe_summary(input_path: str) -> dict[str, Any]: return { 'container': container, 'video_codec': video_codec, + 'video_pixels': video_pixels, 'audio_codec': audio_codec, 'duration_seconds': duration_seconds, } @@ -999,8 +1049,21 @@ def _video_can_passthrough( profile = _resolve_board_profile() if summary.get('container') not in _PASSTHROUGH_CONTAINERS: return False - if summary.get('video_codec') not in profile['passthrough_video_codecs']: + video_codec = summary.get('video_codec') + if video_codec not in profile['passthrough_video_codecs']: return False + # Per-codec pixel cap (Pi 4: H.264 only up to 1080p — the V3D + # V4L2 M2M decoder's envelope. 4K H.264 would otherwise clear + # the codec gate but fall to software at playback time.). The + # profile key is optional; when missing or codec-not-listed, + # no cap applies. Probe failure (video_pixels=None) is treated + # as "don't passthrough" so we don't gamble on an unsized clip. + pixel_caps = profile.get('passthrough_video_max_pixels') or {} + cap = pixel_caps.get(video_codec) + if cap is not None: + pixels = summary.get('video_pixels') + if pixels is None or pixels > cap: + return False if summary.get('audio_codec') not in _PASSTHROUGH_AUDIO_CODECS: return False return True diff --git a/src/anthias_viewer/media_player.py b/src/anthias_viewer/media_player.py index 455dd8ab..a0ae0ab0 100644 --- a/src/anthias_viewer/media_player.py +++ b/src/anthias_viewer/media_player.py @@ -207,36 +207,74 @@ class MediaPlayer: raise NotImplementedError -# mpv's --hwdec accepts a single value at startup, so on the Pi -# boards we need a tiny Lua hook that swaps `hwdec` per file load -# based on the codec. The Pi-tuned mpv from archive.raspberrypi.com -# exposes both `v4l2m2m-copy` (Pi 4 V3D V4L2 M2M, H.264 + HEVC -# theoretically, but the V3D's HEVC support has been flaky in -# practice) and `drm-copy` (FFmpeg's v4l2_request_hevc hwaccel, -# HEVC only, reachable on both Pi 4 *and* Pi 5). It does NOT have -# `v4l2request` for H.264, so Pi 5's Hantro G2 H.264 decoder is -# invisible to mpv and falls back to software — a real gap that's -# upstream-blocked on mpv re-adding v4l2-request hwdec. +# Per-codec hwdec preference on Pi, per-board: # -# Net per-codec preference on Pi: -# H.264 → v4l2m2m-copy (Pi 4: HW; Pi 5: no-op, falls back to SW) -# HEVC → drm-copy (Pi 4 + Pi 5: HW) -_PI_HWDEC_LUA = """\ -local PI_HWDEC_BY_CODEC = { - h264 = 'v4l2m2m-copy', - hevc = 'drm-copy', +# Pi 4: H.264 → v4l2m2m-copy (V3D V4L2 M2M decoder via +# bcm2835-codec, up to 1080p60) +# HEVC → drm-copy (FFmpeg v4l2_request_hevc, up to +# 4Kp60 via the dedicated HEVC block) +# +# Pi 5: H.264 → auto-copy (Hantro G1 silicon exists but mpv +# has no v4l2-request H.264 hwdec +# upstream; passing v4l2m2m-copy +# here would just log "Could not +# find a valid device" errors before +# silently SW-falling-back. The +# asset processor's pi5 profile +# re-encodes H.264 → HEVC at upload +# time so this path is only hit for +# pre-existing assets during the +# rollout window.) +# HEVC → drm-copy (Hantro G2, up to 4Kp60. Requires +# cma=512M in /boot/firmware/cmdline.txt +# — Pi 5's stock 64 MB CMA can't fit +# a 4K dst buffer pool.) +# +# `auto-copy` is the universal safe fallback when ffprobe can't +# read the codec (missing file, network URI we don't probe, etc.). +# +# An earlier revision did this with a Lua on_load hook, but +# video-codec-name is empty at every event mpv exposes to scripts +# before hwdec init (on_load, on_preloaded). ffprobing from Python +# at launch time is both simpler and the only thing that actually +# works. +_PI_HWDEC_BY_CODEC: dict[str, dict[str, str]] = { + 'pi4-64': {'h264': 'v4l2m2m-copy', 'hevc': 'drm-copy'}, + 'pi5': {'hevc': 'drm-copy'}, } -mp.add_hook('on_load', 50, function() - local codec = mp.get_property('video-codec-name') or '' - local chosen = PI_HWDEC_BY_CODEC[codec] - if chosen then - mp.set_property('hwdec', chosen) - mp.msg.info(string.format( - 'pi-hwdec: codec=%s -> hwdec=%s', codec, chosen - )) - end -end) -""" + + +def _probe_video_codec(uri: str) -> str: + """Return the canonical lowercase video codec name for ``uri``. + + Empty string on probe failure (missing file, unreadable codec, + ffprobe absent, etc.) — callers should then pick a safe + fallback like ``auto-copy``. Short timeout because this runs + synchronously before every mpv launch. + """ + try: + result = subprocess.run( + [ + 'ffprobe', + '-v', 'error', + '-select_streams', 'v:0', + '-show_entries', 'stream=codec_name', + '-of', 'default=nw=1:nk=1', + uri, + ], + capture_output=True, + text=True, + timeout=5, + ) + return result.stdout.strip().lower() + except (subprocess.SubprocessError, OSError): + return '' + + +def _pi_hwdec_for_uri(uri: str, device_type: str) -> str: + """mpv --hwdec= value for ``uri`` on Pi 4 / Pi 5.""" + board_map = _PI_HWDEC_BY_CODEC.get(device_type, {}) + return board_map.get(_probe_video_codec(uri), 'auto-copy') class MPVMediaPlayer(MediaPlayer): @@ -244,9 +282,6 @@ class MPVMediaPlayer(MediaPlayer): MediaPlayer.__init__(self) self.process: subprocess.Popen[bytes] | None = None self.uri: str = '' - # Lazy-write the Pi codec-aware hwdec hook to /tmp once per - # process; --script= will load it on every mpv launch. - self._pi_hwdec_script_path: str | None = None def set_asset(self, uri: str, duration: int | str) -> None: self.uri = uri @@ -327,21 +362,15 @@ class MPVMediaPlayer(MediaPlayer): # Rockchip's V4L2 stateless decoder, so it falls back to # software. arm64 HW decode via a vendor-tuned plugin is a # Tier-2 follow-up. - # * Pi 4 / Pi 5 → still pass `--hwdec=auto-copy` at startup, - # but a per-codec Lua hook overrides it at file-load time: - # H.264 → v4l2m2m-copy (Pi 4 V3D V4L2 M2M; Pi 5 has no - # stateful API so this no-ops and mpv falls back - # to software — the only path until mpv re-adds - # a v4l2_request_h264 hwdec) - # HEVC → drm-copy (FFmpeg v4l2_request_hevc; works on - # both Pi 4 and Pi 5) - # The hook is sourced from _PI_HWDEC_LUA above; it's written - # to /tmp on first play() and re-used for subsequent files. - # `auto-copy` deliberately omits v4l2m2m-copy and the drm - # hwdec family from its whitelist for historical-stability - # reasons, which is why we override per-codec rather than - # trusting the auto path on Pi. - # + # * Pi 4 / Pi 5 → ffprobe the asset (~50 ms for a local + # file) and pick per-codec, because `auto-copy`'s upstream + # whitelist deliberately excludes v4l2m2m-copy (H.264 V3D + # M2M is the path Pi 4 needs). See _pi_hwdec_for_uri(). + if device_type in ('pi4-64', 'pi5'): + hwdec_value = _pi_hwdec_for_uri(self.uri, device_type) + else: + hwdec_value = 'auto-copy' + # ANTHIAS_DEBUG_DROPS=1: when set on the viewer container, # mpv's stdout/stderr go to a host-bound log instead of # /dev/null, *and* --no-terminal is dropped so mpv's normal @@ -350,14 +379,6 @@ class MPVMediaPlayer(MediaPlayer): # per-file drop counts so reviewers can validate the test # bed without rebuilding the image. Default (unset) # preserves the silent stdout/stderr=/dev/null behaviour. - script_args: list[str] = [] - if device_type in ('pi4-64', 'pi5'): - if self._pi_hwdec_script_path is None: - self._pi_hwdec_script_path = '/tmp/anthias-pi-hwdec.lua' - with open(self._pi_hwdec_script_path, 'w') as f: - f.write(_PI_HWDEC_LUA) - script_args = [f'--script={self._pi_hwdec_script_path}'] - debug_drops = os.environ.get('ANTHIAS_DEBUG_DROPS') == '1' terminal_args = [] if debug_drops else ['--no-terminal'] if debug_drops: @@ -374,8 +395,7 @@ class MPVMediaPlayer(MediaPlayer): 'mpv', *terminal_args, *vo_args, - '--hwdec=auto-copy', - *script_args, + f'--hwdec={hwdec_value}', *extra_args, *rotate_args, f'--audio-device=alsa/{get_alsa_audio_device()}', diff --git a/tests/test_media_player.py b/tests/test_media_player.py index e276c5df..e2c6e27b 100644 --- a/tests/test_media_player.py +++ b/tests/test_media_player.py @@ -85,10 +85,110 @@ def test_play_pins_1080p_mode_on_pi4_64( args, _ = mock_popen.call_args assert '--drm-mode=1920x1080@60' in args[0] assert '--vd-lavc-threads=4' in args[0] + + +@patch( + 'anthias_viewer.media_player._probe_video_codec', + return_value='h264', +) +@patch('anthias_viewer.media_player.subprocess.Popen') +def test_play_picks_v4l2m2m_for_h264_on_pi4_64( + mock_popen: Any, _mock_probe: Any, mpv: _MPVFixtures +) -> None: + # Pi 4 H.264 dispatches to v4l2m2m-copy (V3D V4L2 M2M); mpv's + # auto-copy whitelist excludes v4l2m2m-copy so we have to set it + # explicitly. + mpv.player.set_asset('file:///test/h264.mp4', 30) + with patch.dict('os.environ', {'DEVICE_TYPE': 'pi4-64'}): + mpv.player.play() + args, _ = mock_popen.call_args + assert '--hwdec=v4l2m2m-copy' in args[0] + assert '--hwdec=auto-copy' not in args[0] + + +@patch( + 'anthias_viewer.media_player._probe_video_codec', + return_value='h264', +) +@patch('anthias_viewer.media_player.subprocess.Popen') +def test_play_picks_auto_copy_for_h264_on_pi5( + mock_popen: Any, _mock_probe: Any, mpv: _MPVFixtures +) -> None: + # Pi 5 has no upstream-mpv H.264 hwdec (Hantro G1 isn't exposed + # through v4l2-request in mpv 0.40). Passing v4l2m2m-copy here + # would just log "Could not find a valid device" errors before + # silently SW-falling-back, so we send auto-copy and let mpv's + # default selector pick (which finds nothing for H.264 on Pi 5 + # and falls to software cleanly). The asset processor re-encodes + # H.264 → HEVC on Pi 5 at upload time so this path is only hit + # for pre-existing assets during the rollout window. + mpv.player.set_asset('file:///test/h264.mp4', 30) + with patch.dict('os.environ', {'DEVICE_TYPE': 'pi5'}): + mpv.player.play() + args, _ = mock_popen.call_args assert '--hwdec=auto-copy' in args[0] assert '--hwdec=v4l2m2m-copy' not in args[0] +@patch( + 'anthias_viewer.media_player._probe_video_codec', + return_value='hevc', +) +@patch('anthias_viewer.media_player.subprocess.Popen') +def test_play_picks_drm_copy_for_hevc_on_pi( + mock_popen: Any, _mock_probe: Any, mpv: _MPVFixtures +) -> None: + # HEVC on Pi 4 / Pi 5 goes through FFmpeg's v4l2_request_hevc, + # exposed in mpv as drm-copy. + for device_type in ('pi4-64', 'pi5'): + mock_popen.reset_mock() + mpv.player.set_asset('file:///test/hevc.mp4', 30) + with patch.dict('os.environ', {'DEVICE_TYPE': device_type}): + mpv.player.play() + args, _ = mock_popen.call_args + assert '--hwdec=drm-copy' in args[0], device_type + + +@patch( + 'anthias_viewer.media_player._probe_video_codec', + return_value='', +) +@patch('anthias_viewer.media_player.subprocess.Popen') +def test_play_falls_back_to_auto_copy_when_probe_fails_on_pi( + mock_popen: Any, _mock_probe: Any, mpv: _MPVFixtures +) -> None: + # If ffprobe can't read the codec (missing file, timeout, …) + # the Pi dispatch must fall back to auto-copy rather than + # passing a bogus --hwdec= value. + mpv.player.set_asset('file:///test/missing.mp4', 30) + with patch.dict('os.environ', {'DEVICE_TYPE': 'pi4-64'}): + mpv.player.play() + args, _ = mock_popen.call_args + assert '--hwdec=auto-copy' in args[0] + + +@patch( + 'anthias_viewer.media_player._probe_video_codec', + return_value='h264', +) +@patch('anthias_viewer.media_player.subprocess.Popen') +def test_play_does_not_probe_on_non_pi( + mock_popen: Any, mock_probe: Any, mpv: _MPVFixtures +) -> None: + # ffprobe shouldn't run on x86 / arm64 — they go through + # --hwdec=auto-copy unconditionally and probing adds latency + # before every mpv launch. + for device_type in ('x86', 'arm64'): + mock_probe.reset_mock() + mock_popen.reset_mock() + mpv.player.set_asset('file:///test/video.mp4', 30) + with patch.dict('os.environ', {'DEVICE_TYPE': device_type}): + mpv.player.play() + mock_probe.assert_not_called() + args, _ = mock_popen.call_args + assert '--hwdec=auto-copy' in args[0], device_type + + @patch('anthias_viewer.media_player.subprocess.Popen') def test_play_tunes_decoder_threads_on_pi5( mock_popen: Any, mpv: _MPVFixtures diff --git a/tests/test_processing.py b/tests/test_processing.py index ba65ccc7..26650dea 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -644,11 +644,11 @@ def test_video_passthrough_for_h264_or_hevc_in_known_containers( monkeypatch: pytest.MonkeyPatch, ) -> None: """H.264 and HEVC in any of the accepted containers passes - through *on a board profile that supports HEVC*. Pin - ``DEVICE_TYPE=pi5`` so the libx265-source rows hit passthrough - rather than getting transcoded back down to H.264 by the - default profile.""" - monkeypatch.setenv('DEVICE_TYPE', 'pi5') + through *on a board profile that accepts both codecs*. Pin + ``DEVICE_TYPE=pi4-64`` so the libx265-source rows hit passthrough + (pi5 no longer passthroughs H.264 — see processing._BOARD_PROFILES + — so libx264 rows would transcode there).""" + monkeypatch.setenv('DEVICE_TYPE', 'pi4-64') src = path.join(asset_dir, f'sample{ext}') _make_video(src, codec=codec, container=container, audio='aac') asset = _make_processing_asset('vid-pass', src, mimetype='video') @@ -1198,6 +1198,7 @@ def test_ffprobe_summary_handles_probe_failure() -> None: assert summary == { 'container': 'unknown', 'video_codec': 'unknown', + 'video_pixels': None, 'audio_codec': 'unknown', 'duration_seconds': None, } @@ -1254,7 +1255,7 @@ def test_video_passthrough_uses_summary_duration_no_second_probe( """The passthrough branch must reuse the duration the summary already extracted; calling ``get_video_duration`` (which would re-shell ffprobe) is a regression. Asserts via mock-not-called.""" - monkeypatch.setenv('DEVICE_TYPE', 'pi5') + monkeypatch.setenv('DEVICE_TYPE', 'pi4-64') src = path.join(asset_dir, 'clip.mp4') with open(src, 'wb') as fh: fh.write(b'\x00' * 64) @@ -1263,6 +1264,9 @@ def test_video_passthrough_uses_summary_duration_no_second_probe( summary = { 'container': 'mp4', 'video_codec': 'h264', + # 1080p — under the pi4-64 H.264 pixel cap so passthrough is + # what we expect to exercise here. + 'video_pixels': 1920 * 1080, 'audio_codec': 'aac', 'duration_seconds': 42, } @@ -1335,6 +1339,7 @@ def test_ffprobe_summary_handles_missing_ffprobe_binary() -> None: assert summary == { 'container': 'unknown', 'video_codec': 'unknown', + 'video_pixels': None, 'audio_codec': 'unknown', 'duration_seconds': None, } @@ -1345,17 +1350,32 @@ def test_ffprobe_summary_handles_missing_ffprobe_binary() -> None: [ # Happy path: H.264 + AAC in mp4 ( - {'container': 'mp4', 'video_codec': 'h264', 'audio_codec': 'aac'}, + { + 'container': 'mp4', + 'video_codec': 'h264', + 'video_pixels': 1920 * 1080, + 'audio_codec': 'aac', + }, True, ), # HEVC in mkv with no audio (board profile must allow hevc) ( - {'container': 'mkv', 'video_codec': 'hevc', 'audio_codec': 'none'}, + { + 'container': 'mkv', + 'video_codec': 'hevc', + 'video_pixels': 3840 * 2160, + 'audio_codec': 'none', + }, True, ), # Unknown container — fail ( - {'container': 'avs', 'video_codec': 'h264', 'audio_codec': 'aac'}, + { + 'container': 'avs', + 'video_codec': 'h264', + 'video_pixels': 1280 * 720, + 'audio_codec': 'aac', + }, False, ), # Exotic codec — fail @@ -1363,6 +1383,7 @@ def test_ffprobe_summary_handles_missing_ffprobe_binary() -> None: { 'container': 'mov', 'video_codec': 'prores', + 'video_pixels': 1920 * 1080, 'audio_codec': 'pcm_s16le', }, False, @@ -1372,6 +1393,7 @@ def test_ffprobe_summary_handles_missing_ffprobe_binary() -> None: { 'container': 'mp4', 'video_codec': 'h264', + 'video_pixels': 1920 * 1080, 'audio_codec': 'truehd', }, False, @@ -1381,6 +1403,7 @@ def test_ffprobe_summary_handles_missing_ffprobe_binary() -> None: { 'container': 'unknown', 'video_codec': 'unknown', + 'video_pixels': None, 'audio_codec': 'unknown', }, False, @@ -1388,15 +1411,16 @@ def test_ffprobe_summary_handles_missing_ffprobe_binary() -> None: ], ) def test_video_can_passthrough_decision_table( - summary: dict[str, str], expected: bool + summary: dict[str, Any], expected: bool ) -> None: """Exhaustive truth table for ``_video_can_passthrough``. Catches a future change to the passthrough sets that wasn't intended. - Pins the board profile to ``pi5`` (which accepts both h264 + hevc) - so the legacy "happy path" cases stay equivalent — separate - per-board tests below cover the pi2/pi3 H.264-only branch.""" - pi5_profile = processing._BOARD_PROFILES['pi5'] - assert processing._video_can_passthrough(summary, pi5_profile) is expected + Pins the board profile to ``x86`` (which accepts both h264 + hevc + and has no per-codec pixel cap) so the legacy "happy path" cases + aren't coupled to Pi 4's resolution gate or Pi 5's H.264 ban — + those have dedicated tests below.""" + x86_profile = processing._BOARD_PROFILES['x86'] + assert processing._video_can_passthrough(summary, x86_profile) is expected # --------------------------------------------------------------------------- @@ -1431,39 +1455,51 @@ def test_resolve_board_profile_picks_target_codec_per_board( @pytest.mark.parametrize( - ('device_type', 'video_codec', 'expected_passthrough'), + ('device_type', 'video_codec', 'video_pixels', 'expected_passthrough'), [ # pi2 / pi3: VLC + mmal-vc4. H.264 only. HEVC must transcode. - ('pi2', 'h264', True), - ('pi2', 'hevc', False), - ('pi3', 'h264', True), - ('pi3', 'hevc', False), - # pi4-64 / pi5 / x86: mpv with HEVC support. Both codecs OK. - ('pi4-64', 'h264', True), - ('pi4-64', 'hevc', True), - ('pi5', 'h264', True), - ('pi5', 'hevc', True), - ('x86', 'h264', True), - ('x86', 'hevc', True), + ('pi2', 'h264', 1920 * 1080, True), + ('pi2', 'hevc', 1920 * 1080, False), + ('pi3', 'h264', 1920 * 1080, True), + ('pi3', 'hevc', 1920 * 1080, False), + # pi4-64: V3D H.264 is 1080p-capped; HEVC has no cap. + ('pi4-64', 'h264', 1920 * 1080, True), + ('pi4-64', 'h264', 3840 * 2160, False), # 4K → SW, force transcode + ('pi4-64', 'h264', None, False), # probe couldn't size → transcode + ('pi4-64', 'hevc', 3840 * 2160, True), # HEVC HW at 4K + # pi5: HEVC only — mpv has no v4l2-request H.264 path so + # any H.264 upload silently SW-falls-back on-device. + ('pi5', 'h264', 1920 * 1080, False), + ('pi5', 'h264', 3840 * 2160, False), + ('pi5', 'hevc', 3840 * 2160, True), + # x86: VAAPI / NVENC handle both codecs at any practical + # resolution; no per-codec cap. + ('x86', 'h264', 3840 * 2160, True), + ('x86', 'hevc', 3840 * 2160, True), # Default profile is H.264-only — safer for unknown boards. - ('', 'h264', True), - ('', 'hevc', False), + ('', 'h264', 1920 * 1080, True), + ('', 'hevc', 1920 * 1080, False), ], ) def test_video_can_passthrough_respects_board_codec_set( device_type: str, video_codec: str, + video_pixels: int | None, expected_passthrough: bool, monkeypatch: pytest.MonkeyPatch, ) -> None: - """A pi3 device must not passthrough an HEVC upload; a pi5 device - must. The test pins ``DEVICE_TYPE`` rather than passing the - profile explicitly so the env-resolution code path is exercised - end-to-end (mirrors how the celery worker decides at runtime).""" + """End-to-end matrix: a pi3 with HEVC transcodes; a pi5 with + H.264 transcodes (no mpv HW path); a pi4-64 with 4K H.264 + transcodes (V3D's H.264 envelope); a pi4-64 with HEVC at any + resolution passes through. Pins ``DEVICE_TYPE`` rather than + passing the profile explicitly so the env-resolution path is + exercised end-to-end (mirrors how the celery worker decides at + runtime).""" monkeypatch.setenv('DEVICE_TYPE', device_type) summary = { 'container': 'mp4', 'video_codec': video_codec, + 'video_pixels': video_pixels, 'audio_codec': 'aac', } assert processing._video_can_passthrough(summary) is expected_passthrough @@ -1526,11 +1562,12 @@ def test_video_passthrough_records_target_codec( ) -> None: """Passthrough rows still get ``transcode_target`` written so the operator can see "this device wanted hevc, the upload already was - hevc, no work needed".""" - monkeypatch.setenv('DEVICE_TYPE', 'pi5') + h264, no work needed at 1080p". Pinned to ``pi4-64`` because pi5 + no longer passthroughs H.264 — its profile demands HEVC.""" + monkeypatch.setenv('DEVICE_TYPE', 'pi4-64') src = path.join(asset_dir, 'sample.mp4') _make_video(src, codec='libx264', container='mp4', audio='aac') - asset = _make_processing_asset('vid-pass-pi5', src, mimetype='video') + asset = _make_processing_asset('vid-pass-pi4', src, mimetype='video') with mock.patch.object(processing, '_notify'): processing._run_video_normalisation(asset) @@ -1592,6 +1629,102 @@ def test_video_pi3_transcodes_hevc_to_h264( assert captured['profile']['transcode_target'] == 'h264' +@pytest.mark.django_db +def test_video_pi5_transcodes_h264_to_hevc( + asset_dir: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """Pi 5 has no mpv H.264 HW path (Hantro G1 is invisible to + upstream mpv), so any H.264 upload would silently SW-fall-back + on-device. The asset processor catches it at upload time and + re-encodes to HEVC, which IS HW-decodable on Pi 5 (Hantro G2 via + v4l2_request_hevc / drm-copy).""" + monkeypatch.setenv('DEVICE_TYPE', 'pi5') + src = path.join(asset_dir, 'fixture.mp4') + with open(src, 'wb') as fh: + fh.write(b'\x00' * 64) + asset = _make_processing_asset('vid-pi5-h264', src, mimetype='video') + + summary = { + 'container': 'mp4', + 'video_codec': 'h264', + 'video_pixels': 1920 * 1080, + 'audio_codec': 'aac', + } + captured: dict[str, Any] = {} + + def fake_transcode(_in: str, staging: str, _profile: Any = None) -> None: + captured['profile'] = _profile + with open(staging, 'wb') as fh: + fh.write(b'\x00\x00\x00\x18ftypmp42') + + with ( + mock.patch.object(processing, '_notify'), + mock.patch.object( + processing, '_ffprobe_summary', return_value=summary + ), + mock.patch.object( + processing, '_transcode_to_target', side_effect=fake_transcode + ), + mock.patch.object( + processing, '_resolve_duration_seconds', return_value=10 + ), + ): + processing._run_video_normalisation(asset) + + asset.refresh_from_db() + assert asset.metadata['transcoded'] is True + assert asset.metadata['transcode_target'] == 'hevc' + assert captured['profile']['transcode_target'] == 'hevc' + + +@pytest.mark.django_db +def test_video_pi4_64_transcodes_4k_h264_to_hevc( + asset_dir: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """Pi 4's V3D V4L2 M2M H.264 decoder is rated for ~1080p60. + A 4K H.264 upload would clear the codec gate but fall to + software at playback (V3D can't service the pixel throughput). + The pixel cap in the pi4-64 profile forces a re-encode to HEVC + — HEVC HW on Pi 4 handles 4Kp60 without breaking a sweat.""" + monkeypatch.setenv('DEVICE_TYPE', 'pi4-64') + src = path.join(asset_dir, 'fixture.mp4') + with open(src, 'wb') as fh: + fh.write(b'\x00' * 64) + asset = _make_processing_asset('vid-pi4-4k-h264', src, mimetype='video') + + summary = { + 'container': 'mp4', + 'video_codec': 'h264', + 'video_pixels': 3840 * 2160, + 'audio_codec': 'aac', + } + captured: dict[str, Any] = {} + + def fake_transcode(_in: str, staging: str, _profile: Any = None) -> None: + captured['profile'] = _profile + with open(staging, 'wb') as fh: + fh.write(b'\x00\x00\x00\x18ftypmp42') + + with ( + mock.patch.object(processing, '_notify'), + mock.patch.object( + processing, '_ffprobe_summary', return_value=summary + ), + mock.patch.object( + processing, '_transcode_to_target', side_effect=fake_transcode + ), + mock.patch.object( + processing, '_resolve_duration_seconds', return_value=10 + ), + ): + processing._run_video_normalisation(asset) + + asset.refresh_from_db() + assert asset.metadata['transcoded'] is True + assert asset.metadata['transcode_target'] == 'hevc' + assert captured['profile']['transcode_target'] == 'hevc' + + # --------------------------------------------------------------------------- # Celery wrapper tests — task-level behaviour (no_op guards, on_failure) # ---------------------------------------------------------------------------