mirror of
https://github.com/Screenly/Anthias.git
synced 2026-06-10 09:08:09 -04:00
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:
@@ -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
215
docs/board-enablement.md
Normal 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.
|
||||
@@ -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
|
||||
# <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
|
||||
|
||||
@@ -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()}',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user