Files
spoopyghosty0 44bc689348 Shared video decoder (codec revision 8): hidden VP9 frames no longer end the EOS drain early
Iris returns an empty capture buffer (bytesused 0, no LAST flag, time 0) for every VP9 frame that isn't shown
(alt-ref frames split from superframes). FFmpeg's V4L2 wrapper takes any empty capture buffer during a drain
for the end of the stream, so the first hidden frame decoded after EOS ended it and the pictures still in the
driver were lost (two-pass VP9 4K: 573/600 and 617/640, always the last frames); outside a drain the empty
buffers were returned as pictures, which the component then guessed away by counting hidden frames.
build.py now patches v4l2_context.c to requeue empty capture buffers without LAST/ERROR; the drain ends at the
LAST buffer, or after a skipped empty buffer at one silent second, so it can't block. The component's
hidden-frame counting is removed. Dev Frame (Batman's container): two-pass VP9 4K 600/600 and 640/640 in
hardware with the same Y hashes as OMX.google.vp9.decoder (seek phase too), VP9 without hidden frames,
H.264 4K and HEVC 4K 600/600, VP9 surface output 600/600. Two builds (different dirs and NDK copies) give the
same libstagefrighthw.so 141de01f.
2026-10-10 02:22:48 -04:00
..

Shared hardware video decoding for Lepton

frameport_hevc.cpp implements Android OMX decoders for HEVC Main, H.264 (Constrained) Baseline/Main/(Constrained) High and VP9 Profile 0 using Iris stateful V4L2 through FFmpeg. Android reports constrained H.264 streams under separate profile values, so they are listed as stock decoders list them. These are 8-bit 4:2:0 decoders. Format-aware clients keep Android's existing codecs for Main10/HDR and protected content; clients selecting by MIME alone must still respect the component's advertised profiles. Other codec types retain Android's existing codecs. Applications that explicitly select their own software decoder bypass this integration. Byte-buffer clients receive full-size planar frames. Native surface clients receive YUV in Android hardware buffers, with acquire/release fences. For large hardware-decoded NV12 surfaces (at least 4096×2048 pixels), compatible linear NV12 buffers are imported into Vulkan and copied on the GPU without CPU pixel access or colour conversion. The decoded AVFrame remains held until the transfer fence completes, preventing its Iris capture buffer from being requeued. Explicit FOREIGN queue-family transfers return both buffers to their Android/decoder owners before delivery. Imports are cached within a bounded pool and cleared before decoder reconfiguration or flush. Unsupported layouts, unavailable Vulkan capabilities and import failures retain the parallel CPU copy; a submitted transfer that fails to complete reports an OMX error instead of exposing incomplete pixels. This removes the CPU frame copy on compatible surfaces but still performs one GPU copy; it is not direct decoding into Android's consumer buffers. Small surfaces use disjoint parallel CPU row bands. The same persistent worker pool handles native YUV copies, byte-buffer planar copies and legacy RGBA conversion (helpers start at panorama sizes); all workers finish before the buffer is unlocked or the decoded frame is released. Legacy software surfaces retain parallel NEON RGBA conversion. Input timestamps, dynamic dimensions, EOS and seek/flush are preserved.

This uses the tested Lepton Android 11 SoftOMX ABI. On connection, FramePort deploys the plugin, codec XML and Podman wrapper once into the agent's shared ~/.local/share/frameport/video-codec/versions/<manifest-sha256> store. Payload checksums are verified before an atomic current symlink exposes the complete version. Older clients cannot downgrade a newer codec revision. A Frame keeps an installed codec of the same or a newer manifest revision (two PCs with different builds of one revision don't replace each other's), so build.py bumps the revision with every change. A failed install is not retried during the same connection. Superseded versions are pruned, keeping the active version and the one it replaced (a launch that resolved the previous current can still mount it). Games opt in through their recipe: the patch frame.hw_video_decode (no APK change; suggested when the APK decodes video through MediaCodec, ExoPlayer or VLC) makes the agent give that game's launcher the line that puts the shared wrapper first on Lepton's PATH (deployment.json hw_video_decode; upgrade_launchers adds or removes it on existing launchers). Only that game's matching podman run gains a read-only plugin/XML mount and /dev/video-dec0. All other Podman operations pass through. FramePort's setting "Hardware video decoding" turns it off for every game (video-codec/disabled, checked by the launcher line and the wrapper at every start); FRAMEPORT_NO_HW_VIDEO=1 in a game's Steam launch options turns it off for that game. Shared Lepton, drivers and original MP4 assets are unchanged. Unknown runtime ABIs retain the stock codecs and log why. A future native Lepton hardware plugin takes precedence.

The wrapper resolves a fallback Podman from PATH outside its own directory before reading deployment configuration. Lepton's Android-only env -i PATH attach commands use host default paths if their PATH contains no host Podman. Missing/malformed configuration, recursive executable paths, mount-preparation errors and failed exec calls retain the original arguments and launch stock Podman. The agent publishes deployment.json and codec payloads before exposing the complete version. The merged runtime XML is written to $XDG_RUNTIME_DIR/frameport-video/ (never into the verified version directory), named per runtime path, with process-local temporary filenames, so concurrent app launches do not overwrite each other's staging.

No package check or MP4 scan selects decoding: the recipe does. Older embedded codec assets are removed when an APK is rebuilt, and agent 70's per-game <base>/frameport-codec folders are removed once the launcher is converted (a game that had one, Batman, keeps hardware decoding). The codec has no OpenXR calls, camera poses or composition changes. Batman's surface_native renderer remains package-scoped and separate. Other apps keep their existing rendering path; hardware decoding alone does not eliminate surface upload/conversion or GPU rendering bottlenecks.

The plugin enumerates coded formats and probes each at 1920x1080, not Batman's 8K geometry. Unavailable components are omitted so Android retains its stock decoders. Actual-session admission can still fail (including Iris's concurrent session-accounting issue: while any other decoder session is open, 8K is refused with ENOMEM). SteamVR's link (vrlinkrunthread) holds a session for about 12 s around every game start, when the plugin loads too (measured on the dev Frame, 2026-10-09), so a refused session is retried every 250 ms until 20 s after the plugin loaded (later: for 2 s); initialization then falls back to FFmpeg software decoding inside the already-selected component. VP9 is limited to 4096x2304 (codec list and component; larger VP9 decodes in software): at 7680x3840 Iris accepted the session but never returned a picture. For every VP9 frame that isn't shown (alt-ref/hidden frames split from superframes) Iris returns an empty capture buffer (bytesused 0, no LAST flag, time 0). FFmpeg's wrapper returned those as pictures and, during the EOS drain, took the first one for the end of the stream, losing the pictures still in the driver (two-pass VP9 at 4K: 573 of 600 frames, measured 2026-10-10). build.py makes the wrapper requeue empty non-LAST buffers instead; the drain ends at the LAST buffer, or after a skipped empty buffer at one silent second, so a driver that ends a drain with an empty unflagged buffer cannot block. This preserves the client's buffer/surface contract without requiring the app to retry another codec.

During hardware playback, bounded references to two GOPs of compressed packets permit software recovery after a driver-reported damaged picture. Replay drops already-presented timestamps, retaining ordered output and EOS. The cache is capped at 64 MiB/512 packets; if no usable keyframe remains, ordinary OMX error handling applies. After recovery, later seeks flush the software decoder for that component until reset. This is a fallback, not a repair to the Iris driver. VP9 seek tests on this Frame exercised it. Capture buffers use FFmpeg's general-purpose budget of 20 at ordinary sizes, which avoids the stall observed with the earlier four-buffer H.264 configuration. At panorama sizes the budget follows the standards' reference-picture bound plus four (10 at 8K HEVC/H.264, 13 for VP9), instead of holding about 1 GB of 8K capture memory. Only hardware-reported damage triggers recovery; software decoders conceal damage themselves and their pictures are output, as Android's stock decoders do. A seek after the stream has fully drained (replay or loop after EOS) restarts the open hardware session in place with V4L2_DEC_CMD_START, keeping its buffers; this works for all three codecs. The component checks the restart result through a read-only FFmpeg option. A failed restart closes that session and continues in software, rather than waiting indefinitely for hardware output. A seek during playback reopens the hardware session: on the tested Iris kernel, stopping and restarting a running session's queues failed the driver's session admission ("current session not supported", -12) and left buffers unreturned (videobuf2 kernel warnings), so it is not used. On the same kernel, reopening VP9 after a seek produced a damaged capture picture and a kernel fault in lookup_swap_cgroup_id during unmap, so VP9 switches to software on a seek during playback rather than reopening that hardware session; H.264 and HEVC retain hardware decoding after seeking. This compatibility guard is independent of app/package identity.

Android CODECCONFIG parameter sets are prepended to the first H.264/HEVC hardware access unit because FFmpeg's V4L2 wrapper does not submit extradata to the driver. Parameter sets sent again mid-stream without a flush (streaming apps, restarted encoders) replace the stored configuration and are submitted in-band before the next access unit, rather than failing the session. Complete-frame parsers identify keyframes for bounded recovery, including ACodec clients that omit the OMX input SYNCFRAME flag.

On the tested headset, disabling hardware video decoding in Steam's interface and restarting Steam removed that conflicting session. This setting does not disable the game's Iris decoder. It is a SteamOS-driver workaround, not a kernel fix; FramePort does not silently change the global Steam setting.

Signalled bitstream colour (primaries, transfer, matrix, range) is reported to ACodec as Android's stock decoders do, so the surface dataspace can follow it; container values take precedence. Legacy RGBA conversion uses the resulting matrix, BT.709 for unsignalled HD (Android's convention), and full-range BT.601 or BT.709 where signalled. A full-range BT.709 table matches the pinned Arm64 libyuv ABI. Both hardware NV12 and software planar conversions produce RGBA channel order; native YUV output retains its original planes and dataspace.

Batman's native surface renderer and stereo composition are documented in Surface video playback. The original decoded panorama is retained; projected eye views are generated on the GPU.

Build on Linux/WSL with Android NDK r27c and the tested Lepton rootfs:

python native/hevc/build.py --ndk /path/to/android-ndk-r27c --lepton-root /path/to/Lepton/images/rootfs

Paths with spaces work: the builder links the repo, the NDK and the rootfs into a space-free temp dir and compiles through those link paths (FFmpeg's configure splits --extra-cflags on whitespace); with -no-canonical-prefixes clang keeps the link path too, so the prefix maps give the same bytes wherever the real folders are. Each build starts from freshly extracted FFmpeg sources.

The build requires Linux x86-64, Python 3.12+, Make, Perl, and a working host C compiler with libc development headers (FFmpeg builds host tools). The NDK revision is checked against 27.2.12479018. Previous FFmpeg objects and install output are discarded. Both FFmpeg and the plugin use that NDK's compiler and linker. A private copy of its libc++ headers uses Android's platform __1 namespace; -nostdinc++ prevents mixing these with the NDK's __ndk1 headers. The build uses libc++'s verbose-abort customization hook to avoid a newer NDK abort symbol absent from Android 11's platform runtime. It does not edit the NDK or ship a second libc++.

Compiler file-prefix maps cover source, NDK and runtime paths. FFmpeg's generated configuration string is normalized separately because it embeds literal configure arguments. The manifest records the NDK revision and source checksum. Two clean builds in different directories produced the same codec binary SHA256; .comment identifies NDK Clang/LLD 18.0.3 throughout. The rebuilt plugin also passed 600 original 8K frames, a flush/seek, 120 replayed frames and clean shutdown in an isolated headset container.

AOSP headers in platform/ come from android-11.0.0_r48 and retain their original license notices. fetch_headers.py records the upstream paths. The builder downloads the pinned FFmpeg 7.1.1 archive from https://ffmpeg.org/releases/ffmpeg-7.1.1.tar.xz and verifies SHA256 733984395e0dbbe5c046abda2dc49a5544e7e0e1e2366bba849222ae9e3a03b1. build.py applies explicit wrapper changes: VP9 V4L2 uses the same vp9_superframe_split input filter as the software decoder, empty non-LAST capture buffers (hidden VP9 frames) are requeued rather than output or taken for the end of a drain, and the V4L2 decoder gains a flush callback that restarts a drained decoder in place (V4L2_DEC_CMD_START with both queues still streaming). Its read-only frameport_flush_error option reports failed or premature restarts to the component, because avcodec_flush_buffers itself has no return value. Without it a restart after EOS could only close and reopen the hardware session. v4l2_export.c.inc adds a capture-buffer export interface inside FFmpeg, so the component does not access FFmpeg's private buffer structures. The caller must retain the AVFrame until GPU work completes. Its LGPL build configuration and those source changes are recorded in the builder. The license is included in the shared version directory as COPYING.FFmpeg.

omx_decode_probe.cpp and media_codec_probe.cpp exercise decoder selection, original-size output, timestamps, flush/seek, EOS and teardown on the headset. The OMX probe's restartfail mode uses the fault shim in tests/fixtures/src/video_codec_faults.c to reject a drained-session restart; software_rgba exercises software fallback and legacy surface conversion together. color_probe.cpp compares both platform NEON conversion paths with the full-range BT.709 equations, including channel order and extreme values. The OMX probe's native mode hashes original-size Android YUV buffers and checks GPU submissions and replayed pixels. native_cpu disables Vulkan initialization; native_importfail rejects memory imports. Both verify fallback preserves the same surface contract and pixels without GPU submissions. The native surface fixtures cover buffer ownership, GPU projection and composition. See the surface documentation for validation and limitations.