* fix(viewer): aspect-fit, gapless looping, and 30fps cap for pi1/2/3 video Fixes the issue #2987 regressions on the Qt5 linuxfb boards by moving playback from a bash gst-launch relaunch loop into a small in-process GStreamer helper (anthias_viewer/gst_fbdev_player.py): - Portrait/4:3 videos no longer stretch to the framebuffer: a CAPS-event pad probe reads the decoder's native dims + PAR and pins aspect-fit caps (pixel-aspect-ratio=1/1) on the capsfilter, so the bcm2835 ISP scales aspect-correct and fbdevsink centers the frame. The previous fb-sized forced caps parked the distortion in a PAR that fbdevsink ignores (reproduced on-device: 1080x1920 -> 3840x2160 par 81/256). - Clips no longer freeze/cut at loop boundaries: playbin about-to-finish re-queues the same URI for a gapless loop instead of rebuilding the whole pipeline per iteration (0.4-1.7 s per loop measured on a Pi 4, several seconds on a Pi 3, all eaten out of the fixed slot duration). Flush-seek on EOS and NULL->PLAYING restart remain as fallbacks. - 50/60 fps sources drop to an even 30 fps cadence up front (videorate drop-only) instead of juddering on irregular late-frame drops; the decode->ISP->memcpy chain sustains ~40 fps at 1080p on a Pi 3. - The framebuffer is zeroed at startup so letterbox borders are black rather than remnants of the previous asset. - The helper runs by path (not -m) so the package __init__ (Django settings, redis, D-Bus) never imports in the child; validated e2e on the armhf image: negotiation, rotation, looping, SIGTERM exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(viewer): address review feedback on the fbdev player helper - Correct the module docstring: the helper is executed by file path, not -m (the package __init__ must not import in the child) - Fail fast with a clear log line when the GStreamer python bindings are missing instead of crashing with a traceback - Clear the framebuffer in scanline-sized chunks so a 4K console doesn't peak a ~33 MB allocation on a 512 MB board Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(viewer): degrade to silent video when the audio branch fails Integrated testbed run surfaced a wholesale-failure mode the relaunch loop also had: a broken audio branch killed the video with it. Two real-world triggers: the ALSA card is absent (HDMI audio disabled in config.txt -> no vc4hdmi), and an undecodable audio codec (AC3 - a52dec lives in plugins-ugly, not shipped). Retry once with GST_PLAY_FLAG_AUDIO cleared on both the synchronous start failure (alsasink can't reach READY) and the first async pipeline error; a genuine video error recurs on the retry and still exits non-zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(viewer): read the visible fb resolution via FBIOGET_VSCREENINFO Integrated testbed run surfaced a divergence the sysfs read hides: sysfs virtual_size reports xres_virtual/yres_virtual, which can be larger than the scanned-out mode (panning / double-buffer configs — observed live: visible 1920x1080, virtual 3840x2160). fbdevsink centers/crops against varinfo.xres/yres, so scaling to the virtual size paints mostly off-screen. Query the same ioctl fbdevsink uses; keep the sysfs read (and the 1080p default) as fallbacks for hosts without fb access. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(viewer): swap the audio sink for fakesink on the video-only retry Clearing GST_PLAY_FLAG_AUDIO is not sufficient: an element set on the audio-sink property remains a playsink child and is still state-synced with the pipeline, so a failing alsasink failed the retry too (observed live on the testbed). Replace it with a fakesink when degrading to silent video. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(viewer): pre-flight the ALSA device and rebuild on audio failure The integrated testbed run showed two things the previous in-place retry missed: - a playbin whose sink activation failed does not reliably restart after NULL: the video-only retry failed instantly on the reused element with no further GStreamer error; - alsasink opens the PCM on NULL->READY, so a missing card is detectable synchronously before it can poison playbin's whole sink activation. So: pre-flight the device with a standalone alsasink and only wire the audio branch when it opens; on any pipeline error with audio enabled (e.g. an undecodable AC3 track mid-preroll), tear down and rebuild a fresh video-only playbin instead of restarting the errored one. Genuine video errors recur on the rebuilt pipeline and still exit non-zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(viewer): satisfy mypy on the gi-typed returns Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
20 KiB
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 | video output |
|---|---|---|---|
| Pi 1/2/3 | Qt5 linuxfb | none | GStreamer v4l2h264dec ! v4l2convert ! fbdevsink |
| 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.
Pi 1 / 2 / 3 video: GStreamer V4L2 → fbdev (GstFbdevMediaPlayer)
The Qt5 linuxfb boards play video by spawning the
anthias_viewer/gst_fbdev_player.py helper, which runs a GStreamer playbin
with a fully-hardware sink: v4l2h264dec (auto-plugged by decodebin at PRIMARY
rank) decodes on the bcm2835-codec (/dev/video10), v4l2convert
hardware-scales and color-converts (YUV→framebuffer format) on the bcm2835 ISP,
and fbdevsink paints frames straight to the framebuffer (/dev/fb0). The
helper loops the clip gaplessly in-process (playbin about-to-finish — issue
#2987: the original gst-launch relaunch-on-EOS loop froze the last frame for
seconds of pipeline rebuild per iteration), pins aspect-fit output caps
discovered from the decoder's CAPS event so portrait/4:3 content letterboxes
instead of stretching (fbdevsink ignores pixel-aspect-ratio and centers
smaller-than-fb frames), and caps the sink-side frame rate at 30 fps
(videorate drop-only=true — the decode→ISP→memcpy chain sustains ~40 fps at
1080p, so 50/60 fps sources drop to an even half-cadence instead of juddering
on irregular late-frame drops). This is deliberate: on a
bare linuxfb console with no compositor, a uid-1000 process cannot acquire DRM
master (the viewer's python process already holds card0), so every
DRM/KMS-master video output fails — VLC's kms vout and mpv's --vo=drm both
return EBUSY/EPERM, and the rpidistro VLC drm_vout only knows how to lease
a connector from a Wayland/X compositor (Failed to get xlease). fbdev needs
none of that.
History: these boards used to play video with VLC's Broadcom --vout=mmal_vout
(HW decode and HW scale/convert/scanout, no DRM master). The Bookworm
upgrade (#1980) dropped mmal, and nothing replaced the vout, so VLC silently
rendered to nowhere — Showing asset … (video) was logged but the screen
stayed black on every OS version. GstFbdevMediaPlayer is the modern
equivalent and drives the same VPU + ISP silicon mmal used: decode, scale,
and color-convert all stay in hardware, only the final fbdev write is a CPU
memcpy. On a Pi 3 it sustains 1080p30 → rgb565 at ~40 fps with zero dropped
frames. (An interim ffmpeg → fbdev attempt was abandoned: the bcm2835 HW
decode worked, but doing the YUV→RGB convert + scale on the ARM CPU via
swscale managed only ~6 fps to rgb565 — swscale has no NEON rgb565 path and CPU
scaling is unaccelerated. v4l2convert moves that work back onto the ISP.)
playbin is used so demux is container-agnostic, the HW decoder is auto-plugged,
and a clip with no audio track degrades gracefully. Validate with the BBB sample
pack below and confirm dropped: 0 at the source framerate.
Note: validated on a Pi 3 (the only linuxfb board in the testbed pool). Pi 1 / Pi 2 share the same VideoCore IV decode + ISP block, so the hardware path is identical; their slower ARM cores only affect demux/parse + the fb memcpy, not the offloaded decode/scale/convert.
Goal: hardware-accelerated playback on every board
Every clip Anthias displays should decode in hardware on the target board. Software decode produces drops, heats the SoC, and on low-end Pis can't keep up at 1080p30, let alone 4K.
Anthias does not re-encode video uploads on-device — see the asset processor
docstring (src/anthias_server/processing.py) for why. The viewer's per-board
mpv hwdec dispatch handles every codec a modern board can decode in hardware
(H.264, HEVC, plus VAAPI's wider set on x86). For codecs the board can't decode
(MPEG-2 DVD rips, MPEG-4 ASP DivX clips, AV1 outside x86, …), playback will
visibly stutter; the asset list surfaces metadata['video_codec'] /
metadata['video_width'] / metadata['video_height'] / metadata['video_fps']
so operators can spot a misfit clip before pushing it to the field.
The viewer (src/anthias_viewer/media_player.py) selects 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.)
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 uploading an HEVC asset for a Pi 2 / Pi 3 fleet member will play (badly) on the SoC's software fallback. If you need HEVC content on that fleet, transcode it upstream of the upload.
Pi 5 4K HEVC requires
dtoverlay=vc4-kms-v3d,cma-512. 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 hitsv4l2_request_hevc_start_frame: Failed to get dst bufferand silently SW-falls-back. Bumpingcma=512Mon the kernel cmdline does not work: the kernel takes the cmdline value over the device-treelinux,cmanode, which leavesrpi-hevc-decorphaned (Failed to probe hardware -517) and/dev/video*disappears entirely, killing HEVC HW at every resolution. The right fix is thedtoverlay=vc4-kms-v3d,cma-512line in/boot/firmware/config.txt— the vc4 overlay carries thecma-Nknob and resizes the DT-declared region without orphaning the HEVC driver. The Anthias ansible template atansible/roles/system/templates/config.txt.j2writes that line on install.
Rock Pi 4 / arm64
bin/install.sh sets DEVICE_TYPE=arm64 for every aarch64 SBC it doesn't
recognise as a Pi. The board subtype that upgrades that catch-all comes
from anthias_common.device_helper.detect_board_subtype (the single
source of the model-string table — "Radxa ROCK Pi 4" → 'rockpi4'),
consumed through two paths:
- on docker-compose installs,
anthias_host_agentruns on the host, detects the subtype, and publisheshost:board_subtypeto Redis; - when Redis has no value — the
screenly_ose/anthias-rockpi4balena fleet ships no host_agent service —anthias_common.boardreads/proc/device-tree/modeldirectly from inside the container (the device tree is kernel-global, the same mechanismget_device_typerelies on for Pi detection).
The server uses the resolved key to pick the right entry in
processing._HW_DECODE_VIDEO_CODECS — Rock Pi 4 accepts H.264 + HEVC
uploads, the catch-all arm64 accepts nothing (because we can't certify
a decoder on an unknown SBC).
The balena fleet (screenly_ose/anthias-rockpi4, device type
rockpi-4b-rk3399) deploys the generic arm64 container images — there
is no rockpi4-specific image build; only the balenaOS device type and the
fleet are board-specific. bin/balena_ota_deploy.sh owns that mapping.
The arm64 viewer image pulls ffmpeg and the libav* family from
archive.raspberrypi.com (the +rpt1 build), which adds
--enable-v4l2-request --enable-libudev --enable-vout-drm — the same package
family Pi 4 / Pi 5 use. The start_viewer.sh entrypoint creates the
/dev/video-dec* symlinks the v4l2_request decoder discovery code expects
(privileged docker mounts its own /dev tmpfs without udev's symlinks). The
+rpt1 repo is pinned to only override ffmpeg + libav* on arm64; Pi
userspace baseline is unaffected on every board.
Decode path (post-#2905)
Playback runs through QtMultimedia (QMediaPlayer rendering into a QML
VideoOutput hosted in a QQuickWidget — issue #2967 replaced the original
QGraphicsVideoItem substrate, whose per-frame toImage() GPU→CPU readback
capped presentation at 8–12 fps) embedded in AnthiasViewer. Qt 6.5 dropped
the upstream gstreamer media backend, so Debian Trixie ships only the
ffmpeg-backed libffmpegmediaplugin.so, which calls libavcodec internally.
The libmpv subprocess and the per-codec --hwdec= dispatch the prior viewer
revisions relied on are both gone — Anthias no longer hand-selects a decoder.
As a consequence: libavcodec's default decoder choice is what runs on this
board.
Known hardware-decode limitation on RK3399
On-device validation against Armbian community 6.18 (rkvdec mainline driver)
confirmed that QtMultimedia's libavcodec backend does not request a
hwaccel — ffmpeg -i sample.mp4 -f null - picks h264 (native) / hevc (native) (software). Forcing -hwaccel drm engages rkvdec via
/dev/media0,/dev/video2 (the stateless v4l2_request entry point), but the
Armbian 6.18 driver produces frame_post_process: Decode fail errors at
0.77× real-time. The H.264 path (rk3399-vpu-dec / Hantro VPU) has no
libavcodec v4l2_request binding in the +rpt1 7.1.3 build — it would have
to ship through h264_v4l2m2m, but that wrapper is stateful and the RK3399
nodes are stateless, so the M2M discovery fails with "Could not find a valid
device".
Practical state on Rock Pi 4 today:
- H.264 1080p30 SW-decode delivers ~99 % of frames on the A72 (measured: 1 dropped per 14 s); below the threshold where HW would matter.
- HEVC 1080p30 SW-decode drops ~22 % even with 5 idle cores — single-thread libavcodec stall + paging on 1 GB SKUs.
- 4K HEVC OOM-kills the viewer container on 1 GB SKUs and is rejected at upload by the low-RAM resolution gate (see "Low-RAM mode" below).
Fixing this requires either an upstream rkvdec driver fix landing in
Debian-shipped Armbian kernels, or a v4l2_request H.264 binding in libavcodec
that targets Hantro's stateless API. Both are outside the Anthias repo;
Anthias does not maintain a custom kernel or distro
(["we don't want to maintain our own Yocto distro"]). When that day comes,
the QtMultimedia side will also need a hwaccel-selection hook — Qt 6.5+ has
no public knob today.
Low-RAM mode
Boards with less than 1.5 GiB MemTotal (Pi 2/Pi 3 1GB, Pi 4 1GB, Rock Pi 4 1GB, generic-arm64 1GB SKUs) run in a degraded "low-RAM" mode:
bin/upgrade_containers.shreads/proc/meminfoand exportsANTHIAS_LOW_RAM=1to the viewer container.AnthiasViewerinstantiates oneQWebEngineViewinstead of two — no preloaded crossfade between URL assets; the page swaps in place with a brief blank during load.anthias_host_agentpublisheshost:total_mem_kbto Redis;anthias_server.processingrejects uploads above 1920×1080 with the existing recipe machinery (-vf scale=1920:1080:force_original_aspect_ratio=decrease).- The diagnostics page's Memory card surfaces a "Low-RAM mode" banner so operators can see why the device degraded.
The 1.5 GiB threshold cleanly separates 1 GB SKUs from 2 GB+ SKUs in the
supported fleet. The cap was sized against on-device measurements: idle
viewer + 2 QtWebEngine renderers + zygotes consume ~440 MB RSS on Rock Pi 4
1GB, leaving roughly 500 MB for the kernel, host services, and decode
pipeline. A 4K HEVC capture-buffer allocation pushes the container past the
cgroup limit; the kernel logs global_oom and the container restart-loops.
armv7 (Pi 2 / Pi 3) WebEngine-init crash + spawn retry
On the 32-bit (armv7) Qt5 viewer build — Pi 2 and Pi 3 — AnthiasViewer
intermittently aborts during Qt/WebEngine initialization with
malloc(): unaligned tcache chunk detected, dying before it emits the
Anthias service start D-Bus handshake. Reproduced and root-caused on a
loaned 64-bit Pi 3B+ running the armv7 anthias-viewer:*-pi3 image:
- It is genuine heap corruption in Chromium/WebEngine init, not a font
bug. The font-database enumeration is only where the corruption surfaces
(the heaviest main-thread
mallocactivity at that instant — the abort always trails the syntheticMonospace … StyleObliqueline). A build that stops before WebEngine actually initializes enumerates the same fonts cleanly every time; only letting WebEngine init proceed triggers the corruption. - It is heap-layout dependent, firing on ~75–90 % of launches — so a
fresh launch clears it ~10–25 % of the time. No userspace mitigation
fixes it: trimming the CJK fonts,
--single-process,--no-zygote, a singleQWebEngineView(ANTHIAS_LOW_RAM=1), a jemalloc preload, and disabling glibc's tcache check (which just turns the abort into a rawSIGSEGV) all still crash.
Because each spawn is a fresh process and a retry usually succeeds within a
handful of attempts, load_browser() in src/anthias_viewer/__init__.py
retries the spawn in-process with capped exponential backoff (one throttled
log line per attempt) instead of letting the exception escape main() into a
tight container restart loop (which floods journald and makes no faster
progress). The budget differs by where it runs:
- At startup (
setup()): a generous budget (BROWSER_SPAWN_MAX_ATTEMPTS/BROWSER_SPAWN_BACKOFF_CAP_SECONDS) — nothing is on screen yet, so it's worth spending time to bring the webview up. - Mid-playback (
view_image/view_webpagerespawn): a small, short budget (BROWSER_SPAWN_INLINE_*). These run on the singleasset_loopthread, so a long retry here would freeze the whole viewer — no rotations, no skips, no standby, andwatchdog()starved. A persistent failure raises instead, and the container restart re-rolls from a clean process.
A missing/unlinkable binary raises WebviewBinaryMissingError and
short-circuits the retry (it's permanent, so burning the backoff budget would
only hide a packaging regression). Operator-visible status is the throttled
logging.warning output (balena logs / journald). This is a stop-gap —
the clean fix is to run 64-bit-capable Pi 3 hardware on a 64-bit OS + the
arm64/Qt6 viewer, which sidesteps the entire 32-bit Qt5 stack.
That clean fix now ships as its own board target: pi3-64 — the arm64
Qt 6 image stream for Pi 3 hardware booted on a 64-bit OS. It folds into the
same Qt 6 / eglfs_kms display path as pi4-64 (the VideoCore IV is weaker, so
hardware decode is H.264-only — see the per-board codec set in
src/anthias_server/processing.py), and has its own balena fleet
(screenly_ose/anthias-pi3-64, device type raspberrypi3-64) and disk image.
bin/install.sh and bin/upgrade_containers.sh select it automatically when a
Pi 3 is running a 64-bit (aarch64) OS; the legacy 32-bit armhf/Qt5 pi3
stream stays for devices still on a 32-bit OS and is flagged as maintenance/
legacy in the Raspberry Pi Imager listing.
Sample pack
Run bin/generate_board_enablement_testbed.sh on a workstation
(not the device under test) to produce the 8-clip pack:
bash bin/generate_board_enablement_testbed.sh ~/bbb-testbed
The script:
- Downloads four Big Buck Bunny H.264 + AAC sources (public-domain,
from
download.blender.org/demo/movies/BBB) — skipped if already present. - Trims each to 60 seconds via
-c copy(instant, no re-encode) — produces the H.264 half of the pack. - Re-encodes each cut with
libx265 -preset medium -crf 23 -tag:v hvc1— produces the HEVC half. - Prints a verification table (codec + resolution + fps + duration
from
ffprobe).
| File | Codec | Resolution | fps |
|---|---|---|---|
bbb_1080p_30fps.mp4 |
H.264 | 1920×1080 | 30 |
bbb_1080p_60fps.mp4 |
H.264 | 1920×1080 | 60 |
bbb_4k_30fps.mp4 |
H.264 | 3840×2160 | 30 |
bbb_4k_60fps.mp4 |
H.264 | 3840×2160 | 60 |
bbb_1080p_30fps_hevc.mp4 |
HEVC | 1920×1080 | 30 |
bbb_1080p_60fps_hevc.mp4 |
HEVC | 1920×1080 | 60 |
bbb_4k_30fps_hevc.mp4 |
HEVC | 3840×2160 | 30 |
bbb_4k_60fps_hevc.mp4 |
HEVC | 3840×2160 | 60 |
60 seconds per clip is enough to capture mpv's hwdec-current banner and
read a stable Dropped: count, while keeping a full pack regen achievable
in a few minutes on a laptop. Pass CUT_SECONDS=N to the script to change
the per-clip length; pass HEVC_CRF=N to override the encoder's quality
target.
The script is idempotent: clips that already exist (and pass an ffprobe
sanity check) are skipped on re-run. A power cycle mid-encode leaves the
temp file as *.tmp.mp4; the next invocation regenerates from scratch.
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.loginside the container, which is~/.anthias/mpv.logon the host (or~/.screenly/mpv.logon 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-currentand 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 dispatch 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.
grep -E "^--- mpv launch|Dropped:" ~/.anthias/mpv.log | tail -80
For a single rolling sample on a running device:
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:Nof each asset's window; - the matching
VO:/hwdec-currentbanner 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.