fix(viewer,server): HW-decode everywhere on Pi 4 / Pi 5 / x86

The previous per-codec Lua hook in media_player.py was a silent no-op:
mpv's video-codec-name property is empty at every script event before
hwdec init (on_load, on_preloaded), so --hwdec=auto-copy leaked through.
auto-copy's upstream whitelist excludes v4l2m2m-copy, so H.264 on Pi 4
fell back to software despite the V3D V4L2 M2M decoder being available.

Viewer (src/anthias_viewer/media_player.py)

- Replace the Lua hook with ffprobe-driven dispatch from Python at
  launch time. ffprobe is in the viewer image; the call is ~50 ms.
- Per-board mapping: Pi 4 → {h264: v4l2m2m-copy, hevc: drm-copy};
  Pi 5 → {hevc: drm-copy}. Pi 5 H.264 falls back to auto-copy
  because mpv has no v4l2-request H.264 hwdec for the Hantro G1,
  and passing v4l2m2m-copy there just logs "Could not find a valid
  device" before SW-falling-back.
- Live-verified on Pi 4: "Using hardware decoding (v4l2m2m-copy)"
  for 1080p H.264 and "Using hardware decoding (drm-copy)" for
  HEVC at 1080p and 4K.

Asset processor (src/anthias_server/processing.py)

- Pi 5 profile drops H.264 from passthrough_video_codecs — Pi 5
  has no mpv H.264 HW path, so H.264 uploads must transcode to HEVC
  at upload time to keep the HW-decode-everywhere contract.
- Pi 4 profile adds passthrough_video_max_pixels for H.264, capped
  at 1080p (1920*1080). 4K H.264 clears the codec gate but the V3D
  H.264 envelope tops at 1080p60, so the cap forces it through a
  libx265 re-encode at upload time. HEVC keeps no cap (the
  dedicated HEVC block handles 4Kp60).
- _ffprobe_summary now returns video_pixels alongside codec /
  container / audio_codec; _video_can_passthrough enforces the
  per-codec pixel cap when the profile declares one.

Tests

- test_media_player.py: new per-board hwdec tests (Pi 4 H.264 →
  v4l2m2m-copy; Pi 5 H.264 → auto-copy; both → drm-copy for HEVC;
  auto-copy fallback when ffprobe fails; no probe on x86 / arm64).
- test_processing.py: matrix tests updated to include video_pixels;
  parametrised rows now exercise Pi 5 H.264-no-passthrough and the
  Pi 4 4K H.264 cap. New end-to-end tests prove
  _run_video_normalisation transcodes Pi 5 H.264 → HEVC and Pi 4
  4K H.264 → HEVC.

Docs (docs/board-enablement.md, new)

- Goal + per-board HW-decode capability table.
- Asset processor codec policy spelled out as a contract.
- BBB test bed recipe (source clips, libx265 transcode commands,
  ANTHIAS_DEBUG_DROPS=1, mpv.log slicing).

Follow-up: Pi 5 4K HEVC HW

The Hantro G2 decoder can't allocate 4K dst buffers from Pi 5's
default 64 MB CMA ("v4l2_request_hevc_start_frame: Failed to get
dst buffer") and SW-falls-back. Adding cma=512M to the kernel
cmdline does NOT work — the kernel takes the cmdline value over
the device-tree linux,cma node, orphaning rpi-hevc-dec ("Failed
to probe hardware -517") and unpopulating /dev/video*, which
kills HEVC HW at every resolution. The right fix is a
dtparam/dtoverlay in /boot/firmware/config.txt that resizes the
existing DT-declared region without orphaning the codec's
reserved-mem reference. Until that lands, the pi5 profile should
downscale 4K → 1080p HEVC. Documented in cmdline.txt.j2 and
docs/board-enablement.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Viktor Petersson
2026-05-13 21:34:41 +00:00
parent 797a6f23cf
commit bb27b1863a
6 changed files with 669 additions and 121 deletions

View File

@@ -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',

215
docs/board-enablement.md Normal file
View File

@@ -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 <uri> ---` 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.

View File

@@ -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
# │ x86mpv + 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 3050%
# 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
# <input>`` and ``<output>`` 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

View File

@@ -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()}',

View File

@@ -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

View File

@@ -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)
# ---------------------------------------------------------------------------