Files
Anthias/docs/board-enablement.md
Viktor Petersson 9863d8c9d3 fix(viewer): aspect-fit, gapless looping, and 30fps cap for pi1/2/3 video (#3004)
* 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>
2026-06-07 16:00:33 +02:00

20 KiB
Raw Permalink Blame History

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 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 the dtoverlay=vc4-kms-v3d,cma-512 line in /boot/firmware/config.txt — the vc4 overlay carries the cma-N knob and resizes the DT-declared region without orphaning the HEVC driver. The Anthias ansible template at ansible/roles/system/templates/config.txt.j2 writes 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_agent runs on the host, detects the subtype, and publishes host:board_subtype to Redis;
  • when Redis has no value — the screenly_ose/anthias-rockpi4 balena fleet ships no host_agent service — anthias_common.board reads /proc/device-tree/model directly from inside the container (the device tree is kernel-global, the same mechanism get_device_type relies 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 812 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.sh reads /proc/meminfo and exports ANTHIAS_LOW_RAM=1 to the viewer container.
  • AnthiasViewer instantiates one QWebEngineView instead of two — no preloaded crossfade between URL assets; the page swaps in place with a brief blank during load.
  • anthias_host_agent publishes host:total_mem_kb to Redis; anthias_server.processing rejects 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 malloc activity at that instant — the abort always trails the synthetic Monospace … StyleOblique line). 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 ~7590 % of launches — so a fresh launch clears it ~1025 % of the time. No userspace mitigation fixes it: trimming the CJK fonts, --single-process, --no-zygote, a single QWebEngineView (ANTHIAS_LOW_RAM=1), a jemalloc preload, and disabling glibc's tcache check (which just turns the abort into a raw SIGSEGV) 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_webpage respawn): a small, short budget (BROWSER_SPAWN_INLINE_*). These run on the single asset_loop thread, so a long retry here would freeze the whole viewer — no rotations, no skips, no standby, and watchdog() 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:

  1. Downloads four Big Buck Bunny H.264 + AAC sources (public-domain, from download.blender.org/demo/movies/BBB) — skipped if already present.
  2. Trims each to 60 seconds via -c copy (instant, no re-encode) — produces the H.264 half of the pack.
  3. Re-encodes each cut with libx265 -preset medium -crf 23 -tag:v hvc1 — produces the HEVC half.
  4. 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.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 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: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.