WebGPU Player
WebGPU Player is a media playback engine for the browser. It plays sources a browser cannot play natively, and presents HDR and Dolby Vision through its own color pipeline.
- Demux: Mediabunny, from the fork in
vendor/mediabunny/, in a worker. - Decode: WebCodecs, or the decoder packages a host loads at runtime: WGSL video decoders that run on the GPU, and WebAssembly audio decoders.
- Present: WebGPU, with tone mapping for HDR10, HDR10+, HLG, and Dolby Vision.
- Audio: an AudioWorklet output that owns the playback clock.
Supported formats lists the containers, codecs, and HDR formats it plays, and those it does not.
The engine gives a host page two layers:
- A custom pipeline for direct-play sources the browser has qualified. Demux, decode, presentation, and audio all run in the engine.
- WebGPU presentation of frames the host’s own
<video>element decodes, for color control of SDR video.
Nothing native is offered until the running browser has proven it. A native decode route is qualified by decoding a known stream and checking the exact output. A decoder package carries its own vectors instead, which run only when the host verifies it. An HDR or Dolby Vision presentation is authorized by rendering a known input through the production shader on the current GPU device.
The engine knows nothing about Jellyfin’s player, server API, or UI; it reads Jellyfin’s media metadata shapes as its input.
Its host is the Jellyfin plugin jellyfin-plugin-webgpu-player, whose web client add-on embeds the engine as a git submodule at jellyfin-webgpu-client/vendor/webgpu-player/.
The add-on supplies the Jellyfin player, the device profile, the settings, and the HTML fallback, and the plugin documents them in its own book, in docs/ of the plugin repository.
Invariants
- A host advertises only what the selected route implements and has exact-output evidence for. A route is never gated on resolution, level, frame rate, bitrate, or a throughput benchmark.
capability/CustomContainerCodecSupport.tsis the only table of which container carries which codec. There are no decoder pair blacklists.- A failure in the custom pipeline falls back to the host’s HTML player in the same session, or asks for one renegotiation. A player is never selected recursively.
- WebGPU needs a secure context. Validate over HTTPS against a local server. A successful negotiation is not a successful playback.
Conventions
- Engine paths are relative to the engine root.
Paths inside
src/may drop the prefix;demux/,decode/,presentation/,audio/,capability/,color/,validation/, andpipeline/are its folders. - Symbols are written
file:symbolorClass.method.
Where to look
| To | Read |
|---|---|
| Build and test a checkout | Set up a checkout |
| Find a folder or a file | Repository layout, Module map |
| Follow a playback session | Architecture |
| Learn why a source plays or not | Eligibility and routes, HEVC and Dolby Vision support |
| Put the engine in a page | Embedding the engine |
| Write or load a decoder | Decoder packages, WebAssembly build |
| Change a module or a vector | WebAssembly build, Codec vectors, Recipes |
| Avoid repeating an investigation | Decisions |
Set up a checkout
You need
- Node.js 24 and npm 11.
- For the engine’s WebAssembly modules: GNU Make, Git, a POSIX shell (Git Bash on Windows), rustup, and Emscripten 4.0.13, which the build can install itself with Python 3.
- To build the decoder packages from the
wgpud/submodule instead of installing their pinned release: also CMake, Perl, and GNU patch (see WebAssembly build). - For the codec vectors: Python 3.10 or later, and the FFmpeg build named in Codec vectors.
- To convert mpv shaders: Python 3.10 or later, and naga-cli 30.0.1; see Migrate an mpv shader to a wgpup.
- For this book: mdBook 0.5.
- To run the engine: Chrome, Edge, or Firefox with WebGPU and WebCodecs, on a secure context (HTTPS or
localhost). Firefox on Windows has no WebCodecs HEVC, so HEVC plays there only through a GPU decoder package; see Decisions. - On Windows, to build the decoder packages: long path support.
The decoder projects’ FFmpeg and cargo trees pass 260 characters in a nested checkout.
Enable
LongPathsEnabledand Git’score.longpaths; GNU Make and the MSVC linker that cargo uses must accept long paths too.
Steps
-
Install the dependencies. In a standalone checkout, run
npm ciin the engine root. When a host lists the engine as an npm workspace, runnpm ciin the host root instead; the engine then has nonode_modulesof its own. Inside a host, annpm installornpm cirun in the engine root resolves to the host’s workspace and rewrites its lockfile; pass--workspaces=falseto install the engine standalone there. -
Fetch the Mediabunny fork, which the engine demuxes with:
git submodule update --init --depth 1 vendor/mediabunnyNothing installs it from npm:
tsconfig.jsonmapsmediabunnyto the folder, andnode scripts/mediabunny.mjsbuilds itsdist/with the fork’s own pinned toolchain. The typecheck, test, lint, and build scripts run that step first and skip it whiledist/webgpu-player-build.jsonnames the checked-out commit;--forcerebuilds. See Decisions for why it is a fork, and which pull requests it merges. -
Fetch the decoder projects and the pass projects, which are repositories of their own checked out at
wgpud/andwgpup/, and install their pinned releases:git submodule update --init wgpud wgpup npm run pinned-releasesThe checks need both submodules:
npm testruns the decoder projects’ tests and the pass tests that readwgpup/, and the typecheck and lint coverwgpud/*/test/.npm run pinned-releasesdownloads the decoder packages and pass files thattools/pinned-releases.jsonpins intobin/wgpud/andbin/wgpup/, checking each file’s SHA-256; it needs the network only for files it installs (see WebAssembly build). To build them from the submodules instead, runmake -C wgpud sourcesandnpm run decoders, which need the decoder toolchain, andnpm run passes; the installer keeps such local builds unless you pass--force. -
Build the engine’s WebAssembly modules, once per checkout and again after their sources change:
make -C wasm allEmscripten comes from the emsdk that
EMSDKnames, whichemsdk_envsets. WithoutEMSDK, runmake -C wasm emsdkfirst: it installs the pinned Emscripten into the engine’s own emsdk inbin/emsdk/, about 2 GB, and the Makefiles use that emsdk from then on. The build writesbin/wasm/, which is ignored. See WebAssembly build../build.shruns every build step: after checking its tools (node, npm, git, make, cargo, and the pinned Emscripten, which it checks throughwasm/Makefile), it installs the pinned releases withscripts/pinned-releases.mjs, then builds the Mediabunny fork, runsmake -C wasm all, and runsnpm run build;--productionminifies the workers. It checks every tool before it removes or builds anything, and withoutEMSDKit runsmake -C wasm emsdkitself when the engine’s emsdk is missing or of another version. It builds no decoder package; build those from the submodule as step 3 says../build_clean.sh, which runs./build.sh --clean, first removesbin/wasm/,bin/libraries/,bin/build-info.json,bin/wgpud/,bin/wgpup/,bin/installed-releases.json, the sandbox’s Vite build, the WebAssembly build tree, and the libdovi crate target, and rebuilds the Mediabunny fork too; it keeps the submodules, and the decoder projects’ build trees belong tomake -C wgpud clean. Run both in Git Bash on Windows. -
Run the checks from the engine root:
npm run typecheck npm run lint npm test npm run build python scripts/generate_all_codec_vector_assets.py --checkWith the decoder toolchain,
npm run decoders -- --checkalso checks that thewgpud/submodule at the pinned commit rebuilds the pinned release byte for byte. Inside a host workspace, run the npm scripts from the host root with-w webgpu-player, for examplenpm test -w webgpu-player.
What the checks cover
| Command | Covers | Needs |
|---|---|---|
npm run typecheck | tsc --noEmit over src/, test/, the decoder projects’ tests, and sandbox/src/ | Nothing built |
npm run lint | The engine’s ESLint config, adapted from Jellyfin Web’s | Nothing built |
npm test | The Vitest suites (jsdom), the decoder projects’ tests in wgpud/*/test/ included, which load the packages in bin/wgpud/, and the pass rules every pass file in wgpup/ keeps; the audio output stage’s tests run its build in bin/wasm/ | The wgpud and wgpup submodules, bin/wasm/, and bin/wgpud/ |
npm run build | The served assets in bin/libraries/ and bin/build-info.json. npm run build -- --production minifies the workers | bin/wasm/ |
npm run decoders -- --check | Every decoder project of the submodule rebuilds into the package in bin/wgpud/, byte for byte; after an install that is the pinned release, so it checks that the submodule reproduces the release | The decoder toolchain |
generate_all_codec_vector_assets.py --check | Every committed vector in bin/codec_vector_assets/ and every decoder project’s vectors file matches a fresh run of its generator | Nothing built |
A host may lint the engine with its own ESLint config as well; the Jellyfin add-on does. Engine changes must then pass both configurations.
Repository layout
| Path | Holds |
|---|---|
src/ | The engine’s TypeScript, by stage: demux/, decode/, then presentation/ and the audio output in audio/, beside capability/, color/, validation/, and the orchestration in pipeline/. See Module map |
src/EngineAssets.ts | The typed names of every file the engine fetches at runtime, and their URL resolution |
src/EngineConfiguration.ts | The feature flags a host can set |
src/capability/vectors/ | Hand-made vectors: embedded TypeScript vectors, streams no script generates (qualification/), and inputs that only tests and generators read (test/) |
test/ | The Vitest suites, mirroring src/, and shared helpers in test/helpers/ |
sandbox/ | The testing sandbox, a local web app that plays media from a picked folder through the engine: its Vite config, page, and sources in src/. See Sandbox |
wasm/ | The engine’s own WebAssembly modules and their build: the libdovi crate, the audio output stage, license texts, and the Makefile. See WebAssembly build |
wgpud/ | The submodule of webgpu-player-decoders: the decoder projects, each of which builds one decoder package, with its tests in test/, their shared build rules, and atmos/, the Dolby Atmos sources the eac3 and truehd projects compile in. See WebAssembly build |
wgpup/ | The submodule of webgpu-player-passes: the pass projects, whose WGSL pass files a host offers as optional passes, and the license texts of their upstream sources. See Bundled passes |
vendor/ | The FFmpeg and dcadec submodules, fetched only by make -C wgpud sources, and the Mediabunny fork in vendor/mediabunny/, which every checkout needs (see Set up a checkout) |
scripts/ | build.mjs (the served assets), library-assets.mjs (which file is served from where), pinned-releases.mjs (the pinned decoder and pass releases’ install), decoder-packages.mjs (the decoder projects’ build), pass-files.mjs (the pass projects’ copy), generate-atmos-hrir.mjs (the head-related impulse responses the Atmos binaural renderer carries), and the codec vector generators. See Codec vectors |
tools/ | Development tooling that never ships, constants.json, and pinned-releases.json, the pinned decoder and pass releases. See Tools and Pinned releases |
docs/ | This book: chapters in src/, PlantUML sources in diagrams/ and their SVGs in src/diagrams/, stylesheets, scripts, and the favicon in theme/, and the built book in book/ |
images/ | The project logo, webgpu-player-logo.svg, the Inkscape source of the favicon |
bin/ | Generated files, all ignored except bin/codec_vector_assets/. See below |
bin/ holds:
bin/wasm/: the engine modules’ builds frommake -C wasm.bin/wgpud/: the decoder packages of the pinned release fromnpm run pinned-releases, or local builds fromnpm run decoders.bin/wgpup/: the pass files of the pinned release fromnpm run pinned-releases, or local copies fromnpm run passes.bin/installed-releases.json: the filesnpm run pinned-releasesinstalled, with their SHA-256.bin/libraries/andbin/build-info.json: the served assets fromnpm run build.bin/playback_smoke_media/: local playback media from the smoke media generators.bin/plantuml/: the pinned PlantUML jar thattools/render-diagrams.mjsdownloads.bin/emsdk/: the engine’s own emsdk at the pinned Emscripten, whichmake -C wasm emsdkinstalls and the Makefiles use whenEMSDKis unset.bin/codec_vector_assets/: the generated codec vectors. This is the one committed folder, so every change to a vector shows in review.
Folder names live in one file
tools/constants.json names every engine folder.
Read a path from it instead of spelling it out, so moving a folder is a one-line change.
| Reader | Reads it through |
|---|---|
Node scripts, tools, and eslint.config.mjs | tools/constants.mjs |
Python tools and scripts/generate_all_codec_vector_assets.py | tools/constants.py |
scripts/codec_vector_assets/ | engine_layout.py, which re-exports tools/constants.py |
| Tests | test/helpers/enginePaths.ts |
vitest.config.ts | A JSON import |
sandbox/vite.config.ts and sandbox/src/main.ts | A JSON import |
build.sh, wasm/Makefile, wgpud/Makefile, and wgpud/rules.mk | node -p, which is why WebAssembly builds need Node.js |
| Hosts | The file itself, at <engine>/tools/constants.json |
Some files cannot read it. Recipes lists them.
Supported formats
This chapter lists what the custom pipeline plays, and what it does not. A source plays when its container, its video track, and its selected audio track all appear below, and the running browser and GPU pass the route’s evidence. A codec whose decoder is a package plays only when the host loaded a decoder package for it. Anything else is ineligible, and the host falls back. HEVC and Dolby Vision support holds the full HEVC and Dolby Vision matrix, and Eligibility and routes the checks, routes, and evidence behind every row.
The pipeline plays a direct-play file over http(s), or a local File the host holds.
It does not play live streams, HLS or DASH, or a server transcode.
Containers
| Container | Video | Audio |
|---|---|---|
| Matroska | H.264, HEVC, VP8, VP9, AV1, MPEG-1, MPEG-2, VC-1, WMV3, MPEG-4 Part 2, H.263, MS-MPEG4, JPEG 2000 | AAC, Opus, FLAC, MP3, Vorbis, AC-3, E-AC-3, DTS, TrueHD, MLP, WMA, PCM |
| WebM | VP8, VP9, AV1 | Opus, Vorbis |
| MP4, M4V, MOV, 3GP, 3G2 | H.264, HEVC, VP8, VP9, AV1, VC-1, MPEG-4 Part 2, H.263 | AAC, Opus, FLAC, MP3, Vorbis, AC-3, E-AC-3, DTS, TrueHD, PCM |
| MOV, MJ2 | JPEG 2000 | AAC, Opus, FLAC, MP3, Vorbis, AC-3, E-AC-3, PCM |
| MPEG-TS, M2TS, MTS | H.264, HEVC, MPEG-1, MPEG-2, VC-1 | AAC, MP3, AC-3, E-AC-3, DTS |
| AVI | H.264, HEVC, VP8, VP9, AV1, MPEG-4 Part 2, H.263, MS-MPEG4 | AAC, MP3, AC-3, FLAC, Vorbis, WMA, PCM |
| ASF (ASF, WMV, WMA files) | VC-1, WMV3, MPEG-4 Part 2, H.263, MS-MPEG4 | AAC, MP3, AC-3, FLAC, Vorbis, WMA, PCM |
| MPEG program streams (MPG, VOB) | MPEG-1, MPEG-2 | MP3, AC-3, DTS |
| FLV, Ogg, RealMedia, MXF, and every other container | Not supported | Not supported |
PCM in Matroska is 8-bit unsigned, 16, 24, or 32-bit integer in either byte order, or little-endian 32 or 64-bit float.
PCM in MP4, M4V, and MOV is 16, 24, or 32-bit integer or 32 or 64-bit float, in either byte order; MOV also carries 8-bit, mu-law, and A-law PCM.
PCM in AVI is 8-bit unsigned, little-endian 16, 24, or 32-bit integer or 32 or 64-bit float, mu-law, or A-law, under its own format tag or as the SubFormat of WAVE_FORMAT_EXTENSIBLE, which FFmpeg writes for a layout other than mono or stereo, a rate above 48 kHz, or integer samples wider than 16 bits.
FLAC and Vorbis in AVI play when their strf extradata holds the STREAMINFO block or the three Xiph-laced Vorbis headers, as FFmpeg’s muxer writes them.
WMA plays from a Matroska A_MS/ACM track and from an AVI or ASF stream, whose WAVEFORMATEX names it by its format tag: 0x0160 WMA v1, 0x0161 WMA v2, 0x0162 WMA Pro, 0x0163 WMA Lossless, and 0x000A WMA Voice, as FFmpeg’s RIFF tags do, under its own tag or as the SubFormat of WAVE_FORMAT_EXTENSIBLE.
Any other tag in these containers is refused by its tag.
An AVI audio stream whose format tag the fork names no codec for opens codec-less with its tag, in decimal, as its codec ID, as a video stream of an unknown FourCC does, and keeps its packets timed as FFmpeg’s demuxer times them.
The rows come from capability/CustomContainerCodecSupport.ts.
AVI plays every packet its indexes list, read as FFmpeg reads them: a stream’s OpenDML index when it has one (its indx super index and the ix## standard index that index locates in each RIFF list), else idx1, which covers only the first RIFF list.
An OpenDML file past about 1 GiB, whose later AVIX RIFF lists only the OpenDML indexes cover, therefore plays to its end and seeks there.
A video stream that still outruns its index, as in a truncated file or one whose OpenDML index cannot be read in full, is refused rather than stopped where the index ends.
MPEG-4 Part 2 in the packed bitstream of DivX 5 and Xvid, which DivX user data ending in p marks, is unpacked to one VOP per packet as FFmpeg’s mpeg4_unpack_bframes filter does.
AVI keeps no presentation times, only each chunk’s slot in decode order, so the fork’s AVI demuxer gives H.264 and HEVC packets their pictures’ presentation times from the picture order counts in their slice headers, as FFmpeg does, and B-frames play in order on every browser.
The worker restamps MPEG-4 Part 2’s B-VOPs into presentation order itself, as it does for Matroska Video for Windows tracks (see Eligibility and routes).
A video stream whose FourCC the fork names no codec for, such as DIV3, MP42, or H263, opens codec-less with its FourCC as its codec ID, so the MPEG-4 decoder package’s route takes it by that FourCC and any other route refuses it by its FourCC.
HEVC in AVI plays under the HEVC, H265, hvc1, and hev1 tags, and under the zero tag FFmpeg’s AVI muxer writes unless told one, which the fork recognizes by the parameter sets of the first chunk; FFmpeg, and so Jellyfin, reads such a stream as uncompressed video.
MPEG audio, AC-3, and E-AC-3 in AVI play one frame per packet however the muxer cut its chunks: the fork’s AVI demuxer reads a stream’s chunks as one byte stream and splits it into frames, as FFmpeg’s parser does, timing a byte-timed stream’s frames by their byte offsets and a chunk-timed stream’s by their chunk’s time, and drops zero-filled chunks, a leading ID3 tag, and a frame cut short.
ASF is read by the fork’s ASF demuxer, as FFmpeg’s asfdec_f.c reads it: the Header Object’s streams, and each media object reassembled from its payloads (compressed payloads and audio spread included), timestamped by its presentation time less the preroll.
Those timestamps are in decode order, as FFmpeg reads them and as its muxer writes them, so the worker restamps VC-1, WMV3, and MPEG-4 Part 2 B-frames into presentation order, as for Matroska Video for Windows tracks; H.264 and HEVC in ASF, which the fork would hand on in decode order, are refused.
A stream opens by its FourCC or format tag as an AVI stream does: WVC1 and WMVA as VC-1, and WMV3, WMA, and the MPEG-4 Part 2 and MS-MPEG4 FourCCs codec-less, with the FourCC or the tag, in decimal, as its codec ID.
A DRM-protected ASF file (a Content Encryption, Extended Content Encryption, or Advanced Content Encryption Object, or a stream flagged as encrypted) is refused, and so is a live broadcast, whose data packets vary in size; WMV1 and WMV2 have no route.
MPEG program streams, MPEG-1 system streams and MPEG-2 program streams such as DVD-Video’s VOB files, are read by the fork’s program stream demuxer: MPEG-1 and MPEG-2 video and MPEG audio by their stream IDs, and the AC-3, DTS, and LPCM substreams of private stream 1 as FFmpeg’s mpeg.c numbers them.
Its tracks come in the order of their first PES packets, as FFmpeg’s demuxer creates them, and their packets are FFmpeg’s, split as in MPEG-TS and stamped with the PTS of the PES packets they begin in.
A picture whose PES packet carries no PTS, as on DVD-Video, which often stamps only the I pictures, is timed by MPEG video’s display order.
MPEG audio Layer II (mp2) has no route, and neither does DVD-Video’s LPCM, which Jellyfin names pcm_dvd; subpictures and DVD-Video navigation packets are skipped.
A .wma file is audio only, so it falls back to the host as every audio-only file does.
An AIFF file opens, but the pipeline plays only sources with video, so AIFF, like every audio-only file, falls back to the host.
A track whose edit list Mediabunny cannot honor, as getUnsupportedFeatures() reports, is refused rather than played out of sync.
Video
| Codec | Plays | Decoder | Does not play |
|---|---|---|---|
| H.264 | Constrained Baseline, Baseline, Main, and High, at 8 bits 4:2:0; High 10, High 4:2:2, High 4:4:4 Predictive, their Intra profiles, High 4:4:4, and CAVLC 4:4:4 at 10-bit 4:2:0, and at 4:2:2 and 4:4:4 at 8 and 10 bits, from raw planes: SDR, and at 10 bits HDR10 and HLG | WebCodecs; the profiles beyond High in software | 8-bit 4:2:0 coded under a profile beyond High, 9, 12, and 14 bits, monochrome, RGB (gbrp), and the Multiview, Stereo, and Extended profiles |
| HEVC | Main and Main 10: SDR, HDR10, HDR10+, HLG, and Dolby Vision; the 9 range extensions, 4:2:0, 4:2:2, and 4:4:4 at 8, 10, and 12 bits | WebCodecs, or a GPU decoder package | 14 and 16 bits, monochrome, Screen Content Coding, High Throughput, Main Still Picture, and the named Intra profiles |
| VP8 | Profile 0 | WebCodecs | |
| VP9 | Profile 0 at 8 bits: SDR; Profile 2 at 10 bits: SDR, HDR10, HDR10+, and HLG | WebCodecs; Profile 2 in software | Profiles 1 and 3 (4:2:2 and 4:4:4) |
| AV1 | Main at 8 bits: SDR; Main at 10 bits: SDR, HDR10, HDR10+, HLG, and Dolby Vision Profile 10; High 4:4:4 at 8 and 10 bits and Professional 4:2:2 at 8 and 10 bits from raw planes, Dolby Vision Profile 10 among them | WebCodecs; 10 bits and the High and Professional profiles in software | Professional at 12 bits, which Chrome’s WebCodecs refuses, outside a decoder package |
| MPEG-1 | In Matroska, MPEG-TS, and program streams (MPG) | A GPU decoder package | |
| MPEG-2 | Every profile, at 4:2:0, 4:2:2, and 4:4:4, in Matroska, MPEG-TS, M2TS, and program streams (MPG, VOB) | A GPU decoder package | MP4, whose MPEG-2 streams Mediabunny does not read |
| VC-1 | Advanced profile (WVC1 and WMVA), in Matroska, MP4, MOV, MPEG-TS, M2TS, and ASF, in a transport stream with or without its VC-1 registration descriptor | A GPU decoder package | The Simple and Main profiles, which FFmpeg names wmv3 (the WMV3 row) |
| WMV3 | The Simple, Main, and Complex profiles, in Matroska and ASF | A GPU decoder package | AVI, MP4, and MPEG-TS |
| MPEG-4 Part 2 | Every profile FFmpeg’s decoder decodes at 8 bits, B-VOPs, quarter-pel, and global motion compensation among them, in Matroska (its own tracks and Video for Windows), MP4, MOV, 3GP, AVI, and ASF | A GPU decoder package | Studio profiles and deeper streams, and MPEG-TS, which Mediabunny does not demux it from |
| H.263 | What FFmpeg’s h263 decoder decodes, the H.263+ annexes among them, in Matroska Video for Windows tracks, MP4, MOV, 3GP, AVI, and ASF | A GPU decoder package | A stream FFmpeg names h263p or h263i |
| MS-MPEG4 | Versions 1, 2, and 3 (DivX 3), in Matroska, AVI, and ASF | A GPU decoder package | MP4 and MOV |
| JPEG 2000 | YUV at 4:2:0, 4:2:2, and 4:4:4, at 8, 9, 10, and 12 bits, and the Digital Cinema profiles’ X’Y’Z’ at 12 bits, in MOV, MJ2, and Matroska Video for Windows tracks | A GPU decoder package | RGB, grey, and palette pictures, X’Y’Z’ below 12 bits or tagged other than BT.709, 11-bit and deeper YUV samples, 4:4:0, 4:1:1, and 4:1:0 chroma, and HTJ2K |
Every other video codec is not supported, among them WMV1, WMV2, Theora, VVC, ProRes, DNxHD, and Motion JPEG.
Eligibility refuses Theora as a known codec with no route (video-codec-without-route), and the others as unsupported (codec-unsupported).
WebCodecs is the browser’s own decoder.
A GPU decoder package is WGSL a host loads, which decodes on the presenter’s device; it plays only what no native route plays, and the decoder projects in wgpud/ build one for HEVC, MPEG-1 with MPEG-2, VC-1 with WMV3, MPEG-4 Part 2 with H.263 and MS-MPEG4, and JPEG 2000.
A package’s frames take the format that holds the stream’s pixel format, so 4:2:2 and 4:4:4 streams keep their chroma.
The JPEG 2000 package converts X’Y’Z’ (xyz12le) to R’G’B’ as FFmpeg’s converter does, gamma 2.6, the XYZ to BT.709 matrix, then gamma 1/2.2, and writes it as limited-range BT.709 Y’CbCr at 12-bit 4:4:4, which presents as an untagged SDR stream’s BT.709.
| Property | Plays | Does not play |
|---|---|---|
| Scan | Progressive; interlaced H.264, MPEG-2, MPEG-4 Part 2, VC-1, and WMV3, deinterlaced at double or single rate (see Architecture) | Interlaced HEVC, coded as field pictures, interlaced JPEG 2000, coded as a codestream per field, and interlaced video on a Dolby Vision route |
| Rotation | 0 | Any other rotation |
| Frame size, frame rate, level, and bitrate | Whatever the decoder accepts; none of them is a gate | |
| Color | BT.709, BT.601, and BT.2020 primaries and matrices, raw planes included, which convert SDR outside BT.709 to BT.709; an unknown or unspecified tag takes its transfer’s default | Any other tag value |
HDR
| Format | Plays | Does not play |
|---|---|---|
| HDR10 | HEVC Main 10 and the 10 and 12-bit range extensions, VP9 Profile 2, and AV1 Main at 10 bits | HDR10 at 8 bits |
| HDR10+ | As HDR10, with per-frame metadata from HEVC SEI, AV1 metadata OBUs, or a VP9 track’s Matroska or WebM BlockAdditionals | Per-frame metadata for VP9 in MP4, which carries none and plays as HDR10 |
| HLG | As HDR10 | HLG at 8 bits |
| Dolby Vision | Profiles 4, 5, 7 (MEL and FEL), 8, and 20 over HEVC, and Profile 10 over AV1 in any raw format the browser qualifies | RPU reconstruction for Profile 9 (AVC), the retired Profiles 0 to 3 and 6, and Profile 10 at 12 bits in Chrome, which play only a declared base layer |
Audio
| Codec | Plays | Decoder | Does not play |
|---|---|---|---|
| AAC | Mono, stereo, 3.0, 5.1, and 7.1, each surround layout once the browser’s decoder passes its probe; FFmpeg’s channelConfiguration 7 decodes as 7.1 with back and side pairs | WebCodecs | 6.1, and more than 8 channels |
| Opus, FLAC, Vorbis | Mono, stereo, 3.0, 5.1, and 7.1, each surround layout once the browser’s decoder passes its probe; FLAC also 2.1, by the mask its metadata names | WebCodecs | 6.1, and more than 8 channels |
| MP3 | Mono and stereo | WebCodecs | |
| AC-3 | Mono, stereo, 4.1, 5.0, and 5.1; a 5.0 track may gain its LFE mid-stream | The browser’s own decoder through MSE when it has one (stereo and 5.1), or an audio decoder package | Three channels; 2/2 with an LFE, whose speaker mask has no layout |
| E-AC-3 | Mono, stereo, 5.1, and 7.1; Dolby Atmos with joint object coding renders its objects into a layout with a speaker its core lacks, heights included, or into binaural stereo, through the eac3 package, and otherwise plays its core | The browser’s own decoder through MSE when it has one, or an audio decoder package | Atmos objects in a stream whose OAMD the parser refuses, which plays its core |
| DTS | Mono, stereo, three channels, 4.1, 5.0, 5.1, 6.1, and 7.1, in every profile the package decodes, DTS-ES included; DTS:X plays its channel bed | An audio decoder package | Four channels (4.0); DTS Express in MP4, which Mediabunny maps but no package decodes; and DTS-UHD in MP4, whose sample entry nothing reads |
| TrueHD | Mono, stereo, and 5.1; 7.1 at 48 kHz; Dolby Atmos renders its 16-channel presentation, bed and objects, into a height layout or binaural stereo, and otherwise plays its 7.1 or 5.1 presentation | An audio decoder package | Atmos objects in the intermediate spatial format, which the renderer refuses; binaural rendering of Atmos at other rates than 48 kHz |
| MLP | Mono and stereo, in Matroska | An audio decoder package | |
| WMA | WMA v1 and v2 in mono and stereo; WMA Pro and WMA Lossless in mono, stereo, three channels, 5.1, and 7.1; WMA Voice in mono; in Matroska, AVI, and ASF (WMV and WMA files) | An audio decoder package | WMA Pro and WMA Lossless in 6.1 |
| PCM | Integer, float, mu-law, and A-law PCM, in mono, stereo, 3.0, or 5.1 | Mediabunny |
Every other audio codec is not supported, among them MP2, ALAC, AMR, and Speex.
A source whose selected audio track is not supported does not play through the pipeline.
Every route resamples to 48 kHz.
The host’s speaker layout setting picks the output layout: auto takes 7.1.4 on a device with 12 channels, 7.1.2 with 10, 7.1 with 8, 5.1 with 6, and stereo below; 5.1.2 and 5.1.4 play only when chosen.
Dolby Atmos a package renders as objects plays to the whole layout, or to binaural stereo for headphones when the host asks for it on a stereo output.
Every other source plays within the layout’s floor: a three-channel or 5.1 source outputs 5.1, a 7.1 source 7.1, or 5.1 on a 5.1 floor, and everything else downmixes to stereo.
Subtitles
The engine reads no subtitle track and renders no subtitles, and a source’s subtitle tracks never affect its eligibility.
The host draws subtitles over the video: style.css keeps the host’s subtitle layers (.videoSubtitles, libass canvases, and other canvases) above the WebGPU canvas.
Architecture
Layers
A session takes one of two paths:
- (a) HTML decode with WebGPU presentation, for known-SDR input only.
The
<video>element or hls.js decodes, and the media element is the clock. Frames go rVFC,importExternalTexture, the identity route’s one-pass plan, canvas. For HDR or unknown input the presenter is off and the<video>shows. - (b) The custom pipeline, for eligible direct-play VOD.
The worker posts
'frame'messages (aVideoFrame, or raw planes in a pooled buffer, with Dolby Vision and HDR10+ metadata), and fMP4 segments for native-media audio. On the frame-pool route it decodes no video and posts compressed'packet'messages instead, which a GPU decoder package’s WGSL decodes on the presenter’s device into a frame pool that the presenter reads in place. For a package with a parser, such as the HEVC decoder project’s, each'packet'carries one picture’s record instead, which the package’s parser writes on the worker’s coordinator worker and its parse workers; a packet that holds several pictures, such as an AV1 temporal unit with hidden frames, posts one'packet'for each. Decoded PCM goes from the worker’s audio decode worker straight to the AudioWorklet, over a channel the page hands over, and the page learns only each chunk’s place and length (audio-progress). The frame loop,CustomPlaybackFrameLoop, takes the controller’s current frame each animation frame and presents it. Decoded audio plays through an AudioWorklet in a pooled 48 kHz context, and AC-3 and E-AC-3 can play through a hidden<audio>element and MSE.
The host player and its HTML backend with any hls.js runtime are host code.
A host drives the custom pipeline through PresentationSetup, which gates the eligibility’s presentation routes and configures the presenter for the chosen one, and runs one CustomPlaybackFrameLoop per playback.
Everything else in the diagram is engine code.
The plugin’s book follows the Jellyfin host’s half of a session.
The engine’s sources follow the stages a custom session runs: src/demux/ reads the container and shapes each track’s packets and the metadata they carry, and src/decode/ turns the packets into frames and PCM.
src/presentation/ and src/audio/ present the frames and play the PCM, and src/pipeline/ runs the stages for each session (see Module map).
Clock
- Decoded PCM: the worklet’s render time, corrected to physical output time by
BrowserCustomAudioOutputthroughgetOutputTimestamp, re-anchors aperformance.nowMediaClock. After each flush the worklet renders silence from the flush position up to the first chunk’s timestamp, so an audio track that starts later than the video still clocks from the start position. A first chunk more than 30 s on is refused with the release and telemetry reasonleading-gap, which the producer and the page’s bridge both report as the leading gap failure, failing the attempt asaudio-output-failed, since a gap that long is a timeline fault that silence would hide. When the output starts, the clock holds its start for the output’s estimated latency, so video moves when the first sample is heard. - Native-media audio:
<audio>.currentTime. A first fragment more than 40 ms after the start position parks the element at the fragment, andplay()waits out the gap while theMediaClockruns alone. - No audio:
MediaClockalone, held while video starves. An audio track that ends before the video hands the clock to theMediaClockonce its tail has played out.
The clock runs at the requested playback rate, from 1/16x to 16x (pipeline/CustomPlaybackRate.ts), 1 by default.
From 0.25x to 4x audio plays at that rate and stays the master: the worklet time-stretches the decoded PCM with its pitch kept (AudioWorkletTimeStretchSource.ts), and native-media audio plays at the rate on its <audio> element.
Outside that range a generation plays without audio, and the MediaClock alone runs it, as for a source without audio.
The worklet’s ring holds media-rate PCM, and the stretcher reads it at the rate, so its telemetry advances media time by the rate per output frame and reports the rate each frame rendered at; BrowserCustomAudioOutput scales the physical output correction, which is context time, by that rate into media time.
At 1x with nothing left in the stretcher the worklet copies the ring sample for sample.
A change to or from 1x continues the input, or crossfades one hop into it, so media time moves at most the 15 ms search half-width.
When the ring cannot feed the stretcher, the frames it holds play out, the rest of its input at 1x, before the quantum counts as an underflow, so a track’s tail is never held back.
Video is pulled: each rAF draws the newest frame at or before the clock, and hands the presenter its timestamp and the clock’s rate, from which it measures the display’s refresh rate. Queued frames hold off video starvation, except before a generation or a resync takes its first frame: then frames whose earliest is more than the 2 s decode lag bound past the clock, while they hold every video credit of the route, count as starvation, so the stall bound falls back instead of waiting for the clock to reach them. After the first frame, frames that far ahead are a real gap in the video, such as a still or a slideshow, which the last frame holds as before. While a plan deinterlaces, each rAF takes frames one frame ahead of the clock, the presenter shows the frame before the newest, and a tick that takes no frame draws a held frame’s next field from history (see Presentation). While a plan holds a display-rate pass, each rAF takes frames one refresh ahead of the clock, and a tick that takes no frame redraws from history at its clock time.
Startup of a custom session
- The host runs
prewarmBrowserAudioContext(48000)synchronously inside its play call (the user-activation window) and callspresenter.startSession. - The host runs eligibility, described in Eligibility and routes, once the authorization keys the source needs have settled. Its own setup bound should end where the controller starts, so the controller’s startup bound applies after that.
- The host gives the presenter a source-less
<video>. The presenter enters push mode, and the host installs the shaders and authorizes the selected route through the presenter. controller.playcreates a generation, resets the clock, emitswaiting, and starts the startup bound. It samples the source bytes the worker reads before its readiness, whichsource-progressreports each 256 KiB, and the decode counters every second, and fails after 20 s without progress, or at 60 s regardless. The fallback message names the counters it reached, so a timeout shows where startup stalled.CustomDecodeSession.startposts the generation’sstartto the session’s decode worker, the prebuiltlibraries/webgpu-player/CustomDecode.worker.jskeyed per build with?v=, which the session’s first start creates. The worker’s first start with decoded PCM audio spawns its audio decode worker, the prebuiltCustomAudioDecode.worker.jsbeside it, which it keeps for its life. Its first start for a package with a parser spawns its coordinator worker, the prebuiltGPUDecoderCoordinator.worker.js, which it keeps for its life too; that worker spawns the parse workers, the prebuiltGPUDecoderParse.worker.js, and keeps them while the package plays. Video gets 4 frame credits, 2 for raw planes, or 8 packet credits on the frame-pool route.- The worker prepares its tracks (
canDecode), scans the static HDR metadata of the first 16 access units or 8 MiB, and postsready. The frame-pool route skipscanDecode, requires the codec its GPU decoder takes, and adds the track’s decoder configuration record, such as an hvcC, ascodecPrivate. With a parser it opens the attempt’s parser beforeready, which then carries the largest picture the parser declared in the codec private data. The session then creates the GPU decode session, or reuses the previous generation’s when its decoder, format, coded size, bit depth, codec private data, and declared maximum match. It scans HEVC SEI on the native PQ route, and AV1 MDCV and CLL metadata OBUs when the first sequence header signals PQ and no RPU route is selected. Video and audio then stream concurrently. - The session is ready when the first frame is queued and at least 100 ms of PCM has been submitted, or, on the native-media audio route, when the first segment is appended.
The controller also waits for the presentation prepared for the track’s chroma format and size, which the session’s
configuredevent carries (see Presentation). completeStartupIfReadystarts audio, resumes the clock, and emitsreadyandplaying. The host then starts the frame loop.
Steady state
| Thread | Work |
|---|---|
| main | rAF loop, controller, session, presenter, audio bridges, sink manager, MSE <audio> |
| worker, one per session | demux, video decode, raw copy, Dolby Vision and HDR10+ metadata, Mediabunny’s audio decoding (WebCodecs and PCM), and fMP4 remux |
| audio decode worker, one per worker | audio decoder packages, the downmix in JavaScript, the resampler and limiter in WebAssembly, and the producer that feeds the worklet |
| coordinator worker, one per worker | a video decoder package’s parser: each picture’s plan and finish in decode order, such as the HEVC parser’s motion derivation |
| parse workers, up to six per coordinator worker, two cores fewer than the device has | the units of a parser’s pictures, such as HEVC’s CABAC slice data, in parallel |
| AudioWorklet | 1024-frame chunks in a 2 s ring, gain, play gate, periodic telemetry |
| GPU | the presenter’s queue, which the authorization probes and the frame-pool route’s decoder share |
Video credits:
takeFramecloses and re-credits older frames.- A presented
VideoFramecloses aftersubmit(), but its credit returns only afterqueue.onSubmittedWorkDone(). This keeps the decoder’s surfaces from starving. - Every wait on queue work, this one, the frame-pool route’s status readbacks, and the authorization probes’, goes through
waitForGPUCompletion(GPUCompletion.ts), which polls the device with an empty submit every 2 ms while the wait is pending. Firefox completesonSubmittedWorkDone()andmapAsync()only when its GPU process polls the device, at a latersubmitor every 100 ms, so without the polls a credit or a readback waited up to 100 ms there (see Decisions). - In raw mode the posted buffer is the credit, and it returns through
recycle-frame. - On the frame-pool route a credit is a packet queued on the page, and it returns once the packet’s decode work is submitted.
With slicing on, which the host chooses through
createGPUVideoDecodeSessionFactoryand leaves off by default, a packet’s decode work submits in slices of at most 96 dispatches, of about equal length, so a large picture’s wavefront splits into several submits; off, every packet submits whole.GPUVideoDecodeFeedkeeps up to two slices in flight, a whole packet counting as one, and submits the next once one completes or a status readback applies, so the GPU decodes on while the page learns of a completion, and presentation queues behind at most two slices of decode work. A reset, as a seek or a new video epoch makes, or a resync submits no more of the packet in progress. With a parser, a credit is one picture: the worker reads packets until their pictures run 8 ahead of the credits, a packet still parsing counting as one, so its parse workers stay busy, and a packet without a picture, such as a skipped leading picture, spends none. A presented frame returns its pool slot right after its submit, because the queue orders later decode work behind it. - The owned HEVC, AV1, and VP9 paths read a packet only while they hold a credit, then wait up to 10 ms for the decoder to return a frame before reading the next. The credit is spent only when a frame is posted, so without that wait a hardware decoder, which answers a few milliseconds after taking a packet, received a whole group of pictures at once (see Decisions). Steady playback therefore decodes one packet per presented frame, while seek preroll still runs at decoder speed, because each returned frame ends the wait.
- A hardware decoder returns a frame once its decode is queued on the GPU, not once the GPU ran it, so a preroll that reads at decoder speed queues decode work faster than the GPU finishes it.
On the VideoFrame route a preroll therefore decodes in batches of 4 frames (
OWNED_VIDEO_PREROLL_BATCH_FRAME_COUNT): the batch’s last frame goes to the worker’s decode fence (VideoDecodeFence.ts), and the packet after the next batch waits until the fence completes, so the decoder runs at most two batches ahead of the GPU and never idles between batches. The fence is a small WebGPU device of the decode worker’s own that reads one texel of the frame and waits for that work, which waits for the frame’s decode; the attempt whose first packet precedes its start creates it, and until it is ready, or where the worker has no WebGPU, frames pass unfenced. The frame that ends the preroll posts once its own fence completes, so its presentation never waits behind the preroll’s decode work, and each wait ends after 250 ms at most. The trace records each wait asvideo-decode-fence.
Video presentation interval, above 1x:
- The display shows at most one frame per refresh, so above 1x the controller hands the session a presentation interval: the rate times the measured refresh interval (
WebGPUPresenter.getDisplayRefreshIntervalMicroseconds, which the frame loop passes on when it changes), 60 Hz until measured, rounded to a whole microsecond. At or below 1x there is none; the session keeps the interval for later generations and sendsset-video-presentation-intervalto the running one. - After an attempt’s first presented frame, a frame within which no multiple of the interval falls (
VideoPresentationInterval.ts, in container time) is never posted. The owned paths close it unposted without spending a credit and recordvideo-frame-decimated; decimated frames count toward the preroll’s fenced batches of 4, so the decoder stays at most two batches ahead of the GPU. The sample cursor route (H.264, VP8) closes it and keeps the credit it took, and raw planes never copy it. Nothing is decimated while a Dolby Vision enhancement layer decodes, and the frame-pool route never decimates in the worker, because a package’s parser must see every packet; there the page’stakeFramedrops the frames it never presents. - The owned HEVC path does not decode a picture no later picture references (
HEVCTemporalSubLayers.ts: every base-layer VCL NAL unit a sub-layer non-reference type at the highest temporal sub-layer every SPS declares) when it can never present: it ends at or before the attempt’s start, or it follows the first presented frame off the interval’s grid. Its HDR10+ and RPU metadata is still read in decode order, the trace recordsvideo-packet-skipped, and a seek’s preroll skips such pictures too. An SPS that does not parse leaves the highest sub-layer unknown for the attempt, and nothing is skipped; nothing is skipped while an enhancement layer decodes either.
Raw planes:
- A decoded
VideoFrameis copied once, into a buffer of 256-byte-aligned rows, which theframemessage transfers to the page. - A run keeps spare buffers: those the page recycled and those of frames closed unposted. A drained frame or a copy takes a spare of its byte length, or a new buffer when none fits, and at most 2 spares of each byte length are kept, as many as the page can hold; a 4K 10-bit frame is about 25 MB.
Audio credits:
- Decoded PCM: the audio decode worker’s
WorkletPCMProducerowns the worklet’s credit window. Chunks are 40 ms to 12000 frames, with 8 credits, so the credits can never hold more than the 2 s worklet ring. One credit returns per chunk the worklet consumes, with the chunk’s buffers, which later chunks reuse. The audio decode worker reconciles decoder timestamps within 2 s before the resampler (see Decisions), and fails the attempt asdecode-failedbeyond that. A gap, an overlap, or an overflow that reaches the producer is therefore an engine fault, and raisesaudio-output-failed. - The worker sends each decoded PCM attempt’s input in batches that close at 40 ms of media time or 64 inputs: compressed packets for a decoder package, or Mediabunny’s decoded samples cut to the start. 4 batches are in flight at once, and each returns its credit once the audio decode worker has rendered it, so demux runs at most a few batches ahead of the worklet’s window.
- Native-media audio uses 2 MiB or 2 s segments, 2 credits, and appends at most 6 s ahead.
Lag guard, in CustomPlaybackController:
- A frame more than 2 s behind the clock is discarded and re-credited, and a video wait starts.
- An empty queue for at least 100 ms while playing also starts a wait.
- A wait of 10 s or more fails with
playback-stalled. - Above 1x the decoder may never catch up with the clock, so video jumps ahead instead: a taken frame more than 500 ms of wall time behind, times the rate in media time and at most the 2 s bound, is discarded (
decode-lag), and video restarts at the next keyframe at or after 250 ms of wall time ahead of the clock (session.resyncVideo(target, 'next')), recorded asvideo-resync. A missing frame jumps the same way when the decoder is that far behind and no jump is pending. The keyframe becomes the attempt’s start and presents first, even when it arrives later than the lag allows, so every jump shows a picture and ends the wait; a frame after it that still lags jumps again. A stream without a keyframe there ends its video. While a jump is pending the stranded-queue check and the catch-up discard are off, since its queue starts ahead of the clock by design. At or below 1x, and when the session declines the jump, the waits above apply.
Hidden page, through setPageVisibility and drainBackgroundVideo:
- No rAF runs while the page is hidden, so the host takes and discards due frames against the clock on a timer (25 ms in the Jellyfin host). Credits keep flowing and audio stays the master.
- After 10 s hidden, audio-clocked
nativedecode sendssuspend-video. The worker unwinds only its video attempt and releases the decoder. Once the audio track has ended, only video can reach the end of the stream, so video is no longer suspended andaudio-endedresyncs a suspended decoder at once. - On return, a suspended video, or one more than 2 s behind, gets
resync-videoat the clock. The worker restarts video alone at the preceding keyframe and skips frames before the target. Waits restart, so hidden time never counts towardplayback-stalled. - Every resync or suspension advances the video epoch.
Frames carry
videoEpoch, and the session closes frames of a replaced epoch and returns their credits. - A reclaimed WebCodecs decoder (
QuotaExceededError) postsvideo-interrupted. Video resyncs at once when visible, and on return when hidden. - When audio outlasts the video track, the worker posts
video-endedbefore the run’sended, and the controller holds the last frame without waiting, stalling, suspending, or resyncing.
Geometry: WebGPUPresenter.bindLayoutHandling invalidates layout on resize observers, class and style mutations of the video and its ancestors, CSS motion events, window resize, seek, and refresh.
The backing size is the CSS size times the device pixel ratio, capped by maxTextureDimension2D.
Presentation
- Every route presents through the pipeline in
src/presentation/passes/. - A route’s built-in chain is its fixed signal, which reads the source and reconstructs Dolby Vision, and its default color functions: transfer decode, tone mapping, output encode, display controls, and dither.
planPresentationarranges the chain and any user passes into GPU passes. Without user passes every route is one pass, which renders exactly as the legacy fused shaders did.PresentationExecutorowns a plan’s pipelines, textures, history rings, and bind groups. A frame costs one uniform write, the source’s import or upload, and one encode per pass, and the presenter submits once.- A new plan compiles while the previous executor keeps presenting, and replaces it when ready.
- The device keeps the most recently used 64 compiled passes (
PresentationPipelineCache.ts), keyed by their WGSL, bind group layout, and target format, so an executor built again for passes it compiled before, in a replan or in a later session on the kept device, compiles nothing. - A starting generation builds the plan its track declares before the first frame is due: the session’s
configuredevent carries the chroma format and coded size of the worker’s ready response, the controller hands them to the preparer the frame loop registered (setVideoPresentationPreparer), the presenter plans with them as with a frame’s facts (prepareDecodedVideo), and the generation reports ready only once that executor is installed. The first frame of those facts then presents without a replan, and a frame that differs replans as before. - User passes come from
WebGPUPresenter.setPresentationPreset. A pass that cannot run is left out of the plan, andgetPresentationPlanReportnames it with a reason code and a terse detail. - The spine starts with the plane stages,
LUMA,CHROMA, andNATIVE, which hold the signal’s planes as normalized codes (see Planes). Each signal offers its planes (SignalDefinition.planes): its own reads of the luma and chroma codes, chroma’s siting, and its conversion to ‘source’ RGB. A plan reads a plane apart into a texture only for a pass that reads it, and when a pass replaced a plane, a merge pass converts the planes into the spine’s first image at luma’s size, reading each plane no pass replaced from the source, so it converts exactly as the signal does. The raw and frame-pool routes hold the decoded planes, and the Dolby Vision routes offer none. The external routes derive them from the browser’s RGB through the stream’s own matrix and range (SignalPlanes.transformCode), the external HDR route from the codes its signal recovers, with chroma at the stream’s subsampling. The frame-pool route reads a pool slot in place, whose planes are padded to 64 rows and columns, and clamps every tap to the frame’s visible picture, which the executor writes into the slot’s layout uniform as it binds the frame, so a picture’s edge repeats as on the raw route. - Every signal reads 4:2:0 chroma at the location the stream declares, and 4:2:2 chroma on its left luma column; the external routes derive chroma at those sites.
The decode worker reads the subsampling and the location from the track’s decoder configuration (
VideoChromaFormat), from its first key packet’s in-band SPS where an H.264 or HEVC track’s configuration holds none, and MPEG-1 as center-sited 4:2:0; its ready response names them, and every frame of every output mode carries them, so the presenter plans with them without a host fact. A deinterlacing plan derives them from its deinterlaced image through the same transform (SignalPlanes.imageCode), so plane passes run on interlaced sources without reading the source again. - The planes hold the frame’s visible samples (
PlanFacts.sampleWidthandsampleHeight, theluma.widthandluma.heightfacts), and chroma’s extent is luma’s over the subsampling divisor, as libplacebo sizes plane rects; the presenter plans again when the frame’s sample size changes while preset passes plan, and when the stream’s chroma changes. - Every image the plan holds has a plan-time offset, as libplacebo’s texture rects place mpv’s textures: chroma starts at its siting, a pass’s output takes the hooked image’s offset scaled to its size plus its declared
offset, andoffset: alignreturns it to the reference grid. No read is shifted; the merge resamples chroma onto luma’s grid through both offsets, and the scaler corrects its image’s offset within its resample. - A pass file’s texture blocks reach the planner inside the manifests of the passes that bind them, and the plan names each by its content identity (
PresentationPlan.passTextures). The presenter’sPassTextureCacheuploads a block when a plan first binds it, hands the uploads toPresentationExecutor.create, and after installing an executor releases the uploads neither its plan nor the preset holds. - A pass’s
whenreads its parameters, and aconstparameter compiles into the plan’s modules, so a change to either builds a new plan (PlanRequest.parameterValues), which the executor of an unchanged plan adopts without rebuilding; any other parameter is a uniform write. - The prelude gives a body that calls them the source’s transfer function in mpv’s scale of linear light (
BuiltinChain.linearLight, from the built-in transfer decode’s code) and its planes transform. - Temporal history advances only on new frames; redraws from history, of a deinterlacing or display-rate plan, do not advance it. It resets on a seek, a new generation, a video epoch change, and a frame whose timestamp goes back or jumps past 2.5 frame durations.
- The
frame.ratefact is the stream’s declared frame rate (PresentationInput.getPresentationFrameRate): its average frame rate, else its real frame rate when the average is missing or at least 1000, whichconfigurePresentationColorPipelinecarries asPresentationColorPipelineConfiguration.frameRate. - Authorization renders the default chain through the same planner and executor, so a preset never changes the route.
- With HDR output enabled by the host, an HDR source presents extended-range values on an
rgba16floatcanvas.
Deinterlacing
- An interlaced stream presents through
Presentation deinterlace, the engine’s yadif pass (passes/builtin/DeinterlacePass.ts; see Decisions). - The presenter asks for it (
PlanRequest.deinterlace) once the stream’s facts or any frame call the video interlaced, while the mode is notoff. A Dolby Vision route never asks, since its profiles are progressive. - The planner places it before every stage: the signal renders into
signal, a ring of three frames at the frame’s visible size inrgba16float, and the pass reads the frame it shows, the one before it, and the one after it, and writesdeinterlaceat the same size, which the rest of the spine takes. - It deinterlaces the signal’s ‘source’ RGB per channel in f32, so one pass serves every route but Dolby Vision’s, 8 to 12 bits and every chroma format.
- Plane passes take their planes from
deinterlace, through the signal’s transform, as mpv’s user shaders see the planes a deinterlacing filter wrote. - A progressive frame passes through untouched, as does a frame beside a progressive one that repeats a field, as yadif’s
deint=interlaceddoes. - The
presentationuniform’sfieldStateword (PRESENTATION_FIELD_STATE) names what shows: whether the pass interpolates, the field shown, whether it is its frame’s second, whether the shown frame is the one before the newest, and whether the frame the signal reads now is interlaced. - On the raw and frame-pool routes a deinterlacing plan’s signal reads an interlaced frame’s 4:2:0 chroma within each field, at MPEG-2’s interlaced siting, so a field never takes the other field’s colors.
A native
VideoFramearrives as an external texture whose chroma the browser upsampled progressively, so its fields share chroma rows. - Each frame carries its own fields (
VideoFrameFields): a GPU decoder’sreportPictureFieldsreport, else its parser’sparser_picture_fieldsword, else the container’s scan, Matroska’s FlagInterlaced and FieldOrder or an MP4 sample entry’sfielbox. A frame without fields takes the stream’s scan from the host’s facts,FieldOrderand thenIsInterlaced; a frame of unknown scan presents as it is. DeinterlaceTimelinemirrors the ring and picks the draw for a clock time: a frame’s first field at its own time, and at double rate, the default, its second field halfway to the next frame’s time, rounded down to a whole microsecond. Single rate shows each frame’s first field only, andoffpresents frames woven, as decoded.- Deinterlacing reads the frame after the one it shows, so the frame loop takes frames one frame ahead of the clock (
getFrameLookaheadMicroseconds), and the presenter shows the frame before the newest. The clock and audio never move: decoding runs one frame further ahead. After a reset the first frame shows at once without a frame after it, and at the end of a stream, or while decoding falls behind, the newest frame shows once the clock reaches it, as FFmpeg’s yadif repeats its last frame. - A tick that takes no frame draws the held frame’s next field from history (
presentHeldFields), without a source, so passes that read the source are skipped. setDeinterlaceModeswitches between double rate, single rate, and off; only a switch to or fromoffbuilds a new plan.
Display rate
- The presenter measures the display’s refresh rate (
DisplayRefresh) from the animation frame times the frame loop handsrecordAnimationFrame: a least-squares line through the times over refresh counts, which counts skipped refreshes and averages timer coarsening up to 1 ms. It reports an interval once at least 30 frames fit the line with a relative standard error of at most 0.02%, over a window of 240 refreshes. A frame more than 0.4 intervals off the line, as after a move to another display, starts the measurement over, and a gap over 500 ms, as a paused loop leaves, starts a new window; the last measurement holds until the next is precise. - The
display.ratefact is that rate, 0 until measured, and the presenter plans again when it moves more than 0.1% (DISPLAY_RATE_REPLAN_TOLERANCE) from the rate the plan read, while a preset pass’swhenreads it. - A pass with
rate: displayplans only while frames come from the frame loop (PlanRequest.displayRateAvailable), and not in a deinterlacing plan; otherwise it is left out asdisplay-rate-unavailable. - In a display-rate plan (
PresentationPlan.displayRate) the pass that reads the source writes a texture rather than the canvas, and a display-rate color step gets a pass of its own,Presentation color. - The frame loop takes frames one refresh ahead of the clock (
getFrameLookaheadMicroseconds), so the frame that starts within a refresh is in history when the refresh draws. - A tick that takes no frame redraws from history at its clock time (
presentHeldFields), unless the last draw was at that time, fromPresentationPlan.historyDrawStart, the first pass with a display-rate step: the passes that read the source and the passes before it are skipped, since no draw of the frame changes their outputs. After a new executor is installed, nothing redraws until a frame’s submission validated it. - The
presentationuniform’srefreshSecondsis the media time one refresh covers, the measured interval times the rate the clock advances at, which the frame loop hands over as 0 unless playing, andtargetOffsetSecondsthe clock time the draw presents at minustimestampSeconds, subtracted in double precision. The lookahead follows it, so a paused or seeking playback takes no frame ahead of the clock. - The uniform’s
frameDurationSecondsis the media time between frames (FrameInterval): the slope of a least-squares line through the last 24 new frames’ timestamps over their frame counts, which evens out millisecond-rounded timestamps to inside 0.1% within 8 frames, where a mean of 16 frames’ own durations strayed 0.2% on such a stream. The frames the loop skipped between two it drew count by the last frame’s own duration, else by the line, and a frame more than 0.4 frames off the line, as after a change of frame rate, starts the line over from the frame before it; a seek, a new generation, or a history discontinuity starts it over too. - Telemetry’s presented media time is the frame the clock falls in: the one before the newest until the clock reaches the newest.
- A history reset clears the display-rate frames.
Black bars
setBlackBarCropEnabledturns the black bar crop on or off, off by default; the choice outlives sessions, and a change starts detection over and builds a new plan.- While it is on, plans end with
Presentation black bars(passes/builtin/BlackBarAnalysisPass.ts), a compute pass over the route’s signal after the pass that writes the canvas, so the frame’s own passes never wait on it. It runs a workgroup for every row and every column of the frame’s source,getBlackBarLineCountsof thepresentationuniform’s source size, and each reads 64 points evenly along its line, shifted along it from frame to frame. A point’s luma is the BT.709 weighting of its ‘source’ R, G, and B, where 0 is black in every transfer, kept signed, so ringing that swings both ways around black cancels in a line’s sum. A point is lit above 0.1, about 0.3 nits in PQ and 0.4 in SDR at 100 nits, and bright above 0.3, as a subtitle’s strokes are. Each line’s word holds how many of its points are lit, how many are bright, and the sum of their luma. - Only a new frame with a source runs the analysis: the presenter hands the executor a readback buffer (
PresentationFrameInput.blackBarReadback), the executor copies the statistics into it, and the presenter maps it once the GPU wrote it, throughwaitForGPUCompletion. Up to 3 readbacks map at once; a frame while all of them do goes unanalyzed. BlackBarDetectordecides the bars from each frame’s words. A line holds picture when its mean luma passes 0.06, about 0.1 nits, as FFmpeg’s cropdetect reads a line, or when 2 or more of its points are bright, as a subtitle in a bar makes them. The line at each end of a frame’s picture that holds no bright point and less than half the level of the line inside it is chroma bled across the edge, not picture. An end of an axis is a sharp edge when a line within 3 lines of it at 1080 lines is lit across half of the points within the other axis’s picture. Each axis crops symmetric bars, the smaller of its two ends, so a dark region at one end never crops the other, and bars must leave at least 30% of the frame.- Bars crop at once on a frame that looks like a letterbox or a pillarbox: sharp edges at both ends of one axis, within 0.4% of the lines of each other, and a picture that spans the other axis. Otherwise a frame whose picture ends in a sharp edge where its bars end proposes them, and they crop once a frame of another shot agrees within 0.4% and no frame between them showed picture inside them; a shot starts after a black frame, or where the mean levels of the rows and of the columns move by more than 8 of 255 on average.
- A frame whose picture reaches into the cropped bars, such as an IMAX scene or a subtitle in a bar, crops less at once. Once an axis cropped, it crops more only for bars agreeing shots support and no picture reached into for 30 s of playback, at the start of a shot; a step between frames counts as at most 250 ms of it, so a seek ahead counts as little.
- A frame of another size starts over.
- The crop rounds to a standard aspect ratio (
StandardAspectRatios.ts), from the table of the Detect Aspect Ratio plugin, when its picture is that close: the nearest ratio that moving the bars of an axis the crop cuts reaches, by cutting at most 0.5% more of the picture or by showing at most one line more of bar, half a line at each end. Moving the rows’ bars changes the picture’s height and the columns’ its width, by the picture’s shape on the display, its pixel aspect ratio included; where both axes could, the one that cuts picture moves, and the picture keeps its center. So a picture coded at 1920x804 presents at 2.39:1, one at 1920x816 stays as measured, since 2.35:1 would show over half a line of each bar, and one with a subtitle in a bar near no ratio stays as measured too. - The crop applies to the layout:
calculateTexturePresentationGeometrytakes the part of the source the rounded bars leave as its source crop, which object-fit fits as the whole object, so a letterboxed film fills an ultrawide surface andcoverzooms the picture rather than the bars. A change takes effect from the next frame; the presenter invalidates the layout and asks for a redraw, which a paused pushed session answers with a refresh. - Telemetry’s
blackBarCropgives the bars in the analyzed frame’s lines, null while the crop is off, andblackBarAspectRatiothe standard ratio the picture presents at, null while it presents as measured.
Transitions
-
Seek: a new presentation generation, then
controller.seek, which stops the running generation and starts a new one at the target in the same worker. The owned HEVC, AV1, and VP9 paths start at the preceding key packet, and drop the frames before the target except the one the target falls in, which presents first; a frame at the target itself presents first with nothing before it. The preroll decodes in fenced batches (see Video credits above). DTS and TrueHD use a 1 s preroll. The host drops stale seek results by its own revision. -
Audio track switch: eligibility runs again. If the source is no longer eligible, the session renegotiates; otherwise it restarts as a seek at the current time. Downmix gain changes apply live with a 20 ms ramp.
-
Audio output layout switch (
controller.reconfigureAudioOutput): a new device layout, force stereo, or a new downmix algorithm restarts only decoded audio while video keeps playing. The requested layout applies whole to a source a package renders as objects (audioRendersObjects); for any other source its floor is a ceiling applied to the decoded source layout, which the worker reports withaudio-source-format: three channels, 5.0, and 5.1 take 5.1, 6.1 and 7.1 take 7.1 or fold into 5.1, and anything else mixes to stereo. A request that keeps the layout only records its downmix for later restarts, and a newer request replaces a pending switch. A binaural headphone change that keeps the layout switches live:session.updateAudioBinauralreaches the attempt’s package asrenderBinauralbefore its next packet.- The controller stops the output and picks a target 250 ms ahead of the clock (the current time when paused or waiting on an audio underflow).
session.resyncAudiostops the old bridge and advances the audio epoch.BrowserCustomAudioOutput.reconfigureretires the worklet, setsdestination.channelCount, and leases a worklet with the new channel count on the same context and sink.- The worker gets
resync-audio: it closes the audio decode worker’s attempt, whose channel to the replaced worklet closes at once, and opens a new one at the target with the new worklet’s channel and a rebuilt downmix, resampler, and limiter (DTS and TrueHD keep their preroll). Samples and credits are tagged with the epoch, so stale ones are dropped. - Once the new epoch has buffered 100 ms, the output starts when the clock reaches the target. Audio telemetry does not move the clock in between. A clock that waited on the old output’s underflow resumes at this point, because the new output starts full and never reports a recovery. A pause holds the start, and resume counts down to the target again.
A failure, or a fill that exceeds 5 s, falls back to HTML in the same session. The host triggers the switch, for example when the output reports
sinkchangewith a different channel count. -
Pause: the clock pauses and the worklet is gated to silence. The AudioContext keeps running; the pool suspends it only when idle.
-
Playback rate (
controller.setPlaybackRate): every generation of the controller keeps the requested rate, across seeks, track switches,play, and stop. A change applies live to the clock, the worklet’s stretcher or the<audio>element, and the presentation interval, unless it crosses the audible range’s boundary while the source has an audio track: then the generation restarts as a seek at the current time, with or without audio. A generation without audio keeps the requested audio selection aside, and seeks, track switches, and the restart back into the audible range take it up again; a downmix or layout change meanwhile only records itself in that selection. The decoded PCM startup and resync fill scales to 100 ms times the rate, at most 200 ms, the live layout switch’s lead to 250 ms times the rate in media time, and its start waits for the target at the rate. -
GPU device loss: one recovery per session. The presenter first tells its host’s device loss handler, its third constructor argument, the browser’s reason and message; a device it destroys itself is never reported. The new device must re-authorize the active HDR, Dolby Vision, or raw route before it draws. If that fails,
device-recovery-failedfalls back to HTML. The frame-pool route never recovers, because its decoder’s references lived on the lost device, so it falls back the same way. -
Audio sink change (
WebGPUAudioOutputManager): the default sink issetSinkId(''). The fallback chain is the selected device, then the default, then the first enumerated device. A route whose sink ID is unchanged is left alone, because browsers ignore a same-IDsetSinkId. Decode, worklet, and queues never restart for a sink change. -
Output device recovery: Chromium binds an AudioContext created with no output device to a fake output for its whole life. The pool detects such a context at creation and closes it on release instead of pooling it. While one is in use, or a context has reported
error, the router pollsenumerateDevices()once a second, because Chromium sendsdevicechangeonly with microphone permission. Each poll that lists an output runs a recovery pass, without a retry limit: it rebuilds the sink withsetSinkId({ type: 'none' })and then the requested sink, and resumes. Polling runs only while the page is visible; a hidden page stops it and probes again as soon as it becomes visible. The rebuilt sink reportssinkchange, so a surround device switches the session’s layout at once. -
Re-detect output (
WebGPUAudioOutputManager.redetectAudioOutputs, offered by the host’s settings): rebuilds every AudioContext sink so the browser re-reads the device. Chromium moves a default output to a new device without telling the page, which leaves a stale channel count until a rebuild. -
End of stream: the audio decode worker flushes the resampler and limiter tails, and the worker posts
ended. The controller emitsendedonce the bridge and worklet queues are empty, the output time has reached the end, and the video queues are empty. On the frame-pool route the worker’s last packets still decode on the page, so the session ends its video only once the GPU decoder drained its last frame. -
Audio ends first: the worker posts
audio-ended(epoch-tagged) when an audio attempt finishes while video continues, and ends the run only once the video track itself has ended. The worklet’s final underflow releases the audio tail instead of starting an audio wait that would end inplayback-stalled. Unlike the end of stream drain, video waits stay in force and an uncorrelated output does not pause the clock; once the tail is out, video starvation holds the clock. The end also completes a start or audio resync that has no PCM left to wait for. A native-media session sendsendOfStream, so<audio>plays out, and the clock then runs without the element. -
Generation handoff: the session’s worker runs one generation at a time, and every run posts
stoppedlast. A newstartgoes out right behind the previous run’sstop, and the worker starts it at once, while the stopped run unwinds beside it;session.stop()does not wait forstoppedeither. A stop closes the run’s decoders and audio attempt and cancels its reads, and the next run’s parser pipeline replaces the stopped run’s on the coordinator worker, so the next run never waits on the stopped one. A run’sstoppedwaits until the audio decode worker released the run’s decoded PCM attempts, and the page drops a retired run’s frames, packets, and audio as stale. A worker is replaced, never reused, after a generation fails, a message fails validation, or the worker crashes; a session’s start on another video decoder backend also gets a new worker, since the session’s earlier runs may still unwind in the old one. A retired run that does not poststoppedwithin 10 s, or a retired run’s failure, replaces the worker at the next start, and such a worker terminates once it holds no run; a start that would leave more than 4 retired runs unwinding in the worker takes a new one. A lost audio decode worker fails its run’s decoded PCM asaudio-output-failed, and the decode worker spawns another for its next run. -
Stop:
controller.destroystops the generation, releases the worklet and the sink lease (1.5 s cap), and ends the presenter session. Once the stopped run postedstopped, the decode worker, with its parser coordinator, parse workers, and audio decode worker, waits for the page’s next session, as the next item of a queue starts one, and terminates if none takes it within 30 s (releaseIdleCustomDecodeWorkerends the wait at once). A session on either video decoder backend takes it. The worker’s first run on another backend than its last run’s ends what that backend set up in the worker: the parser coordinator and its parse workers for the GPU decoder, or the decode fence’s device for the native one. A worker unfit for another generation terminates instead. -
Source prefetch: a host that knows the URL its next session will play, before it starts the session, calls
prefetchCustomDecodeSource(source)(pipeline/CustomDecodeSession.ts), so the media’s first range read overlaps whatever comes before the start, such as the host’s own negotiation. The prefetch goes to the worker that session takes: the idle one, or a session’s worker, which waits for the prefetch until the session is destroyed and takes it then, behind its runs’stop, or else a new worker, whose startup it overlaps too. A session on any video decoder backend takes that worker once it holds no run. In the worker,prefetch-sourceretains aUrlSourcefor the URL and starts its first read, the read at byte 0 that any run’s input begins with; a worker whose run still holds it ignores it, but a stopped run, whose reads were canceled, does not hold it while it unwinds. The session’s first run over the same URL and pattern acquires that source and continues on its response, so it sends no second request for the head. A run over another URL releases it, as doesrelease-prefetchwith the prefetch’s ID, which the handle’scancelposts and which the worker ignores once a run took the source over; no run ever reads another URL’s bytes. A source no run takes within 5 s is released, a failed read releases it at once, a later prefetch replaces an earlier one, a prefetch that waits 5 s for a session to be destroyed gives up, and a session that takes no idle worker, as one that starts before the destroyed session’s worker parks, ends the prefetch at once.
Fallback and renegotiation
pipeline/CustomPlaybackController.ts:getFallbackDisposition decides what a failure does:
| Disposition | Reasons |
|---|---|
| HTML player, same session | audio-output-failed, audio-output-unavailable, lifecycle-failed |
| Renegotiate the source | decode-failed, ended-before-ready, network-failed, playback-stalled, range-unsupported, source-unsupported, startup-timeout |
- The host carries out each disposition, and may renegotiate on every reason when its HTML player cannot play the source.
- Failures in the host’s wrapper (presenter, frame submission, rAF) are
lifecycle-failed. - Fallback never selects another player.
Every stale callback is dropped by a generation or revision check:
- The host: its own generations and revisions.
- The controller:
activeGenerationandfallbackGeneration. - The session: a generation record per generation, which its worker’s messages must match.
- The worklet: its flush generation.
- The presenter:
deviceResourceEpoch, the color and layout revisions, andfallbackLatched.
Gotchas
- External textures of high-bit-depth frames are 8 bits per channel in Chromium (see Decisions). Authorization tolerances follow: external HDR 10/255, external Dolby Vision 8/255, raw 3/255.
- The native external HDR route rewrites the color of the
hvcCSPS and of every in-band SPS, in delta packets too, to BT.709 at the stream’s own range, and the shader recovers the 10-bit codes (Y*876+64,C*896+512), a full-range code as its limited-range equivalent. A frame with a colorSpace that is not neutral, BT.709 at either range, latchesdecoded-frame-color-mismatch. - The raw route checks each frame’s colorSpace against the metadata, and a null member is unspecified, so it never contradicts.
SDR also matches a
smpte170mtransfer, and HLG abt709transfer on BT.2020 primaries. - WebGPU presentation on the HTML path is SDR only: a
<video>external texture is browser-converted sRGB. - A deinterlaced native frame’s chroma is the browser’s progressive upsampling, which blends the two fields’ chroma rows; only the raw and frame-pool routes read chroma within each field.
- Worker track indices are container ordinals, not Jellyfin
MediaStream.Index; the streams Jellyfin lists for external files (IsExternal) take none. - Every time the page and the worker exchange is a playback time, which starts at 0 where the host’s positions start.
A container on a program clock, MPEG-TS, M2TS, or an MPEG program stream, stamps its packets past zero, 600 s on a Blu-ray title, so the worker counts a run’s times from the first timestamp of the tracks it plays (
demux/PlaybackTimelineOrigin.ts). It adds that origin to the start and every resync target before it reads the tracks, and takes it off every frame, packet, decoded PCM input, native audio fragment, startup progress report, and container duration it posts. The audio decode worker and the worklet run on the playback timeline; the queues the worker keys by packet timestamps, the Dolby Vision RPU and HDR10+ queues and the EL pairing, keep container times. - Every seek, audio switch, and paused repaint starts a new run in the session’s worker, and each run opens its input again, over the last run’s URL source when it reads the same URL (
demux/URLSourceRetention.ts). The source keeps its cache, with the container’s header pinned in it and its index once read, and its open responses, so a seek reads the header and index from memory and reads on near where the last run stopped. Matroska Cues are read at the first lookup past the start, so a run that starts at 0 never reads the end of the file. An MP4 or MOV movie box is read without the sample tables of a large track other than the first video and audio track, which that track reads at its first lookup and pins like the Cues, unless the source only reads forward. A run’s stop cancels the reads its input still waits for; a failed run’s source, or one no run takes within 5 s, is released, and only by the run, or the prefetch, that took it last. After the first preparation over a source, its read-ahead holds 5 s of media and its cache 15 s, at the media’s size over its duration, within 8 to 64 MiB and 32 to 192 MiB; a file’s cache stays 32 MiB. - Decoded PCM gain (volume cubed times normalization) is applied after the limiter and is not capped, so a boost can clip; clipping is counted. The native-media route caps gain at 1.
DecodedVideoGeometrylocks the first decoded size of a run, with a 64 px tolerance. A later size change fails the session.- The negotiated
maximumCoded*is Jellyfin’s croppedWidthandHeight, while containers and decoders report block-aligned sizes (mkvmerge can declare 2080 lines for a 2076-line HEVC picture). Route checks allow the same 64 px throughexceedsNegotiatedCodedSize. - A media server’s cross-origin 206 may hide
Content-Range, as Jellyfin’s stream endpoint does. Such a response passes only from a path the URL source’shiddenContentRangePathPatternnames, which the host sets, and only with aContent-Lengthwithin the requested range (demux/HTTPRangeResponse.ts); a source without a pattern needs every 206 to show itsContent-Range. The Jellyfin plugin sets the pattern of/Videos/{id}/streamon the stream URL it plays.
Eligibility and routes
This chapter follows a source from the engine’s capability evidence to a concrete decode and presentation route. The host turns the same evidence into what it advertises to its server; the Jellyfin plugin’s book describes its device profile.
Host entry points
A host calls these, in this order, for each playback:
CustomPlaybackEligibility.hasPotentialCustomPlaybackVideoRoute, the metadata-only prefilter, when it chooses a player. The prefilter declines a source whose containers no route inCUSTOM_CONTAINER_CODEC_RULEScarries, such as FLV, Ogg, or an MPEG program stream. It also declines a video codec no route decodes, such as Theora, rotated video, and interlaced HEVC and JPEG 2000, which code their fields apart (see Interlaced video).CustomPlaybackRuntime.getCustomPlaybackRuntimeAvailability, which fails without a secure context, Worker,navigator.gpu, orVideoFrame.probeCustomDecodeCapabilities(item), the native-media audio probe, and the GPU authorizations the item needs (5 s each), before it advertises anything; see Probes and caching.hasEligibleCustomVideoRoute, to ask whether the runtime would present one item with the measured capabilities and authorizations. It takes the GPU decoder packages a playback could use asgpuVideoDecoderSummaries, since offers need the packages and the presenter’s device; see GPU decoders.getCustomPlaybackEligibility, when the session starts, with the route flags and authorized keys ofCustomPlaybackEligibilityOptions.allowRawSDRneeds only a settled:sdrraw key; a key outside BT.709 authorizes on first use, sogetCustomPresentationEligibilityOptionswaits for the presented stream’s own key (getFirstUseRawSDRRouteKey).gpuVideoDecodersholds the GPU decoder packages the host offers, fromPresentationSetup.getGPUVideoDecoderOffers, andallowFramePoolHDRlets them present HDR; see GPU decoders.audioDecodersholds the host’s audio decoder packages; see Audio routes.
Eligibility
getCustomPlaybackEligibility checks in order:
parsePlaybackSource: DirectPlay, not live, a custom container, and a mediasource: aFile, or a credential-free http(s) URL, which it resolves against the page. A URL source keeps the host’shiddenContentRangePathPattern, the paths whose cross-origin 206 hidesContent-Range(see Architecture). A missing or zeroRunTimeTicksis an unknown duration, not a rejection. Playback then ends with the streams, audio takes the decoded-PCM route because the native media backend needs a duration, and the worker reports the container’s duration onready.selectVideoStream, and a codec the engine names. A codec it knows but nothing decodes, Theora, readsvideo-codec-without-route; any other unnamed codec, such as H.263+ (h263p), readscodec-unsupported.- Rotation 0, and a codec that keeps an interlaced stream’s fields together in frames (
codesInterlacedFieldsApart, frompresentsInterlacedVideo). selectVideoOutput(below), and a route that presents the stream’s scan (refusesVideoScan).selectPlaybackAudio.supportsCustomContainerCodecCombination.- The runtime budget.
An ineligible result carries its reason, and the host falls back.
The worker refuses what only the file shows, with a message that names the check: a track whose edit list Mediabunny cannot honor (getUnsupportedFeatures()), an AVI video track that outruns its index (avi-index-incomplete), a video track Mediabunny names no codec for that no frame-pool route takes (no decode route: fourcc: <FourCC> for an AVI or ASF track, codec-id: <ID> for another container’s), and a DTS Express audio track (no decode route: dtse).
On the frame-pool route an MPEG-TS, M2TS, or MPEG program stream track of MPEG-1, MPEG-2, or VC-1 must carry the negotiated codec (container-codec), and takes its first sequence header’s extradata as codec private data (sequence-header-missing when none of its first 64 packets has one); the worker tells these MPEG system streams by their input format (isMPEGSystemStreamFormat).
A VC-1 or WMV3 track must be a Matroska V_MS/VFW/FOURCC track, an ISO base media vc-1 sample entry (VC-1 only), or an ASF stream of WVC1 or WMVA (VC-1) or WMV3 (codec-id); an AVI stream of those FourCCs is refused, as no container rule places VC-1 in AVI.
An ASF stream’s FourCC and extradata come from its BITMAPINFOHEADER, and its media objects, in decode order like a Matroska VfW track’s, are restamped in presentation order by VC1PresentationOrder.ts.
An MPEG-4 Part 2, H.263, or MS-MPEG4 track must have a codec ID of the negotiated codec (codec-id): Matroska’s V_MPEG4/ISO/SP, ASP, or AP and ISO base media’s mp4v for MPEG-4 Part 2, which Mediabunny names mpeg4 (container-codec otherwise), s263 or h263 for H.263, V_MPEG4/MS/V3 for MS-MPEG4 v3, and Matroska’s V_MS/VFW/FOURCC, AVI, or ASF for any of them.
A VfW, AVI, or ASF track’s FourCC must be one FFmpeg’s riff.c maps to the negotiated codec, compared upper-cased (fourcc), and Mediabunny leaves every such AVI or ASF track but MPEG-4 Part 2 codec-less, with the FourCC as its codec ID.
A codec-less AVI or ASF track’s extradata is the one after its BITMAPINFOHEADER (demux/VFWStreamFormat.ts), as FFmpeg’s AVI and ASF demuxers pass it.
The codec private data is a 40-byte BITMAPINFOHEADER of the track’s coded size and FourCC (mp4v, s263, or MP43 for a track without one), then the track’s extradata (see Decoder packages).
AVI, ASF, and Matroska VfW stamp packets in decode order, so MPEG4PresentationOrder.ts restamps an MPEG-4 Part 2 track from them that FFmpeg outputs one VOP late: its video object layer, from the extradata or else the first key packet, clears low_delay in its vol_control_parameters, or has none and is neither Simple nor Advanced Simple, or has none and its first group holds a B-VOP.
An I, P, or S-VOP then takes the time of the next coded VOP that is no B-VOP, passing over N-VOPs, for which FFmpeg outputs nothing, and a B-VOP keeps its own.
A JPEG 2000 track must be an MJ2 mjp2 sample entry or a Matroska V_MS/VFW/FOURCC track (codec-id), whose FourCC must be one riff.c maps to JPEG 2000, compared upper-cased (fourcc), such as the mjp2 FFmpeg’s Matroska muxer writes.
Its blocks are bare codestreams, every one a key packet stamped in presentation order, and the decoder takes no codec private data, as FFmpeg’s decoder reads no extradata.
A Matroska attachment, such as a cover image, is no track, so it never shifts the video track ordinal.
AVI audio takes the same routes as any container’s: Mediabunny reads a WAVE_FORMAT_EXTENSIBLE track’s codec from its SubFormat, and gives FLAC and Vorbis the decoder descriptions their strf extradata holds.
A track whose container declares a WAVE channel mask, which Mediabunny’s getChannelMask reports for WAVE_FORMAT_EXTENSIBLE, is laid out by that mask: the worker takes its layout from CustomWaveChannelLayout.ts and passes the mask to the audio decode worker’s attempt, whose output stage binds Mediabunny’s decoded samples to it.
So a three-channel 2.1 track feeds its third channel to the LFE, and 3.0(back) splits it across the surround pair, as FFmpeg’s decode does, where three channels without a mask are 3.0.
A mask of 0, or none, lays channels out by their count; a mask the output stage has no layout for, or one naming more or fewer speakers than the track has channels, is refused as The selected audio channel mask is unqualified.
Without a container mask, the host’s audioMetadataLayout (Jellyfin’s count and, where the metadata names a layout no count implies, its mask) screens the track: a metadata mask lays out the channels when it names the container’s count, and Jellyfin’s count replaces a container count that has no layout (demux/CustomAudioTrackMetadata.ts); a track neither lays out is refused as The selected audio channel layout is unavailable.
Probes and caching
- Main thread: WebCodecs configuration and decoded-output probes, raw
copyTofingerprints, the per-profile H.264 probe, and the MSE AC-3/E-AC-3 probe. The engine has no decoder of its own to probe: Mediabunny’s PCM decoding is always supported, and a codec only a package decodes readsunsupportedwith the reasonno-engine-decoder. - GPU: presentation authorization reads back renders of the production shaders.
Results are cached per
GPUDevice, canvas format, and shader signature, and dropped on device loss. Atimeoutordevice-lostdecision stays visible but is not kept: the next authorize call probes the route again (isRetriedAuthorizationDecision), since a deadline that counted time behind other runners is no verdict. Every runner, and the canvas readback, pushes and pops its validation error scope in the same task, around its submit (captureValidationErrors,submitAuthorizationFrames), and awaits the popped result later, because the runners at device creation share the device’s one scope stack and a late pop could take another runner’s scope. - External HDR authorization opens a hardware decoder (HEVC
hvc1.2.4.L120.B0at 1920x1088, AV1av01.0.08M.10at 1920x1080,prefer-hardware), so itsisConfigSupportedcheck and its decode run as recorded steps of the page’s probe session, under the probesexternal-hdr-authorization:hevcandexternal-hdr-authorization:av1, with labels such ascheck hvc1.2.4.L120.B0 1920x1088 prefer-hardware. A crash in them is quarantined like a capability probe step’s, and a blocked or defective step rejects the route with the reasonstopped-pageorbrowser-defect. The decode step’s call decodes, imports, renders, and reads back within the manager’s step timeout (CapabilityProbeManager.timeoutMilliseconds), and returns decisions, never the frame. The routes of one codec and target format asked for in the same task, such as the HEVC PQ and HLG keys, share one check and one decode, and a device runs a codec’s batches one after another, so it never holds two of that codec’s decoders. A deadline that passes during the check opens no decoder. - External Dolby Vision authorization returns
video-frame-creation-failedbefore compiling anything whencanHoldVideoFrameFormat('I420P10')is false, as on shipping Safari or withoutVideoFrame. The other authorizations open no decoder, so they are not recorded. selectCustomDecodeProbes(item)picks the probes an item needs. Every audio probe runs for every item, because a playing item can switch to any of its audio tracks: the stereo probes (native-audio), the 5.1 probes (native-surround-audio), and the 7.1 probes (native-seven-point-one-audio). The video probes follow the union of the video streams across the item’s sources: the codec picks its native probes, a range extension picks its own variants, an H.264 stream beyond 8-bit 4:2:0 picks every raw format variant its profile codes at its depth beside the per-profile probe, since a host advertises the profile at that depth only when all of them qualify, a stream beyond 8-bit SDR adds the raw-plane and native HDR probes, and a range that is neither SDR nor static HDR adds the Dolby Vision probe. JPEG 2000, MPEG-2, VC-1, MPEG-4 Part 2, H.263, and MS-MPEG4 streams pick none, since only a GPU decoder package decodes them. An HDR item also runs the HEVC and AV1 probes, because an HDR transcode targets those codecs and their ranges bound it. An item without stream metadata, or with a video stream that names no codec, runs every probe.- A probe the item does not select reads
not-probed, which no profile rule or eligibility check treats as support, and its H.264 profile result is absent;probeStatesrecords which probes a result ran. At eligibility, the host keeps the negotiated result whenhasProbedCustomDecodeSelectionsays it covers the played source, and otherwise probes that source. - A range-extension or raw-format probe downloads its vector before its decode step joins the probe manager, under its own 30 s bound (
capability/CapabilityAssetLoading.ts), so a slow link never reads as a slow decoder or trips a step’s timeout. A download that fails with a network error, 408, 429, or 5xx is retried after 250 ms and again after 1 s, inside the same 30 s bound; any other HTTP failure, or the bound ending, is final and reportsasset-unavailablewith an unknown status. - Before its download, a raw HDR, range-extension, AV1 raw format, or H.264 raw format probe asks
canHoldVideoFrameFormat(format)(capability/VideoFrameFormatSupport.ts), which builds a 2x2VideoFrameof the format. ATypeErrormeans the browser has no such pixel format, as every shipping Safari lacks the 10-bit and 12-bit formats, and the probe readsunsupportedwith the reasonoutput-copy-unsupportedwithout downloading or decoding. - An audio probe copies each
AudioDatain its own format (copyAudioDataPlanesincapability/AudioDataPlanes.ts): one copy for interleaved data, one per plane for planar data, converted to float in JavaScript. It never asks the browser to convert a sample layout, since Safari 26.0 to 26.3 ends the page incopyTo(..., { format: 'f32-planar', planeIndex })with a plane index of 2 or more on its interleaved multi-channel output (WebKit bug 302521). - A step that matches an entry of
capability/BrowserDefects.tsnever runs and readsunsupportedwith the reasonbrowser-defect. The table holds WebKit bug 308322: AAC decoding with more than 2 channels on WebKit before Safari 27, or of unknown version, may end the page, since AudioToolbox can return no format description for the raw AudioSpecificConfig. An iOS 26 user agent freezes the OS version at 18.x and a web view has noVersion/, so such a WebKit counts as unknown. - Each probe is one promise per page, so a later item runs only the probes no earlier item ran.
It is dropped only when the store clears a stored verdict it holds, or replaced when a background rerun reaches a new verdict.
capability/CapabilityProbeManager.tsruns the browser calls of every probe, and the MSE AC-3/E-AC-3 playbacks, side by side; see The probe manager.
The probe manager
Every browser call is a step: each isConfigSupported check, each decode, each H.264 profile’s check and decode, and each MSE layout playback.
One CapabilityProbeSession per page (capability/CapabilityProbeSession.ts, from getDefaultCapabilityProbeSession() or a probe’s getSession()) owns the manager, and every probe’s steps go through its record(queue, probe) wrapper; see Probes that stop the page.
capability/CapabilityProbeSteps.tsdescribes each step with a label naming the exact call and a cost{ bytes, decoders }. Video labels read<check|decode> <codec> <W>x<H>, then thehardwareAccelerationunless it isno-preference, then the copy format; audio labels read<check|decode> <codec> <N>ch <R>Hz, and MSE labelsplay <codec> <N>ch <R>Hz. Labels are printable ASCII, at most 96 characters (CAPABILITY_PROBE_STEP_LABEL_MAXIMUM_LENGTH).- A check costs 0 bytes and 1 decoder. A video decode costs the frame layout at coded size times the codec’s maximum reference frames plus one, plus one copy frame when it copies: 16 for H.264 and HEVC, 8 for VP9 and AV1, and 3 for VP8. An audio decode costs its decoded f32 PCM.
- Negotiation steps, and a promoted background run’s, run in parallel while the running decoders stay within the decoder limit and the running bytes within the byte limit; a step larger than the limits runs once nothing else does.
The byte limit is
CAPABILITY_PROBE_DEFINITION_BYTES, the largest decode cost among the steps of every probe definition (getLargestCapabilityProbeStepCost), the raw HEVC 4K decode, so parallel probing never holds more than the heaviest single test would alone. The decoder limit isnavigator.hardwareConcurrency, or 1 without it. The session lowers each to the stored ceiling’s value in that dimension when it has one (CapabilityProbeCeiling, each ofbytesanddecodersa number or null for uncapped), and the decoders to 1 while suspects remain; it reads the limits again once the last suspect settles. - Negotiation steps, and a promoted background run’s, go before background steps; among them, a step that does not fit never holds back a later one that does.
A negotiation’s video decodes join the manager only once its audio probes settle (
manager.after), so a video timeout never costs audio its verdicts; checks join at once. - A step of a background run that is not promoted, read at admission, runs alone: it starts only when nothing runs and no negotiation step waits, and no other background step joins it, checks included.
A negotiation step may join a running background step, so playback never waits on the warm-up, but a background crash still names exactly one call when no negotiation runs.
A promotion takes effect at once (
CapabilityProbeBackgroundRun.promotion): the run’s waiting steps join the negotiation steps without waiting for a running call to settle. - A step enqueued with
{ alone: true }runs by itself: it starts once nothing runs and no step ahead of it waits, and nothing joins it. A negotiation’s other steps go ahead of it and it goes ahead of background steps, except that a background step that runs alone goes last. - A decode step may pass
confirm(value), which accepts a negative result. A call that overlapped any other call and settles before its timeout with a valueconfirmaccepts runs again alone, timed from its own admission, and its caller gets that run’s result. A call that ran alone, a timeout, and an error are never rerun. A hardware decoder pool may refuse a decoder only because other calls hold theirs, and a refused decode would otherwise be stored asunsupportedfor the fingerprint. Every decode step passes it: native video and Ultra HD, HDR and Dolby Vision frames, raw HDR and the exact raw formats, audio, each H.264 profile decode, and each MSE playback; configuration checks do not. - A step holds its reservation until its call settles, not until its timeout, so work the browser still does keeps counting.
Checks and decodes (
check,runTimed) time out 2 s after they start, so queue time never counts, and resolveCAPABILITY_PROBE_TIMEOUT. A timed call still unsettled one more timeout after its own stops the page’s probing: every waiting and later step reads as a timeout until the page reloads. - The H.264 profile output probe also closes its own decoder after 2 s (
H264_PROFILE_OUTPUT_PROBE_TIMEOUT_MILLISECONDS), since the manager cannot close a decoder. - An MSE playback (
NATIVE_MEDIA_AUDIO_PROBE,native-media-audio) joins through the untimedrun, since each playback bounds its own stages at 3 s; its cost is its buffered f32 PCM. - The raw HDR output probe waits for a pending
copyTobefore it settles, and closes a frame only after its copy settles.
Background probes
A host may start the probes before its first negotiation with warmCustomDecodeCapabilities(), and tells the engine when media plays with configureCustomDecodeCapabilityBackground({ isPlaybackActive, refreshAfterUse }).
Each call replaces the previous configuration; refreshAfterUse, which defaults to true, decides whether a stored verdict a negotiation used reruns in the background (see Stored verdicts).
- The warm-up runs every probe without a page result or stored verdict, one probe after another, in the order a full run starts them.
A client whose
navigator.connection.saveDatais set, or whosenavigator.hardwareConcurrencyis below 4, gets no warm-up. - Before a background probe starts, and before each of its
runandrunTimedsteps joins the manager, it waits for its turn: arequestIdleCallbackwith a 2 s timeout (BACKGROUND_PROBE_IDLE_TIMEOUT_MILLISECONDS), then no media playing, no negotiation under way, and none settled in the last 10 s (BACKGROUND_PROBE_NEGOTIATION_HOLD_MILLISECONDS); a held step looks again after 1 s. Its checks skip the wait, but still run one at a time like its other steps. WithoutrequestIdleCallback, as on WebKit, the wait is the whole 2 s. The wait comes before a step joins the manager, so it never counts against the step’s timeout, and it never holds back a negotiation’s steps. - A background probe downloads its range-extension vector with
fetch(..., { priority: 'low' }), retries included. probeCustomDecodeCapabilitiespromotes a background probe it needs: its remaining steps skip the wait and take a negotiation’s admission at once, and its later downloads take the default priority; a download already under way keeps its priority. A probe no background run started runs at once, as without a warm-up, so a negotiation shares the limits only with the one background step that may be running.- A background step that times out marks only its run and halts the background for the page: no later background step runs, and the run’s result, like any background result with a
probe-timeoutpart, is discarded rather than kept as the page’s result. A negotiation that needs the probe runs it again. - A background run of a probe that holds suspect steps runs after the others; see Probes that stop the page.
runCustomDecodeBackgroundTask(task)runs a host’s own work in the same background: after every background probe queued ahead of it, one task at a time, each at a turn a probe step would take. No negotiation promotes a task, a task that rejects counts as run, and the promise resolves whether the task ran; a page whose background halted drops it.warmGPUVideoDecodersruns its compiles this way; see Decoder packages.
Stored verdicts
capability/CapabilityProbeStore.ts keeps verdicts across page loads and browser restarts, in one localStorage entry, webgpu-player-capability-probe-verdicts.
- A host names its build with
configureCapabilityProbeStore({ buildKey }), as it callsconfigureEngineAssets; until then nothing is read or written. - The entry holds a schema version (
CAPABILITY_PROBE_STORE_SCHEMA_VERSION), the fingerprint, the time of its last write, and each probe’s result by itsCustomDecodeProbename. - The fingerprint is the build key, the default adapter’s
info, its sortedfeatures, and itslimits,navigator.userAgent, and Chromium’sgetHighEntropyValues(['fullVersionList', 'platformVersion']), compared as JSON with sorted keys. A fingerprint that cannot be read within 2 s disables the store for the page. - An entry of another schema or fingerprint is a miss, and the next write replaces it, so an update leaves no other entry behind.
- A result is written only when every status in it is
supportedorunsupported; one with anyunknownornot-probedpart, from a timeout, an exception, or a missing API, is not. Writes merge, so the video probes of each item a page negotiates join the entry. CustomDecodeCapabilityProbereads a probe’s stored verdict before running it. A hit answers at once and schedules the probe to run again in the background, which overwrites the stored verdict and the page’s result when it reaches a verdict; a rerun that reaches none leaves both. With refresh after use off (refreshAfterUse: false, or the probe’srefreshAfterUseoption andsetRefreshAfterUse), a hit schedules no rerun, so a negotiation it serves makes no browser call and no write. Turning it off schedules no further rerun, but a rerun already queued still runs; turning it back on reruns the hits from then on.- A start that falls back with
decode-failedorstartup-timeoutbefore its first frame clears the entry and the stored verdicts the page holds, when the page used any (clearAfterStartFailure, fromCustomPlaybackController), so the renegotiation probes again. Driver updates, OS codec packs, the hardware acceleration setting, and Chrome’s software fallback after a GPU process crash change no fingerprint. clearStoredCapabilityProbeVerdicts()clears it, quarantine included, andreadStoredCapabilityProbeVerdictState()returns the stored fingerprint, write time, age, probe names,blockedSteps,suspectSteps,ceiling, andcrashedProbes, the blocked and suspect steps grouped by probe, each aCrashedCapabilityProbewith its probe name and step labels, sorted by probe.onCapabilityProbeCrash(listener)calls the listener on the page that finds a stopped page, once per finding, after the entry records it, with each probe and the steps it had in flight; it returns the function that removes the listener. A listener that throws stops neither the recording nor the other listeners. A store of its own takes listeners withonCrash.- The MSE AC-3/E-AC-3 probe and the GPU presentation authorizations are not stored: the first is not a
CustomDecodeProbe, and the second are measured on the presenter’s device, which keeps them for that device.
Probes that stop the page
A probe whose browser call stops the page, as a WebContent crash on WebKit does, never returns, so it reaches no verdict and the next page would run it again. The store therefore keeps a run journal beside the verdicts, and quarantines the steps that were in flight when a page stopped, per fingerprint.
CapabilityProbeSession.record(queue, probe)(capability/CapabilityProbeSession.ts) is the one wrapper every probe step goes through, the MSE playbacks under the probenative-media-audioincluded. A step that matches a declared browser defect, or whose label the quarantine blocks, never joins the manager and resolves aCapabilityProbeBlockwhosereasonisbrowser-defectorstopped-page; the probe reads it asunsupportedwith that reason, which is a verdict the store keeps. A suspect step joins the manager as a suspect; any other step joins as usual. Once the manager admits a step, the wrapper records it on the store (markRunning(probe, step, cost)) with its label and cost, and ends the record once the call settles, even past its timeout. Downloads, idle waits, and steps waiting in the manager are not recorded, so a background run’s record covers only the moments the browser works on it.- Every probe run, negotiation, background, or MSE, first calls the session’s
readQuarantine(), which reads the store’s quarantine and sets the manager’s limits from it. - Each page keeps its record, the probes it has in a call, each with the labels of the steps it has in flight, the in-flight totals of bytes and decoders, and its fingerprint, under its own random id in the localStorage entry
webgpu-player-capability-probe-runs, and drops it once its last call ends. The page counts its open calls by probe and step, so concurrent calls of one step each appear once, and a step leaves the record once its last call ends. - A hidden page records nothing, since a browser may end a hidden page without a crash: the page drops its record as it hides, and as it unloads or enters the back-forward cache (
pagehide), whatever a later visibility change reads, and writes it again as it shows. - A page holds a Web Lock of its own id while it has a record: it takes the lock before it writes the record and lets go of it after it drops the record, so an idle page holds none. The browser frees a page’s locks when the page ends, however it ends, so a record whose lock is free is a stopped page’s.
- A page’s first quarantine read (
readQuarantineon the store) takes the record of every other page whose lock is free, holding that lock while it does, so no page of that id writes one meanwhile, drops the stored verdicts of the probes in it, and calls the crash listeners; a record of another fingerprint goes with no crash, and a page whose lock is held still runs, so its record stays. Without Web Locks, or when the lock manager takes more than 2 s, the page records nothing and reads no crash. - A stopped page with one distinct label in flight blocks that label: in every probe it reads
unsupportedwith the reasonstopped-pageand never runs again for the fingerprint. A label already blocked only adds the probes that had it in flight. - Several labels in flight become suspects.
The next round lasts while suspects remain, with the decoder limit at 1, and retries the suspects alone: the session enqueues each with
{ alone: true }, so it starts only once nothing runs and no step ahead of it waits, and the background runs first the probes that hold no unsettled suspect (holdsSuspectSteps(probe)). A suspect’s call never overlaps another, so it is never rerun to confirm a result. Once the last suspect settles, the session restores the limits, with any new ceiling, even in the middle of the round. A suspect that stops the page alone is the culprit: it is blocked, and leaves the suspects; the others clear as they settle (resolveSuspect). A step that was no suspect and stops the page alone during the round is blocked too, but explains nothing of the suspects’ crash, so the round can still end in a ceiling. - When the last suspect clears and no suspect was the culprit, the crash came from concurrency, and the store lowers the ceiling below that crash’s in-flight totals, each dimension on its own. A dimension the crash held some of is capped one below its total, never above an existing cap; a total of 0, such as the bytes of a crash with only checks in flight, is no evidence and leaves that dimension’s cap, or none. When a new crash comes before an earlier one is explained, the round keeps the lower of each total. A ceiling with neither dimension capped is null; the manager still runs one step alone whatever the ceiling.
- The store’s schema version is 2; an entry or record of version 1 is a miss.
Route catalog
| Backend literal | Output | Qualifying evidence | Gate |
|---|---|---|---|
native SDR | video-frame | H.264 per-profile keyframes; HEVC 1080p access unit; VP8, VP9, AV1 64x64; 4K variants | getOrdinarySDRVideoSelection |
native HEVC Main 10 and AV1 Main 10 HDR, Dolby Vision base | video-frame, with nativeHDRTransfer pq or hlg, neutralized | 4K Main 10 decode (nativeHDRHEVC, nativeHDRAV1), plus that codec’s external HDR or the Dolby Vision GPU authorization | getNativeHDRVideoCodec, selectNativeDolbyVisionBaseOutput (HEVC only) |
native raw | raw-planes: I420P10 for HDR, 10-bit SDR, and Dolby Vision (HEVC Main 10, AV1 Main, VP9 Profile 2), I420 to I444P12 for range extensions, for AV1 outside Main 10, and for H.264 beyond 8-bit 4:2:0 | copyTo plane fingerprints; nine 192x192 two-access-unit range-extension probes, eight 192x192 two-temporal-unit AV1 raw format probes, and five 192x192 I, P, B H.264 raw format probes, whose B frame outputs before the P frame; raw GPU authorization | supportsRawHDRVideo, selectHEVCRangeExtensionVideoOutput, selectAV1RawFormatVideoOutput, selectH264RawFormatVideoOutput |
gpu | frame-pool: I420 to I444P12, the stream’s own format | None at eligibility: a host offers its GPU decoder packages, whose own vectors run only when the host verifies one | selectGPUVideoOutput; native wins |
| Audio decoder package | decoded-pcm | None at eligibility: a host offers its audio decoder packages | findAudioDecoderPackage, for a codec no engine decoder supports |
| Mediabunny PCM | decoded-pcm | None at runtime; always marked supported | isSupportedCustomAudioInputLayout |
| MSE AC-3/E-AC-3 bridge | native-media | isTypeSupported, an fMP4 append, playback advancing; 2 or 6 channels at 48 kHz | getSupportedNativeMediaAudioRoute, checked first |
| WebCodecs aac, opus, flac, mp3, vorbis | decoded-pcm | Stereo 48 kHz silence vectors; separate 5.1 silence vectors; separate 7.1 tone vectors whose decoded planes must carry each source channel’s tone in WAVE 7.1 order | capabilities.audio[codec], nativeSurroundAudio[codec] for 6 channels, nativeSevenPointOneAudio[codec] for 8 |
Video route selection
selectVideoOutput tries these in order.
- A Dolby Vision descriptor.
PresentationInput.getDolbyVisionPresentationSelectionaccepts any integer profile with the base layer flag set, a valid bit depth, and any 4-bit compatibility ID (CCID) or none; the EL flag never rejects. The descriptor names the RPU route (reconstructionProfile): Profiles 4, 5, 7, and 8 as signaled; Profiles 10 (AV1) and 20 as 5 with CCID 0 or none and as 8 otherwise; none for Profile 9, the retired profiles, or a stream without an RPU. RPU reconstruction ignores the CCID, which only declares a base layer that can be shown on its own. The Dolby Vision routes, in order:- Native external Profile 5: a 10-bit HEVC P5, or a P20 that reconstructs as P5.
- Native external AV1 Profile 10 (
selectNativeAV1DolbyVisionOutput): a 10-bit Main P10 reconstructs as P5 or P8 from the hardware decoder’s frames, every sequence header neutralized to limited BT.709, whatever base the CCID declares. It needsnativeHDRAV1, the AV1 PQ external key, which proves the AV1 decoder’s frames import exactly, and the external key of the profile it reconstructs as:allowNativeDolbyVisionfor P5,allowNativeDolbyVisionProfile8for P8.getCustomPresentationEligibilityOptionswaits for the AV1 PQ key and the P8 key on first use. - The native compatible base, HEVC only, when the CCID declares one: HDR10 (1) or Ultra HD Blu-ray (6) for P7 and P8, and HLG (4) for P8. The RPU and EL are ignored. This needs exact BT.2020 limited base metadata with explicit transfer, primaries, and matrix. A separate-track P7 base is a standalone HDR10 track, so it qualifies whatever CCID the EL track reports.
- Raw RPU reconstruction.
The base layer decodes natively to
raw-planes: I420P10 for HEVC Main 10 and for AV1 Main at 10 bits, or the exact format of any range-extension variant or AV1 raw format variant, such as I420 for AV1 Main at 8 bits. HEVC Main at 8 bits has no native raw route, so its RPU route is ineligible. Single-layer routes (P5, P8, P10, P20) discard a signaled EL. No decoder decodes an EL, so dual-layer routes (P4, P7, HEVC only) reconstruct from the base layer alone: MEL is exact, and FEL presents its base layer. The worker still pairs a separate EL track to read its RPUs, and the presenter keeps its FEL composition for an EL decoder, which no route reaches. - The declared base layer (
getDolbyVisionBaseColorMetadata), through the ordinary steps below, where a GPU decoder’s RPU reconstruction (step 6 of this list) comes before the base’s own GPU route. CCID 1 or 6 declares PQ, 4 HLG, and 2 SDR, never for P5 or a P20 that reconstructs as P5. An explicit ColorTransfer must agree with the CCID; VideoRange and VideoRangeType are ignored. - Without a declared base, RPU reconstruction from a GPU decoder’s frame pool (see GPU decoders).
- Otherwise the source is ineligible, with the reason from the reconstruction step.
- No color metadata:
metadata-unsupported.PresentationInput.parseVideoStreamColorMetadatareads ColorTransfer, ColorPrimaries, ColorSpace, and ColorRange. A field that isunknown,unspecified, orreservedis absent and takes its transfer’s default: BT.709 for SDR, BT.2020 for PQ and HLG, and limited range. The BT.2020 10 and 12-bit transfers read as SDR, SMPTE 170M and SMPTE 240M primaries assmpte170m, BT.470 BG primaries asbt470bg, and the SMPTE 170M and BT.470 BG matrices as BT.601. Any other value is unrecognized and fails. - An HEVC range extension needs its exact variant capability and raw key, and so does AV1 outside Main 10 (High 4:4:4, Professional 4:2:2 or 12 bits), through
av1RawFormats[variant]. So does H.264 beyond 8-bit 4:2:0 throughh264RawFormats[variant]: a profile beyond High (High 10, High 4:2:2, High 4:4:4 Predictive, their Intra profiles, High 4:4:4, or CAVLC 4:4:4) whose pixel format is one of the five variants, I420P10, I422, I422P10, I444, or I444P10, and one the profile codes. The SDR keys go by matrix, primaries, and range, and 10-bit PQ or HLG takes the raw HDR key of the variant’s format. A range extension or a raw format variant never falls through to steps 4 and 5; a profile beyond High at 8-bit 4:2:0, 9, 12, or 14 bits, 4:0:0, or RGB has no variant and no other route, so it is refused. - SDR:
native, or native Main 10 SDR. Then, for 10-bit 4:2:0 SDR that neither decodes (AV1 Main, VP9 Profile 2, or HEVC Main 10 without native decode), raw I420P10 through the raw SDR key of its matrix, primaries, and range. JPEG 2000, MPEG-1, MPEG-2, VC-1, WMV3, MPEG-4 Part 2, H.263, and MS-MPEG4 have no native route (CUSTOM_PACKAGE_VIDEO_CODECS). - PQ or HLG: native external first, through the stream’s codec’s key (
external-hevc-main10-bt709-limited:<pq|hlg>-v1for HEVC Main 10,external-av1-main10-bt709-limited:<pq|hlg>-v1for AV1 Main at 10 bits), then raw (<I420P10|I420P12>:bt2020-ncl:bt2020:limited:<pq|hlg>). The worker rewrites the HEVC SPS VUI to BT.709 at the stream’s own range, or every AV1 sequence header, the codecs string, and anav1Cdescription to limited BT.709, so Chrome converts the hardware frame without tone mapping; each rewritten HEVC SPS must declare BT.2020 with the non-constant-luminance matrix and the route’s transfer, at either range, and each AV1 header limited BT.2020 with them, or the attempt fails. - When steps 3 to 5 chose no route, a GPU decoder package the host offers takes the stream, if one does; otherwise the source is ineligible with the reason those steps gave.
GPU decoders
The gpu backend decodes on the presenter’s device with a GPU decoder package’s WGSL; the frame-pool output presents its pool slots without a copy.
A package may carry a parser module (//! parser:), which the selection carries to the decode worker’s start as gpuVideoParser: the HEVC decoder project’s parser parses each packet into the GPU record its WGSL reads.
PresentationSetup.getGPUVideoDecoderOffers offers the host’s packages that accept the stream’s codec and write its output format, in the host’s order, with the presenter’s buffer limit; it verifies nothing.
selectGPUVideoOutput takes the first offer for the stream’s codec that meets each of these:
- The output format: a range extension’s own, else the one that holds the stream’s pixel format (
getGPUDecoderPixelFormatHolding: yuv422p is I422, and 9-bit samples take the 10-bit format, shifted up), else 4:2:0 at the stream’s bit depth. An explicit BitDepth must agree with the pixel format’s samples, and a pixel format no frame pool holds, such as RGB or 4:1:1, has no route. JPEG 2000’sxyz12le, the Digital Cinema profiles’ X’Y’Z’, takes I444P12, into which the JPEG 2000 package writes it as limited-range BT.709 Y’CbCr, so it needs a stream whose color metadata is BT.709 SDR at limited range, as an untagged stream’s is. - For a 4:0:0 pixel format, such as gray10le, which takes the 4:2:0 format of its depth: a decoder whose header declares
monochrome. - The decoder’s stream constraint, over the pool’s coded size and the format’s bit depth.
The pool takes the container’s coded size rounded up to 64 (
GPU_DECODE_POOL_CODED_SIZE_ALIGNMENT), the largest coding block, and the decoder’s header crops it, such as by a conformance window. A session may code larger, at the largest picture its parser declares (see How a session decodes), but eligibility checks the container’s size. - The frame pool, the decoder’s
stateandbuffer:s, and its packet ring each fit the device’s buffer limit (maximumBufferByteLength), the smaller of its largest buffer and its largest storage binding: the HEVC decoder project’s package holds 29 slots, which in I420P10 at 3840x2176 is about 727 MB.findGPUDecoderStreamFitFailurechecks them.
Static HDR needs allowFramePoolHDR, which the host sets when it tone-maps.
A Dolby Vision stream with an RPU route reconstructs on the route too, with dolbyVisionProfile set, when the host authorized that route (allowDolbyVision, allowDolbyVisionProfile4, or allowDolbyVisionProfile7, from the raw route’s key of the BL format) and an offer writes the format the RPU route names: the range extension’s own, I420 for HEVC Main, or I420P10 for HEVC Main 10 and AV1 Main at 10 bits.
allowFramePoolHDR plays no part in it.
The worker parses each packet’s RPUs, as on the raw route, a separate EL track’s included, and each frame carries its packet’s RPUs through the GPU decode session to the presenter; a repeat of the last good frame carries that frame’s.
The presenter reconstructs the slot through the raw route’s reconstruction (FramePoolDolbyVisionSignal.ts), gated by the same base key, and no frame pool holds an EL, so Profiles 4 and 7 reconstruct from their BL alone.
getGPUVideoDecodeTarget names the RPU route’s format, so a host fetches packages for a stream without a declared base.
hasEligibleCustomVideoRoute holds no offers, so a GPUVideoDecoderSummary (src/decode/video/gpu/GPUVideoDecoderCatalog.ts) stands in for one, after the offers, and selects the route without a decoder.
A summary names the package’s codecs and output formats, and may carry the package’s header and the buffer limit of the device a playback would decode on.
isGPUVideoDecoderSummaryFit judges the stream as an offer would: by codec and format, then with a header by monochrome for a 4:0:0 stream and the accepts conditions, and with a limit by the frame pool, state, buffer:s, and packet ring.
A summary without a header takes every stream of its codecs and formats, and leaves the other checks to the playback’s offers.
A package’s self-test vectors run only when a host calls verifyGPUDecoderPackage (see Decoder packages); eligibility never waits on them, and offering an unverified package is the host’s choice.
VP9, HEVC range extensions, and H.264 beyond 8-bit 4:2:0 present HDR only through raw planes, and AV1 keeps its raw route beside its native external one, so a host must settle the raw HDR keys for them whatever the external result.
The AV1 external keys authorize on first use: getCustomPresentationEligibilityOptions waits for the key of an AV1 HDR stream’s transfer before eligibility, through WebGPUPresenter.waitForExternalHDRRouteAuthorization.
The Dolby Vision keys a source needs on first use (Profile 4 in any format, or Profile 7 or single-layer reconstruction in a format other than I420P10) must settle before eligibility.
Interlaced video
Every route but Dolby Vision’s presents interlaced video through the deinterlacing pass (see Architecture), so eligibility refuses interlaced video only where no route can present it, as interlaced-video-unsupported:
IsInterlacedtrue on HEVC, which codes interlaced video as field pictures, and on JPEG 2000, which codes a codestream per field: no decoder weaves their fields into frames, and the pass deinterlaces frames whose rows alternate between the fields. The check runs before route selection, and the prefilter declines the same streams.- A Dolby Vision route, whose profiles are progressive and which never deinterlaces, unless
IsInterlacedis false. A Dolby Vision stream whose declared base layer takes an ordinary route presents through that route’s deinterlacing.
H.264, MPEG-2, MPEG-4 Part 2, VC-1, and WMV3 decode interlaced video into woven frames, so they are eligible whatever IsInterlaced says, and a missing or malformed IsInterlaced is an unknown scan, which each frame’s own fields settle.
MPEG-1, H.263, MS-MPEG4, VP8, VP9, and AV1 have no interlaced coding.
presentsInterlacedVideo(codec) exports the per-codec answer, so a host’s negotiation offers interlaced streams of exactly those codecs, outside its Dolby Vision labels.
| Route | Interlaced video | Field order from |
|---|---|---|
Native VideoFrame (external-texture, external-hdr) | Deinterlaced; the browser upsampled its chroma progressively | The container’s scan, else the host’s FieldOrder and IsInterlaced |
Raw planes (raw-yuv) | Deinterlaced, 4:2:0 chroma read within each field | As the native route; an H.264 raw format variant decodes through the same sample path as native H.264, so its frames carry the same fields |
GPU (frame-pool) | Deinterlaced, 4:2:0 chroma read within each field | The decoder’s reportPictureFields, else its parser’s parser_picture_fields, else as the native route |
Dolby Vision (external-dolby-vision, raw-dolby-vision) | Refused unless IsInterlaced is false | None |
Route keys
- Raw:
<I420P10..I444P12>:bt2020-ncl:bt2020:limited:<pq|hlg>and<I420..I444P12>:<matrix>:<primaries>:<full|limited>:sdr, with every matrix (bt709,bt470bg,smpte170m,bt2020-ncl) and primaries set (bt709,bt470bg,smpte170m,bt2020). The HDR keys and the BT.709 SDR keys are prewarmed; every other SDR key authorizes on first use, and its vector doubles the chroma excursion so the gamut conversion moves its samples beyond the tolerance. - External HDR:
external-hevc-main10-bt709-limited:<pq|hlg>-v1andexternal-av1-main10-bt709-limited:<pq|hlg>-v1, each with its own codec’s vector, authorize on first use, since they are the only keys whose check decodes a frame. The two HEVC keys start together, sharing one decode, when an HDR negotiation waits for them (waitForExternalHDRAuthorizationPrewarm) or a playback that requests the external HDR route starts them (prewarmExternalHDRPresentationAuthorization); an AV1 key starts when its route is first used. Device creation prewarms only the raw and Dolby Vision keys named prewarmed here. - Dolby Vision raw, each per BL format from I420 to I444P12:
<format>:dovi-rpu-v1,<format>:dovi-profile4-base-v1,<format>:dovi-profile4-fel-v1,<format>:dovi-profile7-base-v1, and<format>:dovi-profile7-fel-v1. OnlyI420P10:dovi-rpu-v1and the I420P10 Profile 7 keys are prewarmed; the others authorize on first use. - Dolby Vision external:
external-I420P10-bt709-limited:dovi-p5-rpu-v1, prewarmed, andexternal-I420P10-bt709-limited:dovi-p8-rpu-v1, which only AV1 P10 uses and which authorizes on first use; both check the same shader with their profile’s synthetic RPU.
Audio routes
-
Track:
DefaultAudioStreamIndex, else the first audio stream by index. The worker selects it by its ordinal among the container’s audio tracks, which skips the streams Jellyfin lists for external files (IsExternal), as the video ordinal does. A selected external audio stream isaudio-track-external, since only the server can mux an external file in. Codec aliases: DCA is dts, EC-3 is eac3, TRUE-HD is truehd; WMA takes FFmpeg’s decoder names, wmav1, wmav2, wmapro, wmalossless, and wmavoice. -
Order: an E-AC-3 or TrueHD stream whose Jellyfin
Profilenames Atmos takes the first offered package that declaresrenderChannelMaskwhen the host’saudioRenderTargetneeds its objects: a height speaker layout, a floor wider than the declared channels (7.1 from a 5.1 core), or binaural headphones on a stereo layout; the declared layout must still pass the codec’s input routes, and the eligibility setsaudioRendersObjects. Otherwise the native-media MSE bridge for AC-3 and E-AC-3 (2 or 6 channels, exactly 48 kHz) comes first. Otherwisedecoded-pcm: through the engine’s own decoders when the codec capability and the input layout qualify, the browser’s for WebCodecs codecs and Mediabunny’s for PCM. A codec no engine decoder supports, AC-3, E-AC-3, DTS, TrueHD, MLP, and WMA among them, takes the first of the host’s audio decoder packages whose manifest accepts it. -
Input channels, as declared by Jellyfin. The decoded format is authoritative at runtime, and any decoded layout maps to the output by channel name:
Codec Channels PCM 1, 2, 3, 6 (3 needs a 3.0layout)aac, flac, opus, vorbis 1, 2, 3, 6, 8 (3 needs a 3.0layout, or2.1for FLAC, laid out by its mask; 6 needs the codec’s 5.1 verdict and 8 its 7.1 verdict)ac3 1, 2, 5, 6 (2/1 and 3/0 both arrive as 3.0, and no evidence covers either; 5 is 3/2,5.0(side))eac3 1, 2, 6, 8 mp3 1, 2 dts 1, 2, 3, 5, 6, 7, 8 (4.1 and 5.0 arrive with 5, 6.1 with 7) truehd 1, 2, 6, 8 (8 only at 48 kHz) mlp 1, 2 wmav1, wmav2 1, 2 wmapro, wmalossless 1, 2, 3, 6, 8 wmavoice 1 Jellyfin keeps only the part of FFmpeg’s layout name before
(, so3.0(back)arrives as3.0and7.1(wide)as7.1. A package reports each block’s speaker mask, so the mask, not the declared layout, tells them apart at runtime: a DTS 3.0(back) plays with its back center on the surrounds, and a mask with no output layout fails the attempt asdecode-failed, which renegotiates. DTS-ES’s XCh and a lossless 6.1 bed decode to the WAVE 6.1 mask, which plays with its back center split across the surround or back pair; a DTS 4.1 plays the same way, and a mask with no layout, such as 4.0, fails the attempt. Seven-channel WMA Pro and WMA Lossless still have no route, since their input table leaves 6.1 out. An 8-channel AAC, Opus, FLAC, or Vorbis track declares no mask, so it lays out by its count as WAVE 7.1 (CUSTOM_SEVEN_POINT_ONE_CHANNEL_LAYOUT), the order its 7.1 probe proved. FFmpeg’s encoder writes 7.1 AAC as AudioSpecificConfig channelConfiguration 7, which FFmpeg’s decoder, and so Chrome’s, reads as 7.1 with back and side pairs, not the specification’s 7.1(wide); either reaches the engine as7.1. -
DTS and TrueHD play from Matroska and from MP4, M4V, and MOV (the
DTS,dtsc,dtsh,dtsl, andmlpasample entries); MLP plays only from Matroska. -
WMA plays from Matroska
A_MS/ACMtracks and AVI and ASF streams, which Mediabunny leaves codec-less: the worker reads the WAVEFORMATEX (demux/WaveFormatAudioConfiguration.ts), names the codec by its format tag, and refuses a track whose tag names another codec than the package decodes it as. The package configures with that WAVEFORMATEX before its first packet (see Decoder packages). -
Rates: any positive integer, resampled to 48 kHz. Past 192 kHz the resampler’s kernel widens in proportion, so its band edge stays as sharp as at 192 kHz. A browser decoder judges its own rates per item, and a rate it refuses falls back.
-
Output layout (
audio/NativeMultichannelAudioOutput.ts):selectCustomAudioSpeakerLayoutresolves the host’s speaker layout setting ondestination.maxChannelCount:autotakes 7.1.4 from 12 channels, 7.1.2 from 10, 7.1 from 8, 5.1 from 6, else stereo; an explicit stereo, 5.1, 7.1, 5.1.2, 5.1.4, 7.1.2, or 7.1.4 needs a destination with its channels, else resolves asauto.selectCustomAudioOutputLayoutthen gives a source a package renders as objects the whole speaker layout, and every other source the floor rule within the layout’s floor: three channels, 5.0, and 5.1 take 5.1, 6.1 and 7.1 take 7.1 or fold into 5.1, everything else downmixes to stereo with the selected algorithm. A host may force stereo. A live layout switch applies the same rule to the decoded layout. The output plays 2, 6, 8, 10, or 12 channels in WAVE order (5.1 in Web Audio’s order); a decoded layout maps by channel name, a 5.0 bed fills 5.1 without its LFE and mixes to stereo as that 5.1, a height the output lacks folds into the floor pair beneath it, and a height layout mixes to stereo through its folded floor.
Gotchas
- Native decode uses the hint its probes measured (
getCustomDecodeHardwareAcceleration). Only the routes that present the decoder’s opaque hardware output (native external HDR, the native Dolby Vision base, and external Profile 5) prefer hardware. Raw-plane AV1 and VP9 prefer software, because Chromium’s hardware decoders return opaque 10-bit surfaces, and so does raw-plane H.264, whose profiles beyond High Chromium reports unsupported withprefer-hardware. Every other native route usesno-preference, so a codec without a hardware decoder, such as VP8 in Chromium on Windows, decodes in software. One exception: 10-bit SDR HEVC has no probe of its own. The native HDR HEVC probe gates it withprefer-hardware, while the SDR route requestsno-preference. Chromium decodes HEVC only in hardware, so both resolve to the same decoder. - Eligibility checks no audio profile: a stream a package cannot decode, such as DTS-UHD in a DTS package, fails its attempt at runtime as
decode-failed. - A raw SDR stream outside BT.709 primaries converts its gamut to BT.709 in the raw shader (
builtin.sdr-gamut), as the frame-pool route does, while the native VF-SDR route leaves the conversion to the browser.
HEVC and Dolby Vision support
This chapter says which HEVC and Dolby Vision sources, AV1 Profile 10 included, the custom pipeline plays, by which route, and on what evidence, and which AV1 sources fall back to a decoder package.
It is the engine’s support matrix: what getCustomPlaybackEligibility accepts and the route it selects, whatever the host.
A host may restrict it further; the Jellyfin plugin’s book has the matrix of what Jellyfin negotiates for each row.
It comes from the source and the unit tests, not from playback.
test/capability/CustomPlaybackEligibility.test.ts covers route selection, and the Jellyfin plugin’s support matrix tests assert every row, with its fallbacks, through getCustomPlaybackEligibility.
Read every “Yes” as conditional:
- the evidence listed for at least one of the row’s routes must pass on the running browser and GPU;
- the host must pass the route flags and authorized keys of
CustomPlaybackEligibilityOptionsfor that evidence.
HEVC is eligible only in the containers that capability/CustomContainerCodecSupport.ts pairs with it (MP4, M4V, MOV, Matroska, MPEG-TS, M2TS, MTS).
Resolution, level, frame rate, and bitrate are never gates.
The route names are defined in Routes, and the probe and authorization names in Evidence.
Every HEVC row also falls back last to GPU, when the host offers a GPU decoder package whose header accepts the stream: the plain and range extension rows, and a Dolby Vision row’s declared base. A Dolby Vision row with an RPU route also falls back to GPU-DV, RPU reconstruction from the package’s frame pool, after its native and raw routes and its declared base’s native and raw routes, and before that base’s own GPU route; the authorization is the raw RPU route’s key of the BL format.
Plain HEVC
| Variant | Eligible | Route, then fallback | Evidence |
|---|---|---|---|
| SDR, Main, 8-bit | Yes | VF-SDR native | video.hevc or nativeUltraHDVideo.hevc |
| SDR, Main 10, 10-bit | Yes | VF-SDR native, then RAW I420P10 SDR | VF-SDR: nativeHDRHEVC. RAW: rawHDRVideo.hevc and I420P10:bt709:bt709:<limited or full>:sdr |
| PQ, with or without HDR10+ metadata, Main 10, 10-bit | Yes | VF-PQ, then RAW I420P10 PQ | VF: nativeHDRHEVC and ext-pq. RAW: rawHDRVideo.hevc and I420P10:bt2020-ncl:bt2020:limited:pq |
| HLG, Main 10, 10-bit | Yes | VF-HLG, then RAW I420P10 HLG | VF: nativeHDRHEVC and ext-hlg. RAW: rawHDRVideo.hevc and I420P10:bt2020-ncl:bt2020:limited:hlg |
The SDR rows cover every transfer the engine reads as SDR: BT.709, SMPTE 170M, sRGB, and the BT.2020 10 and 12-bit transfers, which use the BT.709 curve. They take BT.709, BT.601 (SMPTE 170M, SMPTE 240M, or BT.470 BG), or BT.2020 primaries and matrix. The VF-SDR routes leave the color conversion to Chrome, while the raw SDR routes convert a gamut other than BT.709 themselves, through the raw SDR key of the stream’s matrix, primaries, and range, which authorizes on first use outside BT.709. Jellyfin’s HDR10+ flag does not change these rows: HDR10+ metadata applies only on a PQ transfer, so an SDR or HLG stream that Jellyfin flags as carrying it takes its SDR or HLG row, and its metadata is ignored.
The HLG row includes HLG-compatible streams, whose VUI signals the BT.2020 10-bit transfer and whose alternative transfer characteristics SEI names HLG (see Evidence).
Range extensions
| Variant | Eligible | Route | Evidence |
|---|---|---|---|
| SDR, any of the 9 variants | Yes | RAW in the variant’s exact format, SDR | hevcRangeExtensions[variant] and <format>:<matrix>:<primaries>:<limited or full>:sdr |
| PQ or HLG, the 6 variants of 10 or 12 bits | Yes | RAW in the variant’s exact format, PQ or HLG | hevcRangeExtensions[variant] and <format>:bt2020-ncl:bt2020:limited:<pq or hlg> |
| BitDepth omitted but PixelFormat present | Yes: the depth is read from PixelFormat | RAW in the variant’s exact format | As the matching row |
Monochrome (gray, gray10le, gray12le) | Only on GPU | GPU in the 4:2:0 format of its depth, I420, I420P10, or I420P12, whose chroma the decoder writes neutral | A GPU decoder package whose header declares monochrome, as the HEVC decoder project’s does; no native evidence |
| A PixelFormat that contradicts BitDepth | No: codec-unsupported, or for HDR hdr-codec-unsupported, or hdr-presentation-unavailable when the raw HDR key is not authorized | None | None |
The PixelFormat must be exact, and an explicit BitDepth must agree with it.
| Variant | PixelFormat | Raw format | Named alias |
|---|---|---|---|
| rext420-8 | yuv420p | I420 | None |
| main422-8 | yuv422p | I422 | Main 4:2:2 10 |
| main444-8 | yuv444p | I444 | Main 4:4:4 |
| rext420-10 | yuv420p10le | I420P10 | None |
| main422-10 | yuv422p10le | I422P10 | Main 4:2:2 10 |
| main444-10 | yuv444p10le | I444P10 | Main 4:4:4 10 |
| main12-420 | yuv420p12le | I420P12 | Main 12 |
| main422-12 | yuv422p12le | I422P12 | Main 4:2:2 12 |
| main444-12 | yuv444p12le | I444P12 | Main 4:4:4 12 |
Dolby Vision
A variant is written as the Dolby Vision profile and compatibility ID (CCID), then the HEVC profile. Main 10 at 10 bits unless stated. The engine reads the Dolby Vision configuration and the color fields; it ignores VideoRange and VideoRangeType.
| Variant | Eligible | Route, then fallback | Evidence |
|---|---|---|---|
| P5, any CCID or none | Yes | VF-DV5, then RAW-DV P5 | VF: nativeDolbyVisionHEVC and dovi-p5. RAW: rawHDRVideo.hevc and dovi-rpu |
| P7, CCID 1 or 6, MEL or FEL, with or without the EL | Yes | DV base PQ, then RAW-DV7, then the declared PQ base through RAW | Base: nativeHDRHEVC, ext-pq, and exact BT.2020 PQ base metadata. RAW-DV7: rawHDRVideo.hevc and dovi-p7-base; no decoder decodes the EL |
| P7, any other CCID or none | Yes | RAW-DV7 only; the CCID declares no base | As RAW-DV7 above |
| P8, CCID 1 or 6, with or without an EL flag | Yes | DV base PQ, then RAW-DV P8, then the declared PQ base through RAW | Base: as for P7. RAW-DV: rawHDRVideo.hevc and dovi-rpu |
| P8, CCID 2 | Yes | RAW-DV P8, then the declared SDR base through VF-SDR native | RAW-DV: as P8 with CCID 1. VF-SDR: nativeHDRHEVC |
| P8, CCID 4 | Yes | DV base HLG, then RAW-DV P8, then the declared HLG base through RAW | Base: nativeHDRHEVC, ext-hlg, and exact limited BT.2020 HLG base metadata. RAW-DV: as P8 with CCID 1 |
| P8, CCID 0, reserved, or none, or an explicit ColorTransfer that contradicts the CCID | Yes | RAW-DV P8 only | As P8 with CCID 1 |
| P4, CCID 2, MEL or FEL, with or without the EL | Yes | RAW-DV4, then the declared SDR base through VF-SDR native | RAW-DV4: rawHDRVideo.hevc and dovi-p4-base; no decoder decodes the EL. VF-SDR: nativeHDRHEVC |
| P20 MV-HEVC, CCID 1, 2, 4, 6, or reserved | Yes | RAW-DV P8 on the base view, then the declared base through the static routes | As P8 with CCID 1 |
| P20, CCID 0 or none | Yes | VF-DV5 on the base view, then RAW-DV P5 | As P5 |
| P5, P8, or P20 over Rext or a named alias | Yes | RAW-DV in the variant’s exact format, then the declared base through RAW in that format | hevcRangeExtensions[variant] and <format>:dovi-rpu-v1 |
| P4 or P7 over Rext or a named alias | Yes | RAW-DV4 or RAW-DV7 with the BL in the variant’s exact format, then the declared base through RAW in that format | hevcRangeExtensions[variant] and <format>:dovi-profile4-base-v1 or <format>:dovi-profile7-base-v1; no decoder decodes the EL |
| P4, P5, P7, P8, or P20 over 8-bit Main | Only on GPU, or with a declared base | GPU-DV in I420, then the declared base through the ordinary routes, VF-SDR native for an SDR base; no native route reads 8-bit Main raw planes | GPU-DV: a package the host offers in I420 and I420:dovi-rpu-v1 (or the I420 dual-layer base key) |
Dolby Vision over AV1
Profile 10 is single-layer: the RPU travels in an ITU-T T.35 metadata OBU of each temporal unit, and the base layer is AV1 in any profile and depth. VF-DV5 and VF-DV8 here decode Main at 10 bits in hardware through the engine’s own AV1 path, every sequence header neutralized to limited BT.709, and reconstruct from the external frames; RAW-DV decodes natively in software, into I420P10 for Main at 10 bits, or into the exact format of an AV1 raw format variant (see AV1 outside Main 10). Every AV1 track takes that path, whatever its route, so the declared base of a stream with HDR10+ metadata applies its HDR10+ metadata OBUs too (see Decisions). Only an RPU route loads the RPU parser; any other route removes the RPU OBUs without parsing them.
| Variant | Eligible | Route, then fallback | Evidence |
|---|---|---|---|
| P10, CCID 0 (an IPT base) | Yes | VF-DV5, then RAW-DV P5; the CCID declares no base | VF: nativeHDRAV1, external-av1-main10-bt709-limited:pq-v1, and dovi-p5. RAW: rawHDRVideo.av1 and dovi-rpu |
| P10, CCID 1 | Yes | VF-DV8, then RAW-DV P8, then the declared PQ base through VF-PQ where the AV1 PQ key is authorized, else RAW I420P10 PQ | VF-DV8: nativeHDRAV1, external-av1-main10-bt709-limited:pq-v1, and dovi-p8. RAW-DV: as P10 with CCID 0. Base: nativeHDRAV1 and external-av1-main10-bt709-limited:pq-v1, or rawHDRVideo.av1 and I420P10:bt2020-ncl:bt2020:limited:pq |
| P10, CCID 2 | Yes | VF-DV8, then RAW-DV P8, then the declared SDR base through RAW I420P10 SDR | As P10 with CCID 1; base: rawHDRVideo.av1 and the I420P10 SDR key of its color |
| P10, CCID 4 | Yes | VF-DV8, then RAW-DV P8, then the declared HLG base through VF-HLG where the AV1 HLG key is authorized, else RAW I420P10 HLG | As P10 with CCID 1; base: nativeHDRAV1 and external-av1-main10-bt709-limited:hlg-v1, or rawHDRVideo.av1 and I420P10:bt2020-ncl:bt2020:limited:hlg |
| P10, CCID 6, a reserved CCID, or an explicit ColorTransfer that contradicts the CCID | Yes | VF-DV8, then RAW-DV P8; CCID 6 then falls back to its PQ base | As P10 with CCID 1 |
| P10 without a CCID | Yes | VF-DV5, then RAW-DV P5; the stream declares no base | As P10 with CCID 0 |
| P10 at 8 bits, or outside the Main profile | Yes | RAW-DV in the variant’s exact format, as P10 at 10 bits; then a declared base through its ordinary routes, native VF-SDR for 8-bit Main | av1RawFormats[variant] and <format>:dovi-rpu-v1, which authorizes on first use |
The external routes present 8-bit-quantized frames, as every external route does, so their reconstruction differs from RAW-DV by a few code values, more where the RPU’s mapping is steep.
The engine maps the MP4 dav1 sample entry of a 10.0 stream to AV1 itself (see Decisions).
Dolby Vision outside HEVC and AV1:
- Profile 9 (AVC, 8-bit) has no RPU route, because the engine owns no AVC decode path. Its declared SDR base plays through the H.264 routes.
- The retired Profiles 0 to 3 and 6 have no RPU route and present only a declared base.
AV1 outside Main 10
Native routes decode AV1 Main at 4:2:0: VF-SDR at 8 bits, and at 10 bits VF-PQ and VF-HLG where the AV1 external keys are authorized, RAW I420P10 through the raw SDR keys, and RAW I420P10 PQ or HLG.
AV1 outside Main 10 takes raw planes in the exact format of its raw format variant, which its Jellyfin Profile and PixelFormat name, through av1RawFormats[variant], a 192x192 key and inter temporal unit decoded with prefer-software.
Every AV1 stream falls back last to GPU, in its own format, when the host offers an AV1 decoder package whose header accepts it.
The AV1 package takes pictures up to level 6.x, 16384x8704 and 35,651,584 samples, 8K included, on a device whose largest binding holds half its frame store and its packet ring: at 8192x4352 two buffers of 535 MB and a ring of 626 MB at 4:2:0 10-bit, two of 1.07 GB and a ring of 1 GiB at 4:4:4 12-bit (see WebAssembly build); eligibility skips it with the failed check otherwise.
| Variant | Eligible | Route, then fallback | Evidence |
|---|---|---|---|
Main at 8 bits (main420-8) | Yes | VF-SDR native; I420 only for a Dolby Vision RPU route | av1RawFormats['main420-8'] for RAW-DV |
High: 4:4:4 at 8 or 10 bits (high444-8, high444-10) | Yes | RAW in I444 or I444P10, then GPU | av1RawFormats[variant] and the raw key of the format and color |
Professional: 4:2:2 at 8 or 10 bits, or 12 bits (professional422-8, professional422-10, professional420-12, professional422-12, professional444-12) | Yes | RAW in I422, I422P10, I420P12, I422P12, or I444P12, then GPU | As High |
| Main at 10 bits without its raw route, such as HDR without the raw HDR key | Only on GPU | GPU in I420P10; HDR needs allowFramePoolHDR | The host’s GPU decoder package |
| Main 4:0:0 | Yes | As Main 4:2:0, then GPU in the 4:2:0 format of its depth | As Main 4:2:0; GPU only on a package whose header declares monochrome |
| Professional 4:0:0 at 12 bits | Only on GPU | GPU in I420P12 | A GPU decoder package whose header declares monochrome |
Chrome’s software AV1 decoder decodes and copies the 8 and 10-bit variants, and refuses 12-bit AV1 at decode, so the 12-bit variants read unsupported there and take GPU.
hasPotentialCustomPlaybackVideoRoute keeps AV1 outside Main 4:2:0 potential for these routes.
On GPU the decoder receives the track’s av1C (see Codec private data).
H.264 beyond 8-bit 4:2:0
Native H.264 decodes Constrained Baseline, Baseline, Main, and High at 8-bit 4:2:0 to VF-SDR, each profile through its own h264Profiles probe.
A profile beyond High takes raw planes in the exact format of its raw format variant through h264RawFormats[variant]: an I, a P, then a B access unit at 192x192, decoded with prefer-software, whose B frame must output before the P frame.
The profile must code the variant’s chroma format: High 10 and High 10 Intra code 4:2:0, High 4:2:2 and High 4:2:2 Intra up to 4:2:2, and High 4:4:4 Predictive, High 4:4:4 Intra, the removed High 4:4:4, and CAVLC 4:4:4 up to 4:4:4.
The PixelFormat names the variant, and FFmpeg’s yuvj names of a full-range 8-bit stream count as their yuv formats.
No GPU decoder package decodes H.264, so a variant without its capability or raw key is refused.
| Variant | Eligible | Route | Evidence |
|---|---|---|---|
10-bit 4:2:0 (high420-10) | Yes | RAW in I420P10: SDR, and PQ or HLG through the raw HDR keys | h264RawFormats['high420-10'] and the raw key of the format and color |
4:2:2 at 8 or 10 bits (high422-8, high422-10) | Yes | RAW in I422 or I422P10; HDR at 10 bits only | h264RawFormats[variant] and the raw key of the format and color |
4:4:4 at 8 or 10 bits (high444-8, high444-10) | Yes | RAW in I444 or I444P10; HDR at 10 bits only | As 4:2:2 |
| 8-bit 4:2:0 under a profile beyond High, such as x264’s lossless High 4:4:4 Predictive | No: codec-unsupported | None | No variant |
9, 12, or 14 bits, 4:0:0, or RGB (gbrp) | No | None | No variant |
Chrome’s software H.264 decoder is FFmpeg’s, which outputs the same planes as the reference decode the vectors pin, while prefer-hardware reports these profiles unsupported.
The worker decodes them through Mediabunny’s sample path, as native H.264, which orders B frames, reads avcC or Annex B as the container gives it, and takes the presentation times the fork’s AVI demuxer gives each picture.
Interlaced video
The deinterlacing pass presents interlaced frames on every route but Dolby Vision’s (see Architecture and Eligibility and routes). This table covers every codec, beyond this chapter’s HEVC and Dolby Vision.
| Codec | Interlaced | Route | Each frame’s fields |
|---|---|---|---|
| H.264 (PAFF field pairs and MBAFF) | Yes | VF-SDR native, or RAW for a raw format variant, deinterlaced; the browser upsampled VF-SDR’s chroma progressively | The container’s scan, else the host’s FieldOrder and IsInterlaced |
| MPEG-2, field and frame pictures, at 4:2:0, 4:2:2, and 4:4:4 | Yes | GPU, deinterlaced, 4:2:0 chroma read within each field | The decoder’s reportPictureFields, per picture, from progressive_frame, top_field_first, and repeat_first_field as FFmpeg reads them |
| VC-1 Advanced, field and frame interlace | Yes | GPU, deinterlaced, 4:2:0 chroma read within each field | The parser’s parser_picture_fields, per picture, from FCM, TFF, and RFF as FFmpeg reads them |
| MPEG-4 Part 2 interlaced VOPs | Yes | GPU, deinterlaced, 4:2:0 chroma read within each field | The parser’s parser_picture_fields, per VOP, from interlaced and top_field_first as FFmpeg reads them |
| HEVC | No: interlaced-video-unsupported | None; field pictures need a weave the engine lacks | None |
| JPEG 2000 | No: interlaced-video-unsupported | None; a codestream per field needs a weave the engine lacks | None |
| Any codec on a Dolby Vision route | Only when IsInterlaced is false | The Dolby Vision routes never deinterlace; a declared base layer’s ordinary route does | None |
A stream whose metadata calls it interlaced but whose frames report progressive presents them untouched, and a frame without any fields takes the stream’s scan. The decoder’s and the parser’s reports override the container’s: an MPEG-2 or VC-1 stream whose Matroska header calls it progressive still deinterlaces each picture it reports interlaced.
Rejected
| Variant | Reason |
|---|---|
| No color metadata | metadata-unsupported |
| A Dolby Vision descriptor without Dolby Vision configuration fields | metadata-unsupported |
| Dolby Vision over 8-bit Main without a declared base or an offered GPU decoder package | hdr-codec-unsupported: no native route reads 8-bit Main raw planes |
| A Dolby Vision configuration without an integer profile, without the base layer flag, or with a CCID that is not a 4-bit integer; several video tracks other than a separate P7 pair | video-track-unavailable |
Routes
A route is the eligibility output: videoOutputMode, videoDecoderBackend, rawVideoFrameFormat, dolbyVisionProfile, and nativeHDRTransfer, plus gpuVideoDecoder on GPU.
The host maps it to a presenter input mode.
| Route | Eligibility output | Input mode | Notes |
|---|---|---|---|
| VF-SDR | video-frame; native | external-texture, identity | |
| VF-PQ, VF-HLG | video-frame; native; HEVC SPS and VUI, or AV1 sequence header, codecs string, and av1C, neutralized to BT.709 primaries and matrix with the sRGB transfer; transfer pq or hlg | external-hdr | Chrome samples external textures at 8 bits per channel. PQ also applies static mastering metadata. HEVC Main 10 and AV1 Main at 10 bits, each with its own keys |
| DV base PQ, DV base HLG | As VF-PQ or VF-HLG, with dolbyVisionProfile null | external-hdr | P7 and P8 only. RPU and EL discarded. Preferred over RAW-DV whenever authorized |
| Declared base | An ordinary route, with dolbyVisionProfile null | As that route | Used only when no RPU route is selected. RPU and EL discarded |
| VF-DV5 | video-frame; native; dolbyVisionProfile 5 | external-dolby-vision | Per-frame Profile 5 RPU, also for a P20 with CCID 0 or none, and for an AV1 P10 that reconstructs as P5 |
| VF-DV8 | video-frame; native; dolbyVisionProfile 8 | external-dolby-vision, Profile 8 | AV1 P10 only: per-frame Profile 8 RPU over the neutralized base, whatever base its CCID declares |
| RAW | raw-planes in the exact format; native (WebCodecs copyTo) | raw-yuv | Also 10-bit SDR, through the raw SDR key of its color; SDR outside BT.709 primaries converts its gamut to BT.709 (builtin.sdr-gamut). AV1 PQ also applies the static mastering metadata of its MDCV and CLL metadata OBUs |
| RAW-DV | raw-planes in I420P10 for HEVC Main 10 and AV1 Main 10, or a range-extension or AV1 raw format variant’s exact format; dolbyVisionProfile 5 or 8 | raw-dolby-vision | RPU reconstruction. A P10 or P20 reports the profile it reconstructs as. A signaled EL is discarded |
| RAW-DV7, RAW-DV4 | raw-planes with the BL in I420P10 or in a range-extension variant’s exact format; dolbyVisionProfile 7 or 4 | raw-dolby-vision, Profile 7 or 4 | No decoder decodes the EL, so MEL reshapes exactly from the BL and FEL presents its base at the BL’s depth: the HDR10 base for P7, and the SDR base exactly for P4 (limited BT.709, no tone mapping or dither). The FEL composition, which needs the FEL key and a paired I420P10 EL frame, waits for an EL decoder |
| GPU | frame-pool in the stream’s own format; gpu, with gpuVideoDecoder naming the package | frame-pool | The last fallback, through a GPU decoder package the host offers. No native color handling; static HDR needs the host’s allowFramePoolHDR |
| GPU-DV | frame-pool in the RPU route’s BL format; gpu, with gpuVideoDecoder; dolbyVisionProfile 4, 5, 7, or 8 | frame-pool-dolby-vision | RPU reconstruction of the package’s BL slots, the raw route’s reconstruction over the frame pool’s read, gated by the raw route’s base key and flags. No frame pool holds an EL, so P4 and P7 reconstruct from their BL as RAW-DV7 and RAW-DV4 do |
Eligibility and routes gives the order in which they are tried.
HDR10+ dynamic metadata is applied on VF-PQ, DV base PQ, RAW PQ, and GPU PQ, and on the RPU routes (VF-DV5, VF-DV8, RAW-DV, and GPU-DV) to a frame whose RPU has no L1. HEVC carries it in SEI, and AV1 in ITU-T T.35 metadata OBUs, whose trailing bits the engine removes as dav1d does: it drops the trailing zero bytes, then the byte holding the trailing one bit, which must be 0x80, or the frame’s metadata is malformed. VP9 has no metadata of its own, so Matroska and WebM carry each frame’s ITU-T T.35 message beside it in a BlockAdditional, and VP9 in MP4 carries none. The engine recognizes the message by its T.35 header whatever its BlockAddID, so WebM, which has no BlockAdditionMapping, reads like Matroska, which maps FFmpeg’s BlockAddID 4 to the ITU-T T.35 type. A frame’s scene peak and average drive every tone map operator; only the ST 2094-40 operator follows a frame’s Bezier curve, adapted from its targeted display, and without a curve it builds libplacebo’s brightness-matching curve from the scene’s knee. A frame without a curve, such as every profile A frame, never has its targeted display read. A frame without HDR10+ metadata of its own, or whose payload fails to parse, takes the last metadata before it in decode order, as in FFmpeg (see Decisions). Conflicting metadata, unsupported metadata, and a curve with a targeted display of 0 tone-map their frame statically and end the carried metadata until the next valid payload. Each decode attempt and each seek starts without carried metadata.
An RPU route tone-maps each frame from its RPU, as libplacebo ranks the metadata: from its L1 scene peak and average, else from HDR10+, else statically from the peak of the mastering display the RPU names (source_max_pq), which an RPU without display management metadata leaves at FFmpeg’s default of 4000 nits.
An L1 whose maximum or average is 0, as an RPU whose CM v2.9 section codes no L1 presents, counts as none.
Evidence
Probes, all in capability/CustomDecodeCapabilities.ts:
| Probe | Passes on |
|---|---|
video.hevc | 1080p Main decode output |
nativeUltraHDVideo.hevc | 4K Main decode output |
nativeHDRHEVC | 4K Main 10 decode output |
nativeHDRAV1 | The raw probe’s 4K AV1 Main 10 keyframe decoded with prefer-hardware |
nativeDolbyVisionHEVC | The hev1.2.4.H150.B0 configuration plus Main 10 decode |
rawHDRVideo.hevc | An I420P10 copyTo fingerprint |
rawHDRVideo.av1 | A 4K AV1 Main 10 keyframe decoded with prefer-software and its I420P10 copyTo fingerprint |
hevcRangeExtensions[variant] | The exact configuration, the two-frame vector, the exact copy format, and pinned fingerprints |
av1RawFormats[variant] | The exact configuration, the two-temporal-unit vector, the exact copy format, and pinned fingerprints |
h264RawFormats[variant] | The exact configuration, the three-access-unit vector, the exact copy format, and pinned fingerprints in presentation order |
GPU has no probe: a package’s own vectors run only when the host verifies it (see Decoder packages).
GPU authorizations, all in validation/:
| Name | Route key | File |
|---|---|---|
ext-pq | external-hevc-main10-bt709-limited:pq-v1, authorized on first use, with ext-hlg | ExternalHDRPresentationAuthorization.ts |
ext-hlg | external-hevc-main10-bt709-limited:hlg-v1, authorized on first use, with ext-pq | ExternalHDRPresentationAuthorization.ts |
ext-av1-pq, ext-av1-hlg | external-av1-main10-bt709-limited:<pq|hlg>-v1, authorized on first use | ExternalHDRPresentationAuthorization.ts |
| Raw keys | <format>:..., as written in Route keys | RawHDRPresentationAuthorization.ts |
dovi-rpu | <format>:dovi-rpu-v1, one per raw format from I420 to I444P12 | DolbyVisionPresentationAuthorization.ts |
dovi-p4-base, dovi-p4-fel | <format>:dovi-profile4-base-v1, <format>:dovi-profile4-fel-v1, one pair per BL format from I420 to I444P12 | DolbyVisionPresentationAuthorization.ts |
dovi-p7-base, dovi-p7-fel | <format>:dovi-profile7-base-v1, <format>:dovi-profile7-fel-v1, one pair per BL format from I420 to I444P12 | DolbyVisionPresentationAuthorization.ts |
dovi-p5 | external-I420P10-bt709-limited:dovi-p5-rpu-v1 | ExternalDolbyVisionPresentationAuthorization.ts |
dovi-p8 | external-I420P10-bt709-limited:dovi-p8-rpu-v1, authorized on first use | ExternalDolbyVisionPresentationAuthorization.ts |
I420P10:dovi-rpu-v1 and the I420P10 Profile 7 keys are part of the default Dolby Vision prewarm.
The Profile 4 keys in any format, and the Profile 7 and single-layer keys of other formats, authorize on first use, so a host must wait for them before it advertises or starts a stream that needs one.
The external HDR keys authorize on first use too: an HDR negotiation waits for the HEVC pair (waitForExternalHDRAuthorizationPrewarm), which shares one decode, and an AV1 HDR stream waits for its own key.
Other conditions:
- Native VideoFrame HDR and the DV base routes also need explicit ColorTransfer, ColorPrimaries, and ColorSpace values.
- The SPS parser never rejects a VUI color description. It maps each code to a WebCodecs name or to unspecified (null), and an SPS without VUI has unspecified color. Only the native HDR route check is strict: limited range, BT.2020 primaries, the BT.2020 non-constant-luminance matrix, and the route’s transfer.
- The transfer that check reads is the key access unit’s alternative transfer characteristics SEI value (payload type 147) when one is present, and the VUI transfer otherwise. Without the SEI, VF-HLG and DV base HLG also accept the BT.2020 10 and 12-bit VUI transfers (14 and 15) of HLG-compatible streams. An SEI naming another transfer rejects the HLG route, and a malformed SEI counts as absent, as in FFmpeg.
- P20 is MV-HEVC.
NAL units with a
nuh_layer_idabove 0, the second view, are dropped before decode, and SEI of other layers is ignored. - A separate-track P7 (a base track and an EL track) selects the same routes.
test/capability/CustomPlaybackEligibility.test.tscovers it. - An RPU the parser rejects ends the custom session, which then falls back.
The parser follows FFmpeg’s
dovi_rpudec.c: a display-metadata extension block that runs past the data or falls short of its level’s fields, an L2ms_weightbelow -1, or more than 7 static or 25 dynamic blocks reject the RPU, an L1 in the CM v4.0 section counts as an L1 of zeros, and unknown, other misplaced, or padded blocks are skipped. Leftover bits before the CRC32, or an AV1 EMDF wrapper whose size or protection bits FFmpeg refuses, also reject it, while a CRC32 mismatch and bytes after the 0x80 terminator do not, as in FFmpeg. It also reads what FFmpeg does not: polynomial and MMR pieces mixed within one component, polynomial linear interpolation (see Decisions), and any mapping color space or chroma format.
Not supported
- HDR10, HDR10Plus, or HLG at 8 bits, under any profile.
- Profile and bit depth contradictions: Main at 10 bits, Main 10 at 8 bits, Main 10 at 12 bits.
- 14-bit and 16-bit range extensions.
- Monochrome range extensions outside GPU.
- High Throughput 4:4:4 profiles, Screen Content Coding (
Screen-Extended) profiles, Main Still Picture, and named Intra aliases such asMain 4:4:4 10 Intra. Generic Rext with an intra constraint is the ordinary variant. - Interlaced HEVC of any profile or range: HEVC codes interlaced video as field pictures, which no decoder weaves into the frames the deinterlacing pass takes (see Interlaced video).
- Dolby Vision:
- the configurations in Rejected;
- RPU reconstruction for AVC Profile 9, and for AV1 Profile 10 in a raw format variant the browser does not qualify, such as 12 bits in Chrome;
- RPUs the parser rejects: a linear interpolation piece next to an MMR piece, an RPU format extension, missing sequence information, display metadata compression above method 1, and a header from which no profile is inferred while the container declares none.
Known issues
- Firefox on Windows has no WebCodecs HEVC (see Decisions), so HEVC plays there only through GPU.
Dolby Vision then plays through GPU-DV, Profile 5 included, when the host offers the HEVC package and authorized the RPU route’s key.
Firefox decodes 10-bit AV1 to 8-bit
BGRX, so no raw AV1 route, P10 included, qualifies there.
Module map
One entry per file or family.
[main], [worker], and [worklet] mark the thread where it is not obvious.
Each source file’s tests are at the same relative path under test/, and integration suites that span several modules sit beside their main module.
src/
EngineAssets.ts: the typed manifest of every runtime asset path, the engine’s workers, its own WebAssembly modules, and the native range extension probes’ streams, and URL resolution against the host’s asset base or, inside a worker, the worker’s own URL.EngineConfiguration.ts: the feature flags a host can set.GPUCompletion.ts:waitForGPUCompletion, through which every wait on a device’s queue work goes,onSubmittedWorkDone()or amapAsync(); it polls the device with empty submits while the wait is pending, since Firefox completes queue work only when the device is polled.MediaTime.ts: branded integer microseconds and conversions to Jellyfin ticks.TimeMath.ts: safe-integer microsecond math.TimingTrace.ts: the opt-in playback timing trace: the page’s ring buffer, the decode worker’s batches stamped on the shared epoch clock, long tasks, and the JSON export; every hook is one null check until a host starts a trace.style.css: presenter and overlay styles, imported by the host; plain CSS, so a host needs no preprocessor.
presentation/
WebGPUPresenter.ts: the GPU device and canvas; rVFC and pushed-frame submission; each route’s built-in chain, planned and recorded throughpasses/; HDR and Dolby Vision authorization; device-loss recovery; the latched fallback. It also holds the engine API for presentation: the host’s preset of user passes, live parameter changes, the plan report, HDR output for HDR sources, the deinterlacing mode, and the black bar crop. While a plan deinterlaces it shows the frame before the newest, tells the frame loop its lookahead, and draws held fields from history. While a plan holds a display-rate pass it measures the display’s refresh rate from the frame loop’s animation frames, takes frames one refresh ahead, and redraws from history at each tick’s clock time. While the black bar crop is on it reads back each new frame’s black bar statistics and fits the picture the detector’s bars leave, rounded to a standard aspect ratio near it.BlackBarDetector.ts: the black bars a presentation crops, decided frame by frame from the black bar analysis’s statistics: symmetric bars per axis, cropped at once for a letterbox or a pillarbox and else once two shots agree, given up at once for picture inside them, and cropped again only after 30 s of playback.StandardAspectRatios.ts: the film and television aspect ratios of the Detect Aspect Ratio plugin, and the rounding of a black bar crop to the nearest one its bars reach by cutting at most 0.5% of the picture or showing at most a line of bar.DisplayRefresh.ts: the display’s refresh interval, a least-squares fit of animation frame times over refresh counts, reported once it is precise and restarted when the display changes.FrameInterval.ts: the media time between a stream’s frames, frame by frame: a least-squares fit of the last 24 new frames’ timestamps over frame counts, counting skipped frames and restarted when the frame rate changes.DeinterlaceTimeline.ts: the frames a deinterlacing plan’s history holds and the draw a clock time picks: the field shown, of which frame, and its time, at double or single rate, with yadif’s passthrough of progressive frames.PresentationInput.ts: MediaStream metadata to color metadata; Dolby Vision descriptors with their RPU route; declared and exact native base metadata; the presented video ordinal; the known-SDR gate; the stream’s fields from itsFieldOrderorIsInterlacedand its size, and its declared frame rate, whichPresentationSetup.tshands the presenter.DolbyVisionGeometry.ts: the P4 and P7 EL coded size (half the BL when the BL is wider than 1920).PresentationSetup.ts: what a host calls around the eligibility, without state of its own: the play options’ presentation input, the authorizations that gate the eligibility’s presentation routes, the offers of the host’s GPU decoder packages for a stream (getGPUVideoDecoderOffers) and the factory of their sessions on the presenter’s device, the presenter’s color pipeline for the chosen route, Dolby Vision bases included, and static HDR metadata updates.PresentationGeometry.ts: pure object-fit and object-position math to viewport and texture transforms, fitting a crop of the source, the black bar crop’s, as the whole object.RenderSettings.ts: versioned (v7) HDR-to-SDR settings, the four tone map operators, and their 144-byte uniform, with the dynamic fields of HDR10+ and Dolby Vision L1.WebGPUConstants.ts: the WebGPU values presentation and GPU decode share: canvas configuration, texture formats, features, and default limits.
presentation/passes/
The presentation pipeline: each route’s fixed signal and default color track, user WGSL passes, the planner that arranges them, and the executor that records frames.
PassTypes.ts: the contracts of the pipeline: pass manifests, built-in chains, plans, and frame input.PassLanguage.ts: the names that headers, the prelude, and the planner share: the plane and image stages,HOOKED, history accessors, texture block accessors, entry points,offset: align, the prelude’s functions, and plan-time facts.PassExpression.ts: the size and condition expressions of pass and decoder headers.PassManifest.ts: parses a pass file, whose passes each start with a//!header as in mpv’s user shaders, into manifests in file order, each with the texture blocks it binds; a failure names its line in the file.ContentIdentity.ts: the content identity of a texture block, a 53-bit cyrb53 hash computed synchronously, which keys its upload.PassResources.ts: the engine-owned bindings, their WGSL declarations and layout entries, the byte layouts of the uniforms the engine writes, and a parameter value’s normalization to its type and range.PassPrelude.ts: the WGSL around pass code: input accessors, each with its input’s plan-time offset, texture block accessors, constants, the prelude’s linear light and planes transform, entry stages, and the renaming that keeps each user body’s names apart.PresentationPlanner.ts: arranges a route’s built-in chain and the user passes on the stage spine and the color track, then generates each pass’s module. A pass that cannot run is left out with a reason code and a terse detail. Without user passes every route plans one pass, which reads the source and writes the canvas. It reads the signal’s planes apart for plane passes, from the source or a deinterlaced image, merges and converts the planes they replace, and gives every image its offset and extent, which the scaler corrects.PresentationExecutor.ts: compiles a plan, owns its textures, history rings, buffers, and bind groups, binds the caller’s uploads of its texture blocks, and records each frame without submitting; a draw from history records the passes from the plan’s history draw start on, except those that read the source. It runs a plan’s black bar analysis only for a frame with a readback buffer, a workgroup per row and column of the frame’s source, and copies its statistics into that buffer.PresentationPipelineCache.ts: the passes each device keeps compiled, a bounded least recently used cache keyed by WGSL, bind group layout, and target format, which the executor compiles through.PassTextureCache.ts: the uploads of texture blocks on one device by content identity, made when a plan first binds a block and released when no plan or preset holds it.PlaneTextureSource.ts: the plane route’s ingress: layout validation that names the failed check, one integer texture per plane, and the uploads.builtin/: the built-in signals (BuiltinSignals.ts,FramePoolSignal.ts, which reads a pool slot of any output format, as its layout says, for the frame-pool route’s chain, andFramePoolDolbyVisionSignal.ts, the raw Dolby Vision reconstruction over that read), color functions (BuiltinColorFunctions.ts), and route chains (BuiltinChains.ts), the deinterlacing pass (DeinterlacePass.ts, yadif over the signal’s ring), and the black bar analysis (BlackBarAnalysisPass.ts, a compute pass over the signal that writes how many of 64 points along each row and column are lit and bright, and the sum of their signed luma). A deinterlacing plan’s raw and frame pool signals read an interlaced frame’s 4:2:0 chroma within each field. Every signal reads 4:2:0 chroma at the stream’s declared location, left where it declares none. Each signal offers its planes to plane passes: the raw and frame pool signals their decoded planes, with chroma’s siting, range, and matrix, the external signals planes derived from RGB through the stream’s own matrix and range at its chroma subsampling, and the Dolby Vision signals none. Every signal with planes also offers their transform from ‘source’ RGB and back, which deinterlacing plans and the prelude use.BuiltinColorFunctions.tsalso gives each chain its transfer function for the prelude’s linearize and delinearize, renamed from the transfer decode’s code.
The optional user passes a host may offer are pass projects in wgpup/, outside src/ (see Bundled passes); the presenter tests run some of them.
color/
ColorMetadata.ts: the input color schema, validation, and SDR, PQ, and HLG factories.ColorPipeline.ts: the CPU reference of range, matrix, transfer, gamut, tone mapping, and display controls, and the luminance, YUV matrix, gamut, and IPT tables for BT.709, BT.2020, and BT.601 that the built-in color functions share.DolbyVisionColorTransform.ts: RPU reshaping (polynomial and MMR, chosen per segment), the FEL NLQ residual, and reconstruction to BT.2020 PQ, as a CPU reference and as WGSL.
capability/
CustomContainerCodecSupport.ts: the single container and codec matrix.CustomPlaybackEligibility.ts: per-session route choice, the player-selection prefilter,hasEligibleCustomVideoRoutefor item-scoped negotiation, the codecs whose interlaced streams a route presents (presentsInterlacedVideo), and the video and audio codecs a playback decodes (getGPUVideoDecodeTarget,getPlaybackAudioCodec), which a host fetches packages for.CustomDecodeCapabilities.ts: the cached orchestrator of every capability probe, the per-item probe selection, and the codec lists, with the video codecs only a decoder package decodes (CUSTOM_PACKAGE_VIDEO_CODECS).CapabilityAssetLoading.ts: the bounded, retried download of a probe’s served vectors.CapabilityProbeStore.ts: the probe verdicts kept in localStorage across page loads, their fingerprint, the clear a failed start or a host calls, the run journal that names the steps in flight and their costs when a page stopped, the step quarantine (blocked steps, suspects, and the concurrency ceiling), and the crash event a host listens to.CapabilityProbeManager.ts: the parallel runner of every probe’s browser calls within byte and decoder limits, with negotiation priority, reservations held until a call settles, and the stop rule for a call that never settles.CapabilityProbeSession.ts: the page’s probe session, which owns the manager, sets its limits from the quarantine, and wraps every probe step (record): it blocks defect and quarantined steps with aCapabilityProbeBlock, enqueues suspects, and records admitted calls on the store.CapabilityProbeSteps.ts: the step descriptors, a label naming each browser call and its cost in decoded bytes and decoders, from the codecs’ maximum reference frames.BrowserDefects.ts: the declared table of known browser defects whose matching steps never run, and the browser identity it matches.AudioDataPlanes.ts: the copy of an AudioData in its own format, converted to float planes in JavaScript.VideoFrameFormatSupport.ts: the check that the browser holds a raw VideoFrame format, and a format’s byte length.CustomPlaybackRuntime.ts: runtime feature detection with failure reasons.H264ProfileCapabilities.ts: the per-profile H.264 decoded-output probe and Jellyfin’s profile names.HEVCRangeExtensionCapabilities.ts: the nine range-extension variant definitions and the stream metadata resolver.AV1RawFormatCapabilities.ts: the eight AV1 raw format variant definitions outside Main 10 (High 4:4:4, Professional 4:2:2 and 12 bits, and 8-bit Main for Dolby Vision) and the stream metadata resolver.H264RawFormatCapabilities.ts: the five H.264 raw format variant definitions beyond 8-bit 4:2:0 (10-bit 4:2:0, and 4:2:2 and 4:4:4 at 8 and 10 bits), the profiles beyond High that FFmpeg names and the chroma formats each codes, and the stream metadata resolver.NativeMediaAudioCapabilities.ts: the MSE AC-3/E-AC-3 route probe and its selection.NativeAudioChannelTones.ts: the channel order check of the 7.1 probes, which finds each decoded plane’s strongest source tone with a windowed Goertzel measure.
capability/vectors/
Hand-made vectors: known inputs with known answers. Qualification vectors run in the browser before a route is advertised; test vectors feed only tests and generators. The TypeScript modules are embedded in the bundle; the binary files are served or read as files.
HEVCExactCapabilityVectors.ts: the single-frame black HEVC access units the native HEVC probes decode, Main at 1080p and Main 10 at 4K.NativeMediaAudioCapabilityVectors.ts: fMP4 AC-3/E-AC-3 vectors.NativeAudioCapabilityVectors.ts: stereo silence packets per WebCodecs codec.NativeSurroundAudioCapabilityVectors.ts: 5.1 silence packets.NativeSevenPointOneAudioCapabilityVectors.ts: 7.1 packets of one sine per source channel, and each channel’s tone in WAVE 7.1 order.NativeVideoCapabilityVectors.ts: 64x64 VP8, VP9, and AV1 keyframes.NativeUltraHDVideoCapabilityVectors.ts: 4K 8-bit HEVC, VP9, and AV1 keyframes.RawHDRCapabilityVectors.ts: 4K 10-bit VP9 and AV1 keyframes with plane fingerprints.ExternalHDRAuthorizationVector.ts: the neutralized Main 10 access unit and its expected samples, for external HDR authorization.ExternalAV1HDRAuthorizationVector.ts: a lossless AV1 Main 10 BT.2020 PQ temporal unit and its exact samples, for the AV1 external HDR keys.DolbyVisionAuthorizationVector.ts: synthetic RPU snapshots for Dolby Vision authorization.HDR10PlusVectors.ts: deterministic HDR10+ HEVC access units for the dynamic HDR tests.Base64.ts: the base64 decoder the inline vectors share.qualification/: a stream that no script generates, the VC-1 stream (vc1/), which thevc1decoder project’s vector holds.test/: inputs only tests and generators read: DTS samples, a TrueHD Matroska remux, Dolby Vision RPU payloads, and an hdr10plus_tool HDR10+ stream.
pipeline/
CustomPlaybackController.ts[main]: lifecycle, generations, the clock, the startup (20 s without progress, the source bytes read before readiness counting as progress, 60 s ceiling), stall (10 s), and lag (2 s) policy, the live audio output layout switch, the end-of-stream and ended-track drains, the presentation timing counters, and the fallback disposition. It also holds the playback rate: live changes, the restart that crosses the audible range, the audio selection kept aside outside it, the presentation interval it hands the session above 1x, and the forward keyframe resync above 1x.CustomPlaybackRate.ts: the playback rates custom playback takes, 1/16x to 16x, and the audible range, 0.25x to 4x.CustomPlaybackControllerTypes.ts: states, events, fallback reasons, and dispositions.CustomPlaybackFrameLoop.ts[main]: the frame loop a host runs per playback: each animation frame hands the presenter its timestamp and the clock’s rate, passes the presenter’s measured refresh interval to the controller when it changes, then presents the controller’s current frame, taken the presenter’s lookahead ahead of the clock, and acknowledges its credit, or draws again from history when no frame is due, and a hidden page drains decoded video every 25 ms instead.CustomDecodeSession.ts[main]: the session’s one decode worker, which the first start takes from the page’s idle worker or creates, each generation takes at once while the stopped runs unwind beside it, a failure, an unacknowledged stop, or too many unwinding runs replace, anddestroyleaves idle for the page’s next session for 30 s once its runs are over;prefetchCustomDecodeSource, which starts the next session’s head read in the worker that session takes; and per generation, the frame queue, credits, raw buffer recycling, the frame-pool route’s GPU decode feed, readiness, audio-only resync epochs, the decoded source format, and an ended audio track completing a start, a resync, or a native-media stream. It also applies the playback rate to native-media audio bridges and scales the decoded PCM fill threshold by it, and keeps the video presentation interval for every later start.CustomDecode.worker.ts[worker]: one current run, which a start replaces at once while the stopped run unwinds beside it, each ending withstopped: demux, decoder dispatch, raw copy, Dolby Vision and HDR metadata, decoded PCM as restartable audio attempts in its audio decode worker, fMP4 remux, and credit waits; between runs, the source prefetch and its release. On the frame-pool route it posts compressed packets, or the records of the package’s parser, with their HDR10+ state, and the track’s codec private data onready; after a parser’s recoverable failure it posts held packets up to the next key packet and restarts the parser there. Above 1x it decimates video by the presentation interval, skips owned HEVC pictures that can never present, and resyncs video forward to the next keyframe onresync-videowithkeyframe: 'next'.CustomAudioDecode.worker.ts,AudioDecodeWorkerRuntime.ts[worker]: the audio decode worker, which a decode worker spawns for its decoded PCM runs and keeps for its life, or until it is lost, when its next run spawns another. Each attempt has an audio decoder package’s instance, or none for samples Mediabunny decoded, an output stage, and aWorkletPCMProducer; it renders one batch at a time under the producer’s credit window and credits each batch back once rendered. A close shuts the attempt’s worklet channel at once and is acknowledged once the attempt released the rest, and a failure reports the kind the page acts on.AudioDecodeWorkerClient.ts[worker]: the decode worker’s side: the audio decode worker’s spawn, each attempt’s open, input credits, outcome, and close, and the packet and sample batch builders. A worker that fails to start, throws, or answers out of contract fails every attempt asaudio-output-failed.AudioDecodeWorkerProtocol.ts: the messages between the two workers, their validators, the batch shapes and their transfers, the batch and credit constants (4 batches, 40 ms or 64 inputs), and the 1 MiB bound of the codec private data a package attempt carries.DecodeWorkerProtocol.ts: messages, the source prefetch requests that belong to no generation, validators, credit constants (4 frames, 2 raw buffers, 8 GPU decode packets, 8 audio samples), and the backend and output literals; a start’s andset-video-presentation-interval’s presentation interval, andresync-video’s forward keyframe choice.ConcurrentDecodeStreams.ts: concurrent decode streams that cancel each other on the first failure and all drain before the worker reports that the generation stopped.MediaClock.ts[main]: the generation-tagged clock;synchronizere-anchors it.
demux/
The first stage: the media a playback reads, its container input and track selection, codec configuration records, packet shaping and restamping, and the bitstream metadata that travels with packets. All worker code unless marked.
CustomDecodeMediaSource.ts[main and worker]: the media a playback reads, a URL that the worker fetches in byte ranges, with the host’s pattern for the paths whose cross-origin 206 hidesContent-Range, or aFilethat crosses to it by structured clone, and its validator.MediaFetchPolicy.ts: retries only transport errors and 408, 429, and 5xx.HTTPRangeResponse.ts: validates Range responses, a cross-origin 206 that hidesContent-Rangeincluded, from the paths the source’s pattern names.URLSourceRetention.ts: keeps the last run’s URL source, or a prefetch’s, for the next run over the same media, released only by the run or prefetch that took it last, and sizes its read-ahead and cache by the media’s bitrate.MatroskaBlockAdditions.ts: reads a packet’s Matroska or WebM BlockAdditionals but alpha, such as VP9 HDR10+, from the fork’s packet side data.ContainerVideoScan.ts: a video track’s scan as its container declares it: Matroska’s FlagInterlaced and FieldOrder, which the fork’s Matroska demuxer reports as the track’s field order, or an ISO base media sample entry’sfielbox.CustomDecodeTrackSelection.ts: track lookup by ordinal within one media type.CustomAudioTrackMetadata.ts: the QuickTime DTS and Matroska MLP tracks whose codec Mediabunny does not map (DTS,A_MLP), DTS Express detection, declared-rate recovery for the DTS sample entries, the declared channel layout a track is screened with from its container and the host’s metadata layout, the native media route’s bed, and the decoded audio timestamp tolerance.WaveFormatAudioConfiguration.ts: the WAVEFORMATEX of a MatroskaA_MS/ACMtrack or a codec-less AVI or ASF audio stream, written back from the fork’s parsed stream format fields, and the WMA codec its format tag names as FFmpeg’s RIFF tags do, its SubFormat’s for WAVE_FORMAT_EXTENSIBLE.VFWStreamFormat.ts: the FourCC and extradata of an AVI or ASF video stream’s BITMAPINFOHEADER, from the fork’s parsed stream format, for a stream Mediabunny names no codec for.AVIIndexCoverage.ts: whether an AVI track’s indexed packets end well before its stream header’s declared length, as a truncated file’s do, or an OpenDML file’s whose OpenDML index cannot be read.UnsupportedTrackFeatures.ts: the first feature, such as an edit list, that Mediabunny reports it cannot honor for a track.VideoChromaFormat.ts[main and worker]: a video track’s chroma from its decoder configuration, which the worker’s ready response names and every frame carries to presentation: its subsampling from an avcC (its closing chroma format or its first SPS), an hvcC, an av1C, or an AV1, VP9, or VP8 codecs string, and its 4:2:0 location from the first SPS’s VUI of an avcC or hvcC, or an av1C’s or AV1 codecs string’s chroma_sample_position; an H.264 or HEVC track without a record, or whose record holds no SPS, takes both from its first key packet’s in-band SPS, and MPEG-1 is center-sited 4:2:0.NALUnitFraming.ts: splits H.264 and HEVC data into NAL units at Annex B start codes or at an avcC’s or hvcC’s length prefixes, without copying them.AudioStartPacket.ts: the packet an audio attempt starts from, without Mediabunny’s proof scans when the track starts late or the start is at the playback timeline’s origin, and a decoder’s preroll, which never reaches before that origin.PlaybackTimelineOrigin.ts: which containers stamp their packets on a program clock, MPEG-TS, M2TS, and MPEG program streams, and where a run’s playback timeline starts in such a container: the first timestamp of the tracks it plays.TrackIteration.ts: a track’s packets in decode order, read about a second ahead, and its decoded video or audio samples from a start time, as iterators that end at once onreturn().ISOBaseMediaSampleEntryChildBox.ts: reads a child box of an ISO base media track’s first sample entry, such asfiel, from themoovbytes Mediabunny keeps.PresentationOrderTrack.ts: a track whose packets carry presentation timestamps for one that stamps them in decode order, as AVI and Matroska Video for Windows tracks do, by a codec’s reader of each packet’s picture order: an anchor takes the time of the packet FFmpeg outputs it at, and a B picture keeps its own; a key packet lookup steps back to the anchor that presents by the time.BitmapInfoHeader.ts: the BITMAPINFOHEADER of Video for Windows codec private data: its FourCC, printable or as hexadecimal bytes, and the header of a coded size and FourCC the engine builds before a codec’s extradata.JPEG2000TrackConfiguration.ts: the tracks the JPEG 2000 route reads, an MJ2mjp2sample entry or a Matroska Video for Windows track of a FourCC FFmpeg’sriff.cmaps to JPEG 2000, which pass no codec private data.
demux/av1/
AV1OBUParser.ts: the OBU walk of one temporal unit (headers, extension headers, leb128 sizes, a last OBU without a size field), frame header detection, metadata types and ITU-T T.35 messages, and the removal of a payload’s trailing bits.AV1SequenceHeaderParser.ts: the sequence header, parsed as the AV1 specification defines it, with the bit offsets of its color_config and trailing bits.AV1SequenceHeaderColorRewriter.ts: the bit-exact rewrite of a sequence header’s color description and range, and of every sequence header OBU of a temporal unit orav1CconfigOBUs.
demux/dolby-vision/
DolbyVisionProfiles.ts[main and worker]: the dual-layer profile set (P4, P7), and the 1 us tolerance within which a base and an enhancement layer timestamp pair, as packets and as decoded frames.DolbyVisionHEVCSplitter.ts: NAL unit headers overNALUnitFraming.ts, with their temporal ID; the BL, RPU (62), and EL (63) split; RASL handling; in-band SPS neutralization; drops NAL units of layers above 0, the MV-HEVC second view.DolbyVisionAV1Splitter.ts: removes the Dolby Vision T.35 metadata OBUs from an AV1 temporal unit and returns their messages; other metadata, HDR10+ included, stays.DolbyVisionEncodedMetadata.ts,DolbyVisionEncodedMetadataProtocol.ts: per-packet RPU parsing for HEVC and per-temporal-unit parsing for AV1, which removes the RPUs unparsed on a route without Dolby Vision, over one PTS-keyed window, and the transferable schema.DolbyVisionEncodedPacketPairer.ts: BL and EL packet pairing, within the tolerance ofDolbyVisionProfiles.ts.DolbyVisionRPUParser.ts,DolbyVisionRPUParserSession.ts,DolbyVisionRPUDataLayout.ts: the libdovi WASM parser with its HEVC and AV1 T.35 entry points, one instance per worker with a context for each parser, its per-run session, and the packed 3232-byte snapshot layout (schema 2: a mapping method per segment).WorkerWASMInstanceCache.ts: the one instance of a WebAssembly module that a worker’s users share, one cache per kit with the source it loads from as its version, discarded after its code traps; the libdovi parser uses it.MatroskaDolbyVisionHVCE.ts,ISOBaseMediaDolbyVisionConfiguration.ts,MPEGTransportStreamDolbyVisionConfiguration.ts: find a P4 or P7 EL configuration in Matroska; in MP4, as a separate EL track or ashvcEbeside the BLhvcCof one interleaved track; or in MPEG-TS and M2TS (any descriptor version).
demux/hdr/
HDR10PlusMetadata.ts: ST 2094-40 parsing from a frame’s ITU-T T.35 messages, and from an HEVC access unit’s SEI.AV1HDR10PlusMetadata.ts: the HDR10+ messages of an AV1 temporal unit’s T.35 metadata OBUs, without their trailing bits, parsed byHDR10PlusMetadata.ts.HEVCStaticHDRMetadata.ts,AV1StaticHDRMetadata.ts: the MDCV and CLL of an HEVC access unit’s SEI and of an AV1 temporal unit’s metadata OBUs, and the AV1 PQ sequence header check that gates its scan.StaticHDRMetadata.ts[main and worker]: the MDCV and CLL luminance schema, the codec-neutral startup scan, the scan-result validators shared across the worker boundary, and the static tone mapping source peak.
demux/hevc/
HEVCDecoderConfiguration.ts: reads anhvcCrecord: its profile, chroma format, bit depth, NAL length size, and its base layer’s parameter sets.HEVCSEI.ts,HEVCSPSParser.ts: SEI extraction and the alternative transfer characteristics value; SPS parsing within the progressive Main and Main 10 bounds, the VUI color mapping to WebCodecs names, and the VUI rewrite with its native HDR route check, and the chroma format, VUI chroma location, and temporal sub-layer count of any profile’s SPS.HEVCTemporalSubLayers.ts: the highest temporal sub-layer every SPS of a track declares, from itshvcCrecord and in band, and whether an access unit is a picture no later picture references.
demux/mpeg/
MatroskaMPEGVideoConfiguration.ts: a MatroskaV_MPEG1orV_MPEG2track’s CodecPrivate, the sequence header FFmpeg hands its decoder, or none.MPEGTransportStreamVideoConfiguration.ts: the engine codec of an MPEG-TS, M2TS, or MPEG program stream MPEG-1, MPEG-2, or VC-1 track, and its codec private data, the extradata of its first packet with a sequence header, as FFmpeg’sextract_extradatatakes it.
demux/vc1/
MatroskaVFWVideoConfiguration.ts: reads a Matroska Video for Windows track’s FourCC and extradata as FFmpeg does, and from them or an ASF stream’s (readVFWVC1DecoderDescription,readVFWWMV3DecoderDescription) VC-1’s extradata after aWVC1orWMVAheader, and WMV3’s STRUCT_C behind a new header of the track’s coded size, since STRUCT_C holds no frame size.ISOBaseMediaVC1Configuration.ts: avc-1sample entry’sdvc1sequence header and entry point, Advanced profile only, which the fork hands on as the track’s description.VC1TrackConfiguration.ts: the codec private data and packet track of a VC-1 or WMV3 track, per codec and container.VC1PresentationOrder.ts: reads a VC-1 or WMV3 picture’s order from its picture header, forPresentationOrderTrack.tsto restamp a Matroska VC-1 or WMV3 track by FFmpeg’s rule: an I or P picture takes the next I or P picture’s time, and a B picture keeps its own.
demux/mpeg4/
MPEG4TrackConfiguration.ts: the codec private data and packet track of an MPEG-4 Part 2, H.263, or MS-MPEG4 track: its codec ID per container, a Video for Windows or AVI FourCC of the negotiated codec as FFmpeg’sriff.cmaps it, and a BITMAPINFOHEADER of the track’s coded size and FourCC before its extradata.MPEG4PresentationOrder.ts: reads a video object layer’slow_delay,vol_control_parameters, and time increment width, and a VOP’s coding type andvop_coded, forPresentationOrderTrack.tsto restamp an AVI or Matroska VfW MPEG-4 Part 2 track FFmpeg outputs one VOP late, a layer withoutvol_control_parametersby whether its first group holds a B-VOP, passing over N-VOPs.
demux/vp9/
VP9FrameParser.ts: splits a packet at its superframe index and tells from the frame headers whether it shows a frame.
decode/
The second stage: the WebCodecs decoders the engine owns, the GPU decoder package route, the audio decoders, and the package loading that video and audio share. The video modules are worker code unless marked.
decode/video/
What both video routes share.
VideoPresentationInterval.ts: whether a frame presents on a presentation interval’s grid: some multiple of the interval falls within it.RawVideoFrameCopy.ts: copies aVideoFrameinto aligned plane buffers (NV12, and I420 to I444P12). A Dolby Vision pair is a BL in any format from I420 to I444P12 and an I420P10 EL, in one compound buffer. It also names each format’s bit depth and the BL formats Dolby Vision reconstruction samples, which the presenter and the host check.RawFrameBufferPool.ts: a run’s spare raw buffers, recycled by the page or left by frames closed unposted, which drained frames and raw copies take; it allocates when no spare fits and keeps at most 2 of each byte length.DecodedVideoGeometry.ts[main and worker]: the first-decoded-size lock.VideoFields.ts[main and worker]: a stream’s scan, a frame’s fields, and the picture fields word GPU decoders and parsers report them in.HDR10PlusFrameMetadataQueue.ts,HEVCDynamicHDRMetadataQueue.ts: PTS matching of each packet’s HDR10+ result to its decoded frame, for any codec, with the last metadata carried in decode order to frames without their own, and the HEVC packet step that feeds it.
decode/video/native/
The owned WebCodecs paths.
OwnedVideoDecodeStream.ts: the codec-neutral state of one owned decode attempt: decoded outputs matched with their packets’ metadata, the pre-start rule, decimation by the presentation interval, BL and EL pairing, frame credits, and the packet pump, which waits briefly after each packet for the decoder’s next output, skips packets a path marks as never presenting, and waits for the GPU’s decode of the batch before once a preroll’s or decimation’s unshown frames fill a batch. It pairs a path’s Dolby Vision and HDR10+ queues into the metadata its frames take (createOwnedVideoFrameMetadataSource). It also runs a single-layer attempt (runOwnedSingleLayerVideoStream), given a per-packet metadata step.VideoDecodeFence.ts: the decode worker’s fence over native frames, a WebGPU device of its own whose one-texel read of a frame completes once the GPU decoded it.OwnedNativeVideoDecoder.ts: an owned WebCodecs decoder for packets that decode unchanged, used for AV1 and VP9.OwnedAV1VideoStream.ts: one attempt of the owned AV1 path, which every AV1 track takes: each temporal unit’s Dolby Vision RPU and HDR10+ metadata, paired with its frame, after an optional color neutralization.OwnedVP9VideoStream.ts: one attempt of the owned VP9 path, which every VP9 track takes: the HDR10+ in each packet’s container side data, paired with its frame.OwnedNativeHEVCVideoDecoder.ts: the engine’s own WebCodecs HEVC decoder, built onOwnedNativeVideoDecoder: NAL order fix, leading RASL drop, repair of an SPS withoutdefault_display_window_flag, optional SPS neutralization.NativeHDRHEVCColorNeutralizer.ts: rewrites thehvcCSPS and the decoder colorSpace to BT.709 at the stream’s own range for the native external HDR route, and repairs anhvcCSPS withoutdefault_display_window_flagon any route.NativeHDRAV1ColorNeutralizer.ts: the AV1 counterpart: the decoder colorSpace, codecs string, andav1C, and each temporal unit’s sequence headers, each checked against the route’s limited BT.2020 color first.DolbyVisionFramePairQueue.ts: BL and EL frame pairing, within the tolerance ofdemux/dolby-vision/DolbyVisionProfiles.ts.
decode/video/gpu/
The video half of decoder packages: WGSL compute decoders, which write decoded frames into a pool on the presenter’s device, and their optional CPU parsers.
The frame-pool route plays them: GPUVideoDecodeFeed.ts feeds a session the worker’s packets, and presentation/PresentationSetup.ts offers the host’s packages and creates sessions on the presenter’s device.
The engine holds no decoder; the decoder projects in wgpud/ build packages.
GPUDecodeTypes.ts[main]: the decoder manifest, stream facts, packets, output events, and coded failures.GPUVideoDecoderCatalog.ts[main]: the offer of one video decoder package for one output format, with the device’s buffer limit, which the eligibility selects from, and the summary of a package a host has not loaded, which judges a stream by the same fit check when it carries the package’s header.GPUDecoderStreamFit.ts[main]: whether a decoder fits a stream on a device (itsacceptson the picture size, pool, state, scratch buffers, and packet ring), which eligibility checks at the container’s size, and the pool a session codes at: a first session’s at the largest picture its parser declared when the decoder fits it, a stream change’s at its sequence’s requirement or the old pool grown to the picture.GPUDecoderManifest.ts,GPUDecoderPrelude.ts[main]: parse a decoder’s//!header, its error codes’ recoveries among it, or the header alone as a server’s catalog carries it, and generate its bindings and helpers, the status a packet reports into among them.GPUDecoderEntryPointModules.ts[main]: splits a decoder’s module into one module per entry point, of the declarations it reaches, each kept character at its line and column, or leaves code it does not understand whole.GPUFramePool.ts[main]: the pool buffer, its slot layout, and slot lifetimes, with counted holds on a frame a resync repeats.GPUVideoDecodeSession.ts[main]: runs a decoder: the packet ring, dispatches with each picture’s repeat counts, submitted whole or, when the host turns slicing on, in slices that a reset or a resync stops, every packet’s status readback, which its frame waits for, a recoverable error’s recovery, reordering or a parser’s output events, slot release, and the visible rectangle its first header or each picture reports.GPUDecoderPipelineCache.ts[main]: the compiled decoders a device keeps for later sessions, by package id and output format, with the package’s content hash as their version, so a package’s new version replaces its old one’s.GPUVideoDecodeFeed.ts[main]: the frame-pool route’s page side: it creates or reuses a GPU decode session per generation, hands a picture its session cannot hold to a session made for it (a stream change), keeps up to two slices of decode work in flight and submits the next as one completes or a status readback applies, returns packet credits, turns decoded frames into presentation frames, counts the session’s recoveries, and reports its failure and its drained end.GPUDecodeFormats.ts[main],GPUDecodeLimits.ts[main and worker]: the planar formats a decoder may write and the FFmpeg pixel formats each holds, and the route’s bounds.GPUDecoderParser.ts: one instance of a package’s parser module, with its configure, plan, plan_next, parse, finish, and decode calls, each picture’s record, output events, visible size, repeat counts, output format, and sequence maximum, and whether a failure resyncs.GPUDecoderParserPipeline.ts: an attempt’s parser on its coordinator instance: it plans each packet’s pictures, splits a picture’s units into parse jobs, retries a blocked plan or plan_next, and finishes the pictures in decode order.GPUDecoderCoordinator.worker.ts,GPUDecoderParse.worker.ts: a video decoder package’s coordinator worker, which a decode worker starts with its first attempt that needs one and keeps for its life, and its parse workers, which the coordinator worker keeps while the package’s id and content hash stay; they runGPUDecoderCoordinatorWorkerRuntime.tsandGPUDecoderParseWorkerRuntime.ts.GPUDecoderWorkerCoordinator.ts,GPUDecoderCoordinatorHost.ts,GPUDecoderCoordinatorProtocol.ts,GPUDecoderCoordinatorWorkerRuntime.ts: the decode worker’s coordinator worker, shared by its attempts, with its 30 s watchdog; its messages; and its runtime, which runs one pipeline at a time.GPUDecoderParseWorkerPool.ts,GPUDecoderParseProtocol.ts,GPUDecoderParseWorkerRuntime.ts: the parse workers, one per core beyond two, from one to six; the pool sends each job to the worker with the fewest outstanding and stops on a worker error or the watchdog.GPUDecoderVectorSet.ts[main]: reads and writes a package’s vectors section.GPUDecoderVectorCheck.ts,GPUDecoderVerification.ts[main]: one vector’s check on a device, andverifyGPUDecoderPackage, which runs it, through the package’s parser on a worker of its own, only when a host calls it.
decode/audio/
The audio half of decoder packages.
AudioDecoderManifest.ts: parses an audio package’s//!header.AudioDecoderPackageInstance.ts[worker]: one decoder on its own instance of a package’s module, which drives version 1 of the audio decoder interface and copies each block out; the selection of a package and codec, and its validators across worker boundaries.PackageSoftwareAudioDecoder.ts[worker]: owns one package decoder in the audio decode worker, configures it with the track’s codec private data before its first packet, checks each block’s layout and rate, and stamps blocks without a timestamp after their packet’s earlier ones.
decode/packages/
The package file that both halves share (see Decoder packages).
DecoderPackage.ts[main]:loadDecoderPackage, which compiles a package file and checks it in order, failing with aDecoderPackageErrorcode and detail; the video and audio package types.DecoderPackageFormat.json: the section names and the vectors section’s magic and version, which the engine andtools/decoder-package-format.mjsshare.DecoderHeader.ts: the//!header syntax both kinds of manifest share, the kind a source names, and error names.DecoderPackageIdentity.ts[main and worker]: a package’s identity, the form of theid:its header declares and the version, its id with the content hash, that the engine keys what it keeps for a package by.DecoderPackageBinary.ts: reads what the JavaScript API does not reflect from a module binary: its imports with their memory limits, and whether it defines a memory.DecoderPackageImports.ts[main and worker]: the import whitelist, the memory bound (16384 pages), the inert WASI stubs, and the instantiation that runs a reactor’s_initializefirst.DecoderPackageStore.ts[main]: a host’s IndexedDB store of package files by package id, which loads each file with the engine’s checks, so a package’s new version replaces its old one.
audio/
CustomAudioCodec.ts: the WebCodecs, Mediabunny PCM, and package codec lists; the package codecs are those the engine has no decoder of.CustomAudioSampleRate.ts: the sample rate contract, any positive integer.CustomCompressedAudioRoute.ts: the E-AC-3, DTS, and TrueHD route tables that the input channel counts derive from, the DTS profiles, and the TrueHD rate rule.CustomAudioOutputPolicy.ts: input channels per codec, the 3.0 layout requirement for decoders without a speaker mask, and the 48 kHz 2, 6, and 8-channel output contract with its 2 s ring.NativeMultichannelAudioOutput.ts: the speaker layout setting (selectCustomAudioSpeakerLayout) and the output layout rule (selectCustomAudioOutputLayout: objects take the whole layout; otherwise three channels, 5.0, and 5.1 to 5.1, 6.1 and 7.1 to 7.1 or 5.1, else stereo).AudioNormalization.ts: TrackGain and AlbumGain decibels to linear gain.AudioSampleWindow.ts: PCM windowing at a seek.SafeIntegerValidation.ts: the positive safe integer check the audio modules share.
audio/processing/
StreamingAudioResampler.ts,StreamingAudioLookaheadLimiter.ts,StreamingAudioOutputPipeline.ts,StreamingAudioDownmixSettings.ts[worker]: the 48 kHz resampler with input timestamp reconciliation within 2 s (larger deviations throw, and a listener sees every correction) and a continuation across source rate changes, the 100 ms lookahead limiter, the pipeline that rebinds its source rate and enables the limiter late, and generation-scoped live gains that follow the decoded rate. The resampler’s and the limiter’s own storage and rendering are the JavaScript reference; given the output stage module, a kernel stores and renders instead, with the same bytes.AudioOutputStageModule.ts[worker]: theaudio-output-stageWebAssembly module, loaded once per audio decode worker on its first attempt, with a once-warned fallback to the reference when it does not load, and its resampler and limiter kernels, freed on finalize, on close, or after their owner is collected.PCMChannelPool.ts[worker]: the spare channel buffers the worklet returns, each sized for the largest chunk, which the output stage writes later chunks into.DecodedAudioOutputStage.ts[worker]: binds one audio attempt’s output stage to the decoded rate and layout, checks them against the decoded PCM routes, and reports each bound format;closefrees a stopped attempt’s kernels.CustomAudioDownmix.ts,CustomAudioDownmixAlgorithm.ts,CustomAudioChannelLayout.ts,CustomWaveChannelLayout.ts: downmix matrices per algorithm (three-channel beds have their own), algorithm IDs, layout tables with the output layouts (stereo, 5.1, 7.1, 5.1.2, 5.1.4, 7.1.2, 7.1.4) and by-name mapping and height folds into them, a 5.0 bed’s mix through 5.1, and WAVE mask mapping.
audio/output/
AudioWorkletController.ts[main],AudioWorkletProcessorSource.ts[worklet],AudioWorkletProtocol.ts: the worklet node, the inline processor (which renders silence from a flush up to the first chunk, and plays the ring at the playback rate), and their messages, the rate among them.AudioWorkletTimeStretchSource.ts[worklet]: the WSOLA time stretcher the processor inlines, which plays media-rate PCM at a playback rate with its pitch kept, allocating nothing as it renders.AudioOutputDevicePresence.ts[main]: whetherenumerateDevices()lists any audio output.BrowserAudioContextPool.ts,BrowserAudioContextPrewarm.ts,BrowserAudioWorkletPool.ts,BrowserAudioOperation.ts[main]: the shared 48 kHz context (never pooled when created without an output device), the prewarm lease, the leased worklet node, which forwards the playback rate, and timeouts.BrowserCustomAudioOutput.ts[main]: the production output: context, sink lease, worklet lease, the playback rate, the physical output time correction scaled to media time by the rate, in-place layout reconfiguration, andsinkchangereporting.CustomDecodeAudioBridge.ts[main]: each generation’s flush and its new channel for the producer, the producer’s progress against the processor’s consumption, which the end-of-stream drain reads, and the processor’s overflow and stale-generation failures.WorkletPCMProducer.ts[worker]: feeds one worklet processor over its own channel: it owns the credit window, checks each chunk’s layout, bounds, and continuity before posting it, and passes the buffers the processor returns to the channel pool.WebGPUAudioOutputManager.ts: the page-wide sink router (setSinkId), with its fallback chain,devicechangehandling, output recovery poll and sink rebuild, picker, and UI snapshot.
audio/native/
AC-3 and E-AC-3 through a hidden <audio> and MSE.
CustomDecodeNativeAudioBridge.ts,OwnedNativeMediaAudioBackend.ts[main]: the bridge and the owned media element, which parks at a late first fragment, delaysplay()by the gap, and advances by a late timer’s overshoot.NativeMediaAudioFMP4Remuxer.ts[worker]: the fMP4 remux of the selected audio track.NativeMediaAudioLimits.ts: the segment caps (2 s, 2 MiB) and the pending-append caps (16 segments, 4 MiB).
validation/
The authorization vectors are in capability/vectors/.
RawHDRPresentationAuthorization.ts: raw route keys, readback of the route’s default plan, and the per-device registry.ExternalHDRPresentationAuthorization.ts: external PQ and HLG route keys per codec, HEVC Main 10 and AV1 Main 10, each checked with its own neutralized vector, its check and decode recorded as probe session steps and batched per codec and target format.DolbyVisionPresentationAuthorization.ts,ExternalDolbyVisionPresentationAuthorization.ts: raw Dolby Vision keys (per raw format: single-layer, the P4 and P7 bases, and the P4 and P7 FEL) and the external P5 and P8 keys, one registry each, checked with synthetic RPU vectors.GPUAuthorizationDeadline.ts: the shared 5 s timeout, cancelled on device loss.GPUCanvasReadback.ts: bounded GPU canvas pixel readback (5 s).GPUErrorScope.ts: the validation error scope the authorizations and the canvas readback push and pop in one task around their submit (captureValidationErrors), and its cleanup.
Outside src/
test/helpers/enginePaths.ts: the engine root and thenode_moduleslocation, independent of the test runner’s working directory, and the folders fromtools/constants.json.test/helpers/dolbyVisionAV1ITUTT35Payload.ts,test/helpers/dolbyVisionMixedRPUVector.ts: wrap an HEVC RPU in the AV1 EMDF T.35 container, and build RPUs with mixed and linear pieces.test/helpers/libraryAssets.ts: the asset build’s tables fromscripts/library-assets.mjs, and a served binary read as bytes from the file the build copies, since tests have no server for its URL.test/helpers/decoderPackages.ts: video decoder packages for tests, assembled with the engine’s package tool or built in memory, and the decoder projects’ sources, vectors, and built packages.test/helpers/postprocessingPasses.ts: the pass files of the pass projects, one by project and name, or all of them asnpm run passescopies them.test/helpers/audioDecoderPackageStubs.ts,test/helpers/scriptedAudioDecoderModule.ts,test/helpers/audioDecoderPackageResults.ts: the smallest audio package the loader accepts; a module of the audio decoder interface scripted in JavaScript, found again after a structured clone by a custom section; and the packets and results the decoder projects’ tests use.test/helpers/webAssemblyModuleEncoder.ts: encodes small WebAssembly modules without a toolchain, for the package loader’s and the interfaces’ tests.test/helpers/fakeIndexedDB.ts: an in-memory IndexedDB of the parts the package stores use: versioned opens with upgrades, keyed object stores, and transactions that apply their writes only when every request succeeds.test/helpers/vc1Media.ts: synthetic VC-1 and WMV3 sequence and picture headers, Matroska Video for Windows files, and MP4vc-1files.test/helpers/mpegVideoHeaders.ts: synthetic MPEG-1 and MPEG-2 sequence headers, sequence extensions, and I picture packets, for tests that read a stream’s sequences without decoding its pictures.test/helpers/mpeg4Bitstream.ts: synthetic MPEG-4 Part 2 video object layers, extradata, and VOP headers of each coding type, coded or not, for tests that order VOPs without decoding them.test/helpers/mpeg4Vectors.ts: the media of thempeg4decoder project and the DivX packed AVI vector, with each one’s codec, FourCC, size, extradata, and frame order as FFprobe reports them, and a packet credit return for frame-pool runs.test/helpers/matroskaElements.ts,test/helpers/isoBaseMediaBoxes.ts: EBML elements and ISO base media boxes, and a file of one video track in each, with any codec ID or sample entry, CodecPrivate or none, and boxes ahead offtyp.test/helpers/av1MetadataOBUs.ts: AV1 OBUs, metadata OBUs included, that the AV1 tests build temporal units from.test/helpers/hdr10PlusVectors.ts: the HDR10+ messages ofHDR10PlusVectors.ts, and what the HDR10+ AV1 and VP9 vectors share: theexpectations.jsonreader, the coded values in the engine’s units, and the check of a posted frame’s HDR10+ result.test/helpers/av1HDR10PlusVectors.ts,test/helpers/vp9HDR10PlusVectors.ts: each set’s files and its codec’s known answers, such as the AV1 static HDR metadata and the VP9 BlockAddID.test/helpers/ownedVideoStreamFakes.ts: a stream run, a packet iterator, and a decoder that outputs each frame at once or holds its frames until a flush, in decode or presentation order, for the owned decode path tests.test/helpers/decodeWorkerHarness.ts: loads a fresh playback worker in a stand-in browser: a global scope that plays the session’s part and waits for each run’sstopped, so one worker can run several generations, range responses for the media it plays, a WebCodecs video decoder, and the audio decode worker run in-process (inProcessAudioDecodeWorker.ts) on a channel.test/helpers/hevcMatroskaVector.ts: a one-frame Matroska HEVC Main 10 track muxed from the x265 HDR10+ sample, as Annex B with in-band parameter sets or length-prefixed with an hvcC.test/helpers/inProcessGPUDecoderWorkers.ts: a package’s coordinator worker and parse workers run in-process on channels, for the frame-pool route’s parser tests.test/helpers/fakeGPUVideoDecode.ts: a GPU decode session without a device, which decodes each packet into a frame at once, and a queue whose submitted work completes when a test says so.test/helpers/fakeWorkletProcessor.ts,test/helpers/decodedAudioMedia.ts: the worklet processor’s end of a producer channel, which records each chunk and returns it played, held, or dropped; and a Matroska file of the VP9 vector’s video and a stereo PCM tone, which Mediabunny decodes in Node.wasm/: the engine’s own WebAssembly modules and their build. See WebAssembly build.wgpud/: the submodule of the decoders repository, webgpu-player-decoders: the decoder projects, each of which builds one decoder package, with its tests intest/, and their shared build rules. See WebAssembly build.wgpup/: the submodule of the passes repository, webgpu-player-passes: the pass projects, whose pass files a host offers as optional passes, and their upstream license texts inlicenses/. See Bundled passes.vendor/: the FFmpeg and dcadec submodules (update = none), whichmake -C wgpud sourcesfetches.scripts/build.mjs: assemblesbin/libraries/, including the esbuild worker bundles, andbin/build-info.json.scripts/library-assets.mjsmaps each served file to its source.scripts/pinned-releases.mjs: installs the pinned decoder and pass releases intobin/wgpud/andbin/wgpup/, recording them inbin/installed-releases.json, and with--pinpins a release intools/pinned-releases.json. See Pinned releases.scripts/decoder-packages.mjs: builds the decoder projects intobin/wgpud/, or checks them against it.scripts/pass-files.mjs: copies the pass projects’ pass files intobin/wgpup/, flat.scripts/codec_vector_assets/: the Python codec vector generators, their tests intest/, and the local playback media generators. See Codec vectors.tools/: browser probes, the decoder package tools, the mpv shader converter and the.cubeLUT converter with their tests intest/and the texture block writer they share,constants.json, andpinned-releases.json, the pinned decoder and pass releases, file by file with their SHA-256. See Tools.bin/wasm/: the engine modules’ builds frommake -C wasm,libdovi/andaudio-output-stage/, served fromlibraries/; ignored.bin/wgpud/: the decoder packages of the pinned release, or thosenpm run decodersbuilds; ignored.bin/wgpup/: the pass files of the pinned release, or thosenpm run passescopies from the pass projects; ignored.bin/installed-releases.json: the filesscripts/pinned-releases.mjsinstalled, with their SHA-256; ignored.bin/codec_vector_assets/: the generated codec vectors, the one committed folder inbin/:truehd/TrueHDExactCapabilityVectors.ts: TrueHD and MLP access units and their expected outputs, which thetruehdproject’s tests import as#codec_vector_assets/*, and the synthetic streams the module embeds.hevc-range-extension/: the nine range extension streams, which the native probes fetch frombin/libraries/and thehevcproject’s vectors hold.av1-raw-format/: the eight AV1 raw format streams, which the native probes fetch frombin/libraries/aswebgpu-player/av1-raw/.h264-raw-format/: the five H.264 raw format streams, which the native probes fetch frombin/libraries/aswebgpu-player/h264-raw/.dolby-vision-av1/: the Profile 10 test vectors and theirexpectations.json.hdr10plus-av1/: the HDR10+ AV1 test vectors and theirexpectations.json.hdr10plus-vp9/: the HDR10+ VP9 test vectors, in WebM and Matroska, and theirexpectations.json.downmix-reference/: the deterministic 7.1-to-stereo reference.
Embedding the engine
A host imports the engine’s TypeScript, serves its prebuilt workers and WebAssembly modules, and configures it once at startup.
Decoders are not among them: a host hands the engine the decoder packages it loads, at runtime.
Read the engine’s folders from tools/constants.json (sourceDirectory, scriptsDirectory, libraryOutputDirectory, buildInfoFile) rather than restating them.
You need
- The engine as a git submodule of the host.
- The toolchain of the engine’s modules from WebAssembly build, for the first build of each checkout: Emscripten and rustup.
Steps
-
Make the sources resolvable. Map
webgpu-player/*to the engine’ssrc/*in the host’s TypeScriptpathsand in its bundler alias. The sources import nothing through theimportsfield of the engine’spackage.json; only the decoder projects’ tests do. -
Install the engine’s dependencies. Add the engine to the host’s npm
workspaces, so npm installs them,esbuildfor the asset build included. Without a workspace, install the engine’sdependenciesandesbuildin the host. -
Build the engine’s modules once per checkout, and again after their sources change:
make -C <engine>/wasm all. The asset build reads them from<engine>/bin/wasm/and stops when that folder is missing. -
Build the served assets before bundling:
node <engine>/scripts/build.mjs, with--productionfor a production build. It bundles the four engine workers with esbuild as classic workers. It copies the engine’s modules, their licenses, and the native range extension probes’ streams into<engine>/bin/libraries/. It also writes<engine>/bin/build-info.json, whoseassetKeyhashes every served file. -
Serve
<engine>/bin/libraries/unmodified, and do not minify it again. The default asset base islibraries/beside the page. Workers live in itswebgpu-player/folder and resolve the other assets relative to their own URL, so keep the layout intact. -
Configure the engine at startup:
import { configureEngineAssets } from 'webgpu-player/EngineAssets'; import { configureEngineFeatureFlags } from 'webgpu-player/EngineConfiguration'; import 'webgpu-player/style.css'; // assetKey comes from <engine>/bin/build-info.json, written by the asset build configureEngineAssets({ cacheKey: assetKey }); configureEngineFeatureFlags({ isHDRToneMappingEnabled: () => Promise.resolve(true) });Pass
baseURLto serve the assets from somewhere other thanlibraries/.
How assets are named and served
src/EngineAssets.tsnames every runtime asset,scripts/library-assets.mjsmaps each served file to its source, and theEngineAssetstest keeps the two in agreement.scripts/build.mjsfails when an asset it maps is missing, which also stops a host build that runs it.- Asset URLs are stable.
The cache key is appended as
?v=, so a browser never mixes files from two builds. - Engine workers are served at
libraries/webgpu-player/<Name>.worker.js, not as host bundler chunks. - The range extension probes’ streams are served with a
.binextension, because some static file servers reject unknown extensions. They come frombin/codec_vector_assets/. - The served folder holds each module’s license beside it. The engine serves no LGPL code; whoever distributes the LGPL decoder packages offers their source (see WebAssembly build).
Gotchas
- Reload the page fully after deploying a new build.
Open pages keep their old bundles; engine workers and modules change their
?v=key with every build. - A bundler dev server that runs the asset build at configuration load must be restarted after an engine worker changes.
- WebGPU needs a secure context. Over plain HTTP on a LAN address, a host that still selects the WebGPU player plays HTML pass-through only, which proves nothing.
- The presenter places its canvas over the surface’s video element and fits frames by that element’s CSS
object-fit, so the element needs a layout box. Hide it withvisibility: hiddenor cover it, but never withdisplay: noneor thehiddenattribute, which leave every frame undrawn assurface-empty. - While the black bar crop is on (
WebGPUPresenter.setBlackBarCropEnabled), the presenter fits the picture the bars leave rather than the whole frame, so the picture’s rectangle on the surface differs from the frame’s. Layers a host draws over the video, such as subtitles, place themselves over the element’s box and know nothing of the crop; the presenter’s telemetry gives it asblackBarCrop.
Decoder packages
A decoder package is one .wasm file that adds a video or an audio decoder to the engine.
The engine has no decoder package of its own and names none: a host hands it the packages it loads, at runtime, and the engine reads what each one decodes from the package itself.
Packages are a fallback beside the browser’s decoders: a native WebCodecs route comes first, and a package plays only what the browser cannot.
A package carries no JavaScript, so a host may load packages from anyone.
The package file
A package is a WebAssembly module with custom sections; src/decode/packages/DecoderPackageFormat.json names them for the engine and its tools.
| Section | Holds |
|---|---|
webgpu-player.decoder | Required: UTF-8 text, a //! header and, for a video decoder, the WGSL body after it |
webgpu-player.vectors | Optional, video only: the package’s self-test vectors |
The header’s decoder: directive makes the package a video or an audio decoder.
A video package’s module is its parser, when its header declares parser:, or a module without code; an audio package’s module is the decoder.
Identity
A package’s id is the one its header’s id: directive declares, and every version of the package keeps it.
Its content hash, the lowercase hexadecimal SHA-256 of the whole file, tells its versions apart.
src/decode/packages/DecoderPackageIdentity.ts holds the rules.
- Every header, video or audio, declares
id:once. - An id is 1 to 64 characters: lowercase ASCII letters and digits, in runs joined by single hyphens, such as
av1,jpeg2000, orhevc-main-10. - A header without one fails as
directive-missing, a malformed one asid-syntax, and a longer one asid-length, so the package fails to load aspackage-manifest. - A host’s store keeps one file per id, so a new version replaces the old one.
- The engine keeps what it derives from a package, such as compiled pipelines and parse workers, by id with the content hash as the version, so a new version replaces what the old one left, and nothing counts or ages entries out.
loadDecoderPackage(bytes) in src/decode/packages/DecoderPackage.ts compiles the module and checks the package in this order.
It rejects with a DecoderPackageError whose message is the failed check’s code and a terse detail.
| Code | Check |
|---|---|
package-invalid | The bytes compile, and their binary reads |
package-section-missing | The decoder section exists |
package-section-repeated | No section appears twice |
package-encoding | The decoder section is UTF-8 |
package-import | The module imports only what packages may import, its memory once |
package-memory | The memory is imported, bounded, neither shared nor 64-bit, and not defined by the module |
package-kind | decoder: is video or audio |
package-manifest | The header parses, its id included; the detail carries the header’s own code, detail, and line |
package-export | The module exports every function of the interface its header declares |
package-vectors | The vectors section reads, and holds only formats the decoder writes; an audio package carries none |
Imports and memory
A package’s module may import only these:
env.memory, once: the memory the engine creates for each instance.env.emscripten_notify_memory_growth.- From
wasi_snapshot_preview1:args_get,args_sizes_get,clock_time_get,environ_get,environ_sizes_get,fd_close,fd_fdstat_get,fd_read,fd_seek,fd_write,proc_exit, andrandom_get.
The engine’s WASI calls are inert: arguments and the environment are empty, output goes nowhere, reads and seeks fail, the clock reads zero, randomness is zero bytes, and exit traps the call.
src/decode/packages/DecoderPackageImports.ts holds the whitelist and the stubs.
A parser and an audio decoder must import their memory as env.memory; a video package without a parser imports none.
Each instance gets a new memory with the module’s declared initial pages, which may not exceed 16384 pages (1 GiB).
Its maximum is the module’s declared maximum, or 16384 pages when the module declares none or more.
A module that exports _initialize, as a WASI reactor does, has it run before any other export.
Video decoders
A video decoder is WGSL: a //! header, then a body of compute entry points.
The engine compiles the body behind a prelude of its own for the output format a session decodes into, and runs the header’s dispatch sequence once per packet on the presenter’s device.
Decoded frames stay in a frame pool, one storage buffer of slots, which presentation reads in place.
The header
A header is the source’s leading lines that start with //!, one name: value directive per line; a bare //! line is skipped.
src/decode/video/gpu/GPUDecoderManifest.ts parses it, and a malformed header fails with a code, a detail, and the line.
| Directive | Required | Value |
|---|---|---|
id | Yes | The package’s id, as Identity describes |
name | Yes | Any text, which failures and hosts show |
decoder | Yes | video |
accepts | Yes | codec in [<codecs>], then optional conditions over stream.width, stream.height, stream.bitDepth, picture.width, and picture.height, separated by commas, which must all hold |
output | Yes | The formats it writes, one or more of I420, I420P10, I420P12, I422, I422P10, I422P12, I444, I444P10, and I444P12 |
monochrome | No | yes: it decodes a 4:0:0 stream into the 4:2:0 format of the stream’s depth, with the neutral value 1 << (bit depth - 1) in both chroma planes; eligibility offers a 4:0:0 stream to no decoder without it |
packet | Yes | The size of the largest packet it reads, at most 512 MiB for any stream, which sizes each session’s packet ring of two packets; an expression lets the bound grow with the stream, as AV1’s records do; the device’s buffer limit, the smaller of its largest buffer and its largest storage binding, must hold the ring |
dispatch | Yes, repeatable | <entry point>, <x>[, <y>[, <z>]] workgroup counts, or <entry point>, indirect <buffer> |
repeat | Repeatable | <count>, <dispatches>: the next <dispatches> dispatch directives run <count> times, or fewer for a picture whose parser reports fewer |
parser | No | 2: the module implements version 2 of the parser interface, and the decoder reads its pictures’ records instead of the container’s packets |
references | No | The pool slots it keeps as references, 0 to 16; 0 by default |
reorder | No | How many frames presentation order may trail decode order, 0 to 8; 0 by default |
state | No | The size of the persistent state buffer; empty by default |
buffer | Repeatable | <name>, <size>[, indirect]: a scratch buffer; indirect makes it usable for indirect dispatch |
requires | No | Device features it needs, such as shader-f16 or subgroups, which the prelude enables |
error | Repeatable | <code>, <name>[, <recovery>]: names an error code from 1 to 255, and how a playback recovers from it, conceal, drop, or resync, as Errors and recovery describes; a code without one ends the attempt |
The codecs in accepts are the engine’s codec names, which are FFmpeg’s: av1, h263, h264, hevc, jpeg2000, mpeg1video, mpeg2video, mpeg4, msmpeg4v1, msmpeg4v2, msmpeg4v3, vc1, vp8, vp9, and wmv3.
A size is a byte count with an optional B, KiB, or MiB, or an expression the session evaluates for each stream.
Sizes, workgroup counts, and repeat counts are expressions over these facts, in the presentation passes’ expression language: numbers, + - * / %, comparisons, && || !, and abs, ceil, floor, max, min, and round.
The accepts conditions read the stream and picture facts, and no output facts; the picture facts are theirs alone.
| Fact | Value |
|---|---|
stream.width, stream.height | The pool’s coded size: the picture size its session is sized for, rounded up to a multiple of 64, as How a session decodes describes |
picture.width, picture.height | In accepts only, the picture size a pool is judged for, before that rounding: the container’s visible size at eligibility, and the size a session’s pool is chosen for; a decoder bounds its pictures by them as its codec’s levels do, since the rounding can carry a legal picture’s pool past such a bound |
stream.bitDepth | The bit depth of the output format |
output.bitDepth | 8, 10, or 12 |
output.bytesPerSample | 1 or 2 |
output.chromaWidthDivisor, output.chromaHeightDivisor | 2 and 2 at 4:2:0, 2 and 1 at 4:2:2, 1 and 1 at 4:4:4 |
The dispatch sequence runs in order for every packet. A direct dispatch whose workgroup count is zero for the stream is skipped, and an indirect dispatch reads its counts from the named buffer, which an earlier entry point wrote. A repeat suits a wavefront, which dispatches once per wave: its entry points find their iteration in the decoder’s own state, and a count of zero skips the run. A parser may report a smaller count for each picture, such as the waves its picture needs, and the picture’s record runs the repeat that many times. A packet’s sequence holds at most 4096 dispatches once its repeats are expanded.
//! id: example-intra
//! name: Example intra decoder
//! decoder: video
//! accepts: codec in [jpeg2000], stream.width <= 4096, stream.height <= 4096, stream.bitDepth == 8
//! output: I420
//! packet: 8 MiB
//! state: 64 KiB
//! buffer: sliceCounts, 16, indirect
//! error: 1, sliceHeader
//! dispatch: parsePicture, 1
//! dispatch: decodeSlices, indirect sliceCounts
//! repeat: ceil(stream.height / 16), 1
//! dispatch: predictRow, ceil(stream.width / 16)
//! dispatch: reconstruct, ceil(stream.width / 16), ceil(stream.height / 16)
@compute @workgroup_size(1)
fn parsePicture() {
// Reads the picture header with readPacketByte and writes the slice count into sliceCounts
}
The prelude
The engine compiles each output format’s module as its prelude followed by the decoder’s body, so one body serves every format it lists.
src/decode/video/gpu/GPUDecoderPrelude.ts generates the prelude, and compile messages map back to the body’s lines.
The body declares no bindings, no @group or @binding, and no enable directives, and its scratch buffers take none of the prelude’s names.
Each entry point compiles a module of its own, which src/decode/video/gpu/GPUDecoderEntryPointModules.ts makes of the whole one.
- A module holds the declarations its entry point reaches through the names they use, of the prelude and the body alike, with every directive and
const_assert; the rest is blank. - Every kept character stays at its line and column, so compile messages map back to the body as the whole module’s do.
- The split reads WGSL’s module scope: comments, which may nest, attributes, directives, and the
fn,struct,const,override,var,alias, andconst_assertdeclarations. - Code it does not understand, a name declared twice, or an entry point no function declares, compiles whole for every entry point.
- When an entry point’s module reports an error, the whole module compiles: its errors are the decoder’s, as before, and a whole module without errors serves every pipeline.
- A declaration no entry point reaches compiles nowhere, so its errors go unreported.
| Constant | Value |
|---|---|
OUTPUT_BIT_DEPTH | The format’s bit depth |
OUTPUT_CHROMA_FORMAT | 1 at 4:2:0, 2 at 4:2:2, 3 at 4:4:4, as chroma_format_idc counts them |
SAMPLE_BYTES | 1 or 2 |
SAMPLES_PER_WORD | 4 or 2 |
SAMPLE_MASK | 0xFF or 0xFFFF |
Every binding is in group 0.
| Binding | Name | Holds |
|---|---|---|
| 0 | packetBytes | The packet ring, read only |
| 1 | packet | The packet’s byteOffset, byteLength, index in decode order, keyframe, reset, and outputSlot |
| 2 | config | The codec private data from the container, read only |
| 3 | stream | The stream: three planes, each a byteOffset and bytesPerRow within a slot and a width and height; width, height, bitDepth, bytesPerSample, planeCount, slotByteLength, slotCount, and configByteLength |
| 4 | state | The persistent state buffer |
| 5 | framePool | The frame pool’s slots |
| 6 | status | What the engine reads back after each packet: the fatal error word and the header’s stream facts, then the packet’s block, its freed slots, its picture’s most severe recoverable error word, and its picture fields |
| 7 on | The buffer names | The scratch buffers, in declaration order |
Each buffer binds whole, and the device must hold each one within its buffer limit, so a decoder whose working set can pass a device’s largest storage binding spreads it over several buffer: lines, as AV1 spreads its frame store over two, and its WGSL picks the buffer by index.
A device holds at least eight storage buffers per stage, the prelude’s five among them, so a decoder may declare three storage scratch buffers on every device; more need a device that holds more, or the session fails as storage-buffer-count.
| Helper | Does |
|---|---|
readPacketByte(byteIndex), readPacketWord(wordIndex) | Read the current packet |
readConfigByte(byteIndex) | Reads the codec private data |
writeOutputSamples8(plane, x, y, samples: vec4u) | Writes four 8-bit samples of the output slot at a multiple of 4; one invocation owns each word |
writeOutputSamples16(plane, x, y, samples: vec2u) | Writes two 16-bit samples of the output slot at a multiple of 2 |
readOutputSample(plane, x, y) | Reads a sample an earlier dispatch wrote into the output slot |
readReferenceSample(slot, plane, x, y), readSlotSample(slot, plane, x, y) | Read a reference slot’s sample |
getSampleByteAddress(slot, plane, x, y) | The byte address of a sample in the pool |
releaseReferenceSlot(slot) | Hands a reference slot back to the engine |
reportDecodeError(word) | Reports an error word: a code the header gives a recovery marks the packet’s picture, which keeps its most severe word; any other ends the attempt, the highest such word winning |
reportHeaderFacts(width, height, bitDepth, visibleRectangle) | Reports stream facts parsed from a header, from one invocation |
reportPictureFields(interlaced, topFieldFirst, repeatFirstField) | Reports the packet’s frame’s fields, from one invocation: whether it is interlaced, which field comes first, and whether a progressive frame repeats its first field; a packet that reports none keeps what its container or parser gave |
An error word holds an error code the header names in its high byte and the failing value in its low 24 bits: (code << 24) | value.
A code without a recovery fails the session with decoder-error and the word, which the route describes by the code’s name and the value, such as sliceHeader: 12.
A code with one recovers, as Errors and recovery describes.
Codec private data
The config binding holds what the decode worker reads from the track for its codec, as FFmpeg’s demuxers hand it to their decoders; configByteLength is 0 when the track has none.
| Codec | config holds |
|---|---|
av1 | The track’s AV1CodecConfigurationRecord (av1C), its 4-byte header and then its configOBUs: a Matroska or WebM V_AV1 track’s CodecPrivate, or an ISO base media av1C box’s payload; nothing when the container has none, since temporal units carry their sequence headers |
hevc | The track’s HEVCDecoderConfigurationRecord (hvcC), or nothing for Annex B packets that carry their parameter sets, as in MPEG-TS |
mpeg1video, mpeg2video | A Matroska track’s CodecPrivate, its sequence header and extensions, or nothing |
vc1 | The sequence header and entry point FFmpeg’s vc1 decoder gets: a Matroska WVC1 or WMVA track’s extradata, or an ISO base media dvc1 box’s after its 7-byte VC1DecSpecStruc |
wmv3 | A 40-byte BITMAPINFOHEADER of the track’s coded size and the WMV3 FourCC, then the STRUCT_C sequence header, which holds no frame size of its own |
mpeg4, h263, msmpeg4v1, msmpeg4v2, msmpeg4v3 | A 40-byte BITMAPINFOHEADER of the track’s coded size and FourCC, then the track’s extradata, an MPEG-4 Part 2 track’s VOS and VOL headers, or nothing; a Matroska, MP4, or MOV track without a FourCC gets mp4v, s263, or MP43 by its codec, and FFmpeg’s MPEG-4 workarounds read the FourCC |
jpeg2000 | Nothing, for an MJ2 sample entry and a Matroska Video for Windows track alike |
How a session decodes
src/decode/video/gpu/GPUVideoDecodeSession.ts runs one decoder for one stream and output format.
- Each entry point’s pipeline compiles from its own module, as The prelude describes.
- The modules and their pipelines depend only on the package and the output format, so the device keeps each compiled decoder by package id and output format, with the package’s content hash as its version, in
src/decode/video/gpu/GPUDecoderPipelineCache.ts. A playback’s session of a kept decoder compiles nothing, sessions created at once share one compile, and a failed compile is not kept. A package’s new version replaces every decoder its old one compiled, while sessions of the old one keep their own pipelines; nothing else drops a decoder. A self-test vector’s check compiles a decoder of its own. - A session is created only once the worker has opened the file and described the track, and it waits for every pipeline of its decoder, since every packet dispatches every entry point.
GPUVideoDecodeSession.precompilecompiles a package’s decoder for an output format into that same cache before the stream is known, so a session created later compiles nothing and one created meanwhile joins the compile. A failed compile is left to the session, which fails the same way. Three calls insrc/presentation/PresentationSetup.tsstart it, earliest first:warmGPUVideoDecoders(presenter, videoPackages)compiles, in the background, the decoders later playbacks would take from packages the host already holds. Only codecs no browser decodes count, since no native route comes before their packages, and each such codec and output format takes the first package that writes it, in the host’s order. Each package format is onerunCustomDecodeBackgroundTask, so it waits behind the background probes, for an idle period, for no negotiation near, and for no playback; the warm-up ends at a dropped task or a device without a frame pool, and skips the rest of a package that fails to compile. Beyond the device’s own cache, it leaves the browser’s shader cache warm, so a later page’s session compiles in milliseconds.precompilePredictedGPUVideoDecoder(presenter, offers, options)compiles the decoderpredictGPUVideoDecoder(options, offers)insrc/capability/CustomPlaybackEligibility.tspredicts while the host still negotiates the playback. It predicts only a stream of a codec no browser decodes, in a container the engine plays that carries the codec, with an SDR color and a scan and rotation a route presents, and takes the first offer that decodes and fits it, as eligibility would;predictGPUVideoDecodeTarget(options)gives its codec and format first, so a host loads only the packages that match. The audio and the presentation are not judged, so a playback they refuse has compiled a decoder it never uses.precompileGPUVideoDecoder(presenter, eligibility)compiles only the frame-pool route’s chosen decoder, as soon as the host has the eligibility.
- Sizes and counts evaluate once per session, from the stream facts; nothing evaluates per packet, and a picture’s own repeat counts only lower a repeat’s count.
- A first session’s stream facts come from the container’s size, or from the larger of it and the largest picture the parser read in the codec private data, such as an AV1 sequence header’s maximum frame size, when the decoder’s
acceptstakes that picture size and the device holds its pool, state, scratch buffers, and packet ring; a stream whose frames grow past the container’s size, as AV1 resolution changes do, then fits the pool.selectGPUDecodeSessionStreaminsrc/decode/video/gpu/GPUDecoderStreamFit.tsdecides, and eligibility checks the container’s size the same way, so a stream never becomes ineligible for its declared maximum. - The pool holds the decoder’s
references, itsreorderdepth, three slots for presentation, and two spares when the decoder keeps references. - Each packet goes into a ring of two packets at a word-aligned offset, and its
packetuniform names the slot it decodes into. With a parser, each of the session’s packets is one picture’s record, with its packet’s timestamp, duration, and keyframe flag. - The first packet after a start or a seek carries
reset; the engine clears the state and status buffers before it, and the decoder drops what it held. - A decoder without references frees a slot when presentation releases it.
A decoder with references holds every output slot until it calls
releaseReferenceSlot; the engine reads the freed slots back a few frames behind, which the spare slots cover. - Without a parser, frames present in timestamp order once
reorderframes wait; a frame earlier than one already presented is dropped, asframes-dropped. - With a parser, its output events present or drop each frame, as the next section describes.
- A frame presents its visible rectangle scaled into the stream’s display area: the visible size its picture reported, at the slot’s top left, or the stream’s visible rectangle.
- Every packet’s status reads back asynchronously, and the packet’s frame presents only once it has, as the next section describes.
- A picture the session cannot hold, of another output format or beyond its pool, goes to a session of its own, as Stream changes describes.
Errors and recovery
A damaged packet need not end a playback: a decoder that can go on past an error says how in its error: lines, and the engine counts each recovery.
| Recovery | The packet’s frame | Then |
|---|---|---|
conceal | Presents: the decoder concealed the damage, as FFmpeg’s error resilience conceals damaged macroblocks | Decoding goes on |
drop | Never presents | Decoding goes on from the decoder’s state |
resync | Never presents, nor does any later frame before the next key packet; the last good frame repeats in their place | The next key packet decodes from a reset |
| None | Never presents | The attempt ends with decoder-error |
- The session submits a packet only when a status readback buffer is free for it, and copies the status into it after the packet’s dispatches.
- The fatal error word and the header’s stream facts last until a reset clears the status; the packet’s block, its freed slots, its picture’s error word, and its picture fields, holds one packet’s reports, and the engine clears it after each copy.
- A frame presents only once its packet’s readback applied, so a recovery always applies before its frame could present.
reportDecodeErrorkeeps the packet’s most severe recoverable word:resyncoverdropoverconceal, then the highest word.- A resync replaces each frame of the damaged packet, and of the packets after it that already decoded, with a repeat of the last good frame: of the earlier packets’ frames that the session or presentation still holds, the one with the latest timestamp.
Each packet up to the next key packet repeats it too, at its own time, without running the decoder.
The repeats share that frame’s slot, on which the pool counts holds; without a good frame they drop.
The decoder’s references end with the old epoch, and the key packet decodes with
reset. - With a parser, the session numbers repeated pictures as it numbers decoded ones, so the parser’s events keep naming them; an event for a repeated picture changes nothing.
- A parser call that fails with a code its header gives any recovery resyncs, since the failed call made no picture and left its instance unusable.
The decode worker closes the parser, posts the failed packet and those before the next key packet as held packets, which carry no data and repeat the last good frame, and opens a new parser at the key packet, whose first picture carries
reset. The session presents the frames the old parser left undecided in timestamp order, since no event will decide them. Without a later key packet, the packets up to the track’s end hold, and the track ends. A failed call whose code has no recovery, or a failed check of the interface, ends the attempt. - A recovery’s detail is the error’s name and value,
parserbefore a parser’s error name, such ascoefficients: 90112orparser sliceHeader: 3. The feed’s telemetry counts recoveries since the page loaded asrecoveries, one entry per code,concealed,dropped, orresynced, and error name, with the latest detail. - A self-test vector that recovers fails its check as
decoder-recovered, since a vector is undamaged. - Without a recovery for its codes, a decoder ends the attempt on every error, as before recoveries existed; the route then falls back as any failed route does.
Stream changes
A stream may change midway in a way its session cannot follow, as an AV1 sequence header that raises the maximum frame size or changes the bit depth or chroma subsampling does, or an MPEG-2 sequence header of a larger size, as a broadcast stream that switches resolution has.
GPUVideoDecodeFeed in src/decode/video/gpu/GPUVideoDecodeFeed.ts checks each picture the parser reports against the request its current session was made for and that session’s pool, in getGPUVideoDecodeStreamChange.
- A picture needs a new session when it reports another output format, or a requirement its pool does not hold: the larger of its sequence’s largest picture and its own visible size on each axis, against the coded size of the pool its pictures decode into.
- The new session decodes only the new sequence’s pictures, so its pool takes the requirement alone, rounded up to 64, even below the pool before it, when the decoder’s
acceptstakes the requirement and the device holds the pool and the decoder’s buffers. - Otherwise the pool takes the fallback, the pool before it grown to the picture’s own visible size, as for a declared maximum far beyond the frames, such as 16384x8704 for frames of 640x360.
- A picture its pool holds starts no session when its requirement is the maximum its session asked for already, or one the decoder’s
acceptsrefuses, since its pool would be the fallback, the current one. - The new session’s request keeps the decoder and the codec private data, and takes the picture’s format, the requirement as its maximum, and the fallback;
chooseGPUDecodeSessionStreaminsrc/decode/video/gpu/GPUDecoderStreamFit.tschooses its pool. - The feed creates it while the current session decodes on; the picture and every packet after it wait for it.
- Pictures queued while a session is created, the first one or a change’s, wait unjudged, and are judged against its pool once it exists, since the device may give it a smaller pool than its request asks for.
- The current session hears that its packets are complete, with the picture’s events that name earlier pictures, and presents its last frames.
- Once the current session decoded its packets and its decode work completed, the new session takes over, numbering its pictures on from the picture’s index so the parser’s events keep naming them, and its first packet clears its state.
- A replaced session stays until presentation releases its last frame, and is destroyed then.
- Presentation reads each frame’s sample size, bit depth, and chroma subsampling from its slot’s layout, and binds each pool as its frames arrive, so frames of every output format and pool present through one chain.
- A change can come only where the decoder needs no earlier picture, since the new session holds no references of the old one: at an AV1 key frame after a new sequence header, or at an MPEG-2 sequence header of a larger size or another chroma format, after which FFmpeg drops every reference too.
- A seek configures the session from the track again, and a picture after it that the session cannot hold changes the session again.
- A change whose requirement and fallback the decoder’s
acceptsboth refuse fails the attempt asaccepts: picture 4096x2160 in pool 4096x2176 at 8 bits, the fallback’s picture size, pool, and bit depth. - A new session that cannot be made fails the attempt with its creation failure, such as
format-unsupported: I444P12 not in I420, I420P10for a format the decoder’soutputlacks.
The parser interface
A parser is the package’s module: CPU code that turns each packet into what the WGSL reads, such as a picture’s parsed syntax, where entropy decoding is serial.
It implements version 2 of this interface, and its header declares parser: 2.
A packet may hold several pictures, such as an AV1 temporal unit’s hidden frames beside its shown one, and each picture has its own plan, parse jobs, finish, GPU record, output events, visible size, and repeat counts.
src/decode/video/gpu/GPUDecoderParser.ts drives it.
Every export but the first two takes the handle parser_create returned; pointers, lengths, and counts are u32, and statuses i32.
| Export | Returns | Does |
|---|---|---|
parser_abi_version() | 2 | The interface version, which the engine checks |
parser_create() | A handle, not 0 | Creates an instance’s state |
parser_destroy(handle) | Frees it | |
parser_reserve_input(handle, byteLength) | A pointer, not 0 unless the length is 0 | A buffer the next call reads its input from |
parser_configure(handle, byteLength) | Status | Reads the codec private data in the input |
parser_maximum_picture_width(handle), parser_maximum_picture_height(handle) | A size | Read right after a successful configure, the largest picture the codec private data declares, such as an AV1 sequence header’s maximum frame size, and right after each picture, the largest picture of its sequence; 0 and 0 for none |
parser_reset(handle) | Drops every picture and parameter set; configure must run again | |
parser_plan(handle, byteLength) | Status | Plans the first picture of the packet in the input, in decode order; its output is the picture’s plan |
parser_plan_next(handle) | Status | Plans the packet’s next picture after a plan or plan_next that returned 3; it reads no input, and its output is that picture’s plan |
parser_plan_unit_count(handle) | A count | The last plan’s units, which parse calls may split |
parser_parse(handle, byteLength, firstUnit, unitCount) | Status | Parses units of the plan in the input; its output is a job |
parser_finish(handle, byteLength) | Status | Finishes the oldest planned picture from its jobs in the input, concatenated in unit order; its output is the picture’s GPU record |
parser_picture_width(handle), parser_picture_height(handle) | A size | Read right after a finish, decode, or decode_next that output a picture: its visible size, or 0 and 0 for none |
parser_picture_format(handle) | A format | Read right after the same calls: the output format the picture decodes into, its chroma format as chroma_format_idc counts it, 0 for 4:0:0, which decodes into 4:2:0, shifted left 8, ORed with its bit depth, 8, 10, or 12; 0 for the session’s format |
parser_picture_repeat_count(handle, repeat) | A count | Read right after the same calls, once for each repeat: directive, repeat counting them from 0 in header order: how many times the picture’s record runs it |
parser_picture_fields(handle) | A picture fields word | Optional. Read right after the same calls: the picture’s fields as reportPictureFields stores them, bit 0 reported, bit 1 interlaced, bit 2 top field first, bit 3 a progressive picture’s repeat first field; 0 reports none |
parser_decode(handle, byteLength) | Status | Plans, parses, and finishes the first picture of the packet in the input on this instance |
parser_decode_next(handle) | Status | Plans, parses, and finishes the packet’s next picture on this instance; it reads no input |
parser_output_pointer(handle), parser_output_byte_length(handle) | The last call’s output | |
parser_event_pointer(handle), parser_event_count(handle) | The last call’s output events, as words | |
parser_error_code(handle), parser_error_value(handle) | The last failure’s code, one the header’s error: lines name, and its failing value |
| Status | Meaning |
|---|---|
| 0 | Success; from a plan, plan_next, decode, or decode_next, the call planned the packet’s last picture |
| 1 | No picture: from a plan or decode, the packet holds none; from a plan_next or decode_next, it holds no more. The call’s events still apply |
| 2 | Blocked, from a plan or plan_next: the call waits until every earlier picture finished, as a codec whose parsing reads earlier pictures needs. A blocked plan kept no state, and the engine plans the packet again; a blocked plan_next kept its place, and the engine calls plan_next again |
| 3 | A picture, and more of the packet’s pictures follow: the engine calls plan_next, or decode_next on one instance |
| -1 | A failure, with its code and value |
A failure’s code decides what follows: a code the header gives any recovery resyncs at the next key packet with a new parser, as Errors and recovery describes, and any other ends the attempt.
A parser that can go on past damage, as FFmpeg’s decoders conceal it, outputs the picture’s record with the damage marked and lets its WGSL conceal it and report a conceal code, rather than fail.
The engine calls plan_next after every 3 until a call returns 0, 1, or -1, and plans no later packet before; a packet holds at most 64 pictures.
A plan without units parses nothing, so its finish receives no jobs.
parser_decode and parser_decode_next run a packet on one instance, as a decoder project’s tests do; every earlier picture finished there, so a blocked decode fails.
A picture’s visible size is its top-left width x height samples, which must lie within the pool’s coded size, or the session fails as visible-rectangle; a report that gives one side without the other fails the attempt.
Presentation scales each frame’s own visible rectangle into the stream’s display area, so a stream whose frame size changes, as AV1’s may, plays at one size; a picture without a report keeps the stream’s visible rectangle.
A repeat count above the directive’s count for the stream runs the directive’s count, so a parser without a smaller one returns 4294967295, and 0 skips the run.
The largest picture the codec private data declares sizes the session, as How a session decodes describes; the decode worker opens its first attempt’s parser before the track is ready, so the ready response carries it.
A picture’s format and its sequence’s largest picture decide whether it needs a session of its own, as Stream changes describes; a format no output format holds, such as 4:2:0 at 9 bits, fails the attempt as parser picture format 0x109.
A picture’s fields word travels with its record to the frame, which the deinterlacing pass reads (see Architecture); a parser without parser_picture_fields reports none, so its frames keep their container’s fields, and a word that sets a bit beyond the four, or any without bit 0, fails the attempt as parser picture fields 0x12.
The engine copies each output and event list out of the instance’s memory before its next call on that instance, and fails the attempt when a pointer and length leave the memory.
The engine runs a parser on these instances, each with its own memory:
- A coordinator instance on the decode worker’s coordinator worker,
webgpu-player/GPUDecoderCoordinator.worker.js, which configures, plans each packet’s pictures as the packet arrives, and finishes pictures one at a time in decode order. Every start and seek opens a new coordinator instance. - One instance on each parse worker,
webgpu-player/GPUDecoderParse.worker.js: one worker per core beyond two, from one to six. A picture’s units split into at most that many nearly equal, contiguous groups, which parse in parallel, and each parse call receives the serialized plan. The coordinator worker keeps its parse workers for later attempts while the package’s id and content hash stay the same. - The decode worker reads packets until their pictures run eight ahead of its credits, a packet still parsing counting as one, so the workers stay busy while the page decodes.
- A worker that answers nothing for 30 seconds is ended, and every pipeline on it fails.
Each picture reaches the page as a packet of its own: its record with its packet’s timestamp, duration, and keyframe flag, its own run of the dispatch sequence into its own output slot, and one video credit.
Only the first picture after a start or a seek carries reset, and the first picture of a packet carries the packet’s HDR10+ state.
An output event is a u32 word: bits 0 to 29 hold a picture’s index, counted from 0 in decode order since the instance started, modulo 2^30, every picture of a packet counting.
Bit 31 drops the picture instead of presenting it, as for a picture its stream marks not for output or an AV1 temporal unit’s hidden frame, and bit 30 is reserved and must be zero.
A picture’s plan or plan_next events and then its finish’s travel with its record; the events of a call that planned no picture wait for the next picture, or for the end of the track.
The session holds each decoded frame until an event names it, and a picture an event drops frees its slot at once.
An event for a picture that already left changes nothing, and an event for a picture not yet decoded fails the attempt as output-event.
When more frames wait undecided than the header’s reorder, the earliest presents, so the pool never fills.
At the end of a track the waiting events apply first, then the frames still undecided present in timestamp order.
A picture’s record carries at most 256 events, its own and those carried to it.
Self-test vectors
A video package may carry known-answer vectors of its own in its webgpu-player.vectors section, at most one per output format.
The engine runs one only when a host calls verifyGPUDecoderPackage(device, package, format) in src/decode/video/gpu/GPUDecoderVerification.ts; playback never runs them, and nothing caches the result.
getGPUDecoderVectorFormats(package) lists the formats a package carries vectors for.
A check decodes the vector’s packets in a session on the device, through the parser when the package has one, reads back frames firstFrame to lastFrame in presentation order, and compares their SHA-256 with the vector’s.
The hash covers each checked frame’s luma plane, then its two chroma planes, cropped to its visible rectangle, rows tightly packed and 16-bit samples little-endian: FFmpeg’s rawvideo output of those frames in the vector’s format, such as yuv422p10le.
A frame’s visible rectangle is the visible size its picture reported, at the top left, or the vector’s, so a vector whose frame size changes checks each frame at its own size.
A check returns passed with the hash, unverified when the package carries no vector for the format, or the check that failed: parser-failed, decoder-recovered with the first recovery, such as decoder-recovered: concealed coefficients: 90112, frames-dropped, frames-missing, slots-exhausted, hash-mismatch, or the session’s failure code.
The section is little-endian u32 words, names and data padded to whole words; src/decode/video/gpu/GPUDecoderVectorSet.ts reads and writes it.
| Part | Words |
|---|---|
Magic WPDV (0x56445057), version 2, vector count | 3 |
| Per vector: its byte length | 1 |
The format name’s length, then the name, such as I420P10 | 1 + name |
| The visible width and height, which the stream facts derive from | 2 |
| The first and last checked frame | 2 |
| The SHA-256 of the checked frames | 8 |
| The codec private data’s length, then the data | 1 + data |
| The packet count, then each packet: its length, flags (bit 0 keyframe), timestamp and duration in microseconds, then its data | 1 + packets |
Audio decoders
An audio decoder’s module decodes packets to planar float PCM, which the engine downmixes, resamples to 48 kHz, and plays like any decoded audio.
The header
An audio package’s decoder section is its manifest, a //! header like a video decoder’s; any text after the header is ignored.
src/decode/audio/AudioDecoderManifest.ts parses it.
| Directive | Required | Value |
|---|---|---|
id | Yes | The package’s id, as Identity describes |
name | Yes | Any text |
decoder | Yes | audio |
accepts | Yes | codec in [<codecs>], by the engine’s audio codec names, such as ac3, eac3, dts, mlp, truehd, wmav1, wmav2, wmapro, wmalossless, or wmavoice |
packet | No | The largest packet it takes, at most 16 MiB; 2 MiB by default |
attribute | Repeatable | <key>, <name>: a codec-specific value the module reports, key 1 to 255 |
option | Repeatable | <key>, <name>: a codec-specific setting a host may set, key 1 to 255 |
error | Repeatable | <code>, <name>: names an error code |
//! id: eac3
//! name: E-AC-3
//! decoder: audio
//! accepts: codec in [eac3, ac3]
//! packet: 2 MiB
//! attribute: 1, libraryVersion
//! error: 1, input
//! error: 2, send
Codec private data
The decode worker reads a track’s codec private data for its codec, as FFmpeg’s demuxers hand it to their decoders, and sends it with the attempt’s package selection to the audio decode worker.
There PackageSoftwareAudioDecoder hands it to audio_decoder_configure once, before the attempt’s first packet; a track without any makes no configure call.
A configure call that fails ends the attempt with the package’s named error.
| Codec | audio_decoder_configure reads |
|---|---|
ac3, eac3, dts, mlp, truehd | Nothing; the decoder finds everything in its packets |
wmav1, wmav2, wmapro, wmalossless, wmavoice | The track’s WAVEFORMATEX: its 18-byte header (format tag, channels, rate, average bytes per second, block alignment, bits per sample, and cbSize, little-endian), then the cbSize bytes after it, the WAVE_FORMAT_EXTENSIBLE fields first when the tag is 0xFFFE; a Matroska A_MS/ACM track’s CodecPrivate up to the end of those bytes, whatever follows, or an AVI stream’s strf chunk, which the worker writes back from the fields the fork parsed |
The audio decoder interface
The module implements version 1 of this interface, which wgpud/include/audio_decoder_interface.h declares for C.
src/decode/audio/AudioDecoderPackageInstance.ts drives it, on the playback worker’s audio decode worker.
| Export | Returns | Does |
|---|---|---|
uint32_t audio_decoder_abi_version(void) | 1 | The interface version, which the engine checks |
handle audio_decoder_create(int codec) | A handle, not 0 | Creates a decoder of the codec at that position of accepts |
void audio_decoder_destroy(handle) | Frees it | |
uint8_t *audio_decoder_reserve_input(handle, uint32_t byte_length) | A pointer, not 0 | A buffer the next call reads its input from |
int audio_decoder_configure(handle, uint32_t byte_length) | 0 or -1 | Reads the track’s codec private data in the input |
int audio_decoder_set_option(handle, uint32_t key, double value) | 0 or -1 | Sets an option the manifest names |
int audio_decoder_send(handle, uint32_t byte_length, double timestamp) | 0, 1, 2, or -1 | Sends the packet in the input, with its timestamp in microseconds |
int audio_decoder_receive(handle) | 1, 0, or -1 | 1 when a block is ready, 0 when the packet has none left |
uint32_t audio_decoder_frame_count(handle) | The block’s frames, 1 to 65536 | |
uint32_t audio_decoder_sample_rate(handle) | The block’s sample rate | |
uint32_t audio_decoder_channel_count(handle) | The block’s channels, 1 to 32 | |
uint32_t audio_decoder_channel_mask(handle) | The block’s layout as a WAVE channel mask | |
float *audio_decoder_channel_pointer(handle, uint32_t channel) | A channel’s samples, 4-byte aligned | |
double audio_decoder_timestamp(handle) | The block’s timestamp in microseconds, or -1 to continue from its packet’s | |
double audio_decoder_attribute(handle, uint32_t key) | An attribute the manifest names, or NaN when it has none | |
void audio_decoder_clear(handle) | Drops the state between frames | |
uint32_t audio_decoder_error_code(handle), uint32_t audio_decoder_error_value(handle) | The last failure’s code, one the manifest’s error: lines name, and its failing value |
audio_decoder_send returns 0 when it accepted the packet, 1 when it dropped it, 2 while it waits for a sync point, or -1 when it failed.
A dropped packet, and one before a sync point, decode to nothing and fail nothing.
After an accepted packet the engine calls audio_decoder_receive until it returns 0, and copies each block out of the instance’s memory: its channels’ planar floats, its layout, its timestamp, and every attribute the manifest names.
A packet’s blocks number at most its byte length, and their frames at most 65536 per byte.
A block’s channel mask must be one the engine mixes: mono, stereo, 3.0, 2.1, 3.0 with a back center, 5.1 with back or side speakers, 6.1, 7.1, or one of FFmpeg’s height layouts, 5.1.2 (0x503F), 5.1.4 (0x2D03F), 7.1.2 (0x563F), and 7.1.4 (0x2D63F), or 5.1 with side speakers and the top front pair (0x560F), which FFmpeg’s E-AC-3 decoder reports for a dependent stream with heights.
A height layout’s channels follow its mask bits too: the floor’s, then top front left and right, then top back left and right.
An E-AC-3 or TrueHD package’s block may have a height mask whatever channel count the track declares; any other package’s blocks must fit its codec’s input routes (see Audio routes).
Every start and seek is a new attempt on a new instance, which audio_decoder_destroy ends, so a decoder never sees a jump in its packets.
A module may also export int audio_decoder_drain(handle), which returns 0 or -1.
The engine calls it once at the end of a track, after the attempt’s last packet, then calls audio_decoder_receive until it returns 0, so a decoder that holds frames back, as FFmpeg’s do, outputs them; at most 64 blocks follow.
A drained block without a timestamp of its own continues from where the last block ended, or, when no block came before it, as after a seek onto a track’s last packet, starts at the last packet’s time.
A module without the export ends the track at its last packet’s blocks, and findMissingAudioDecoderExports never asks for it.
Object audio
A package that renders object audio, such as Dolby Atmos in E-AC-3 and TrueHD, declares two options and one attribute by these names; their keys are its own.
| Name | Directive | Value |
|---|---|---|
renderChannelMask | option | The WAVE channel mask of the playback’s output layout: stereo 0x3, 5.1 0x60F, 7.1 0x63F, 5.1.2 0x503F, 5.1.4 0x2D03F, 7.1.2 0x563F, or 7.1.4 0x2D63F |
renderBinaural | option | 1 when the output is stereo and the host asked for binaural headphone rendering, else 0 |
objectCount | attribute | Per block: the dynamic objects the block renders, 0 for a stock decode |
The audio decode worker sets both options with audio_decoder_set_option after audio_decoder_configure and before the attempt’s first packet (PackageSoftwareAudioDecoder.setRenderTarget), and sets renderBinaural again between two packets when the host switches binaural rendering live.
A live change of the output layout starts a new attempt on a new instance, which takes the new mask before its first packet.
A package that declares neither option is never asked, and one that declares only renderChannelMask gets no renderBinaural.
The package renders object audio into exactly the mask’s layout, or into binaural stereo (0x3) with renderBinaural 1, and reports its blocks with that mask.
Where the target adds nothing to what its stock decode outputs, the package outputs the stock decode with its own mask instead, and a stream without objects always does; the engine plays either, folding or mixing it to the output layout by channel name.
The worker reports each change of the blocks’ objectCount to the page, whose decode telemetry shows it as decodedAudioObjectCount; a count above 1024 fails the attempt.
A block with objects passes through the output stage’s peak limiter, since the package sums them unlimited.
Hosts
Loading and keeping packages
loadDecoderPackage(bytes)returns aVideoDecoderPackageor anAudioDecoderPackage: its id, content hash, name, manifest, compiled module, memory limits, and, for video, its vectors section. The compiled module crosses to the engine’s workers, which instantiate it with memory of their own.DecoderPackageStoreinsrc/decode/packages/DecoderPackageStore.tskeeps package files in IndexedDB by package id, under a database name the host chooses.add(fileName, bytes)loads a file with the engine’s checks before it stores it,remove(id)deletes one, andloadAll()loads every stored file, oldest first, with a failure for each one that no longer loads.load(id)loads the one stored file of an id, or null when the store holds none, so a host that caches packages it fetches compiles only the ones a playback needs. A file of an id the store holds is a new version of that package: it replaces the stored file and keeps its place in the order. The store’s schema is version 2, which keys files by id; a database of an earlier version opens empty, without its files.- Where a host gets package files is its own choice: the engine fetches none.
The decoder projects in the decoders repository, checked out at the engine’s
wgpud/, build packages a host may offer, and that repository’s releases publish them (see WebAssembly build).
Offering packages to a playback
getGPUVideoDecoderOffers(presenter, videoPackages, options)insrc/presentation/PresentationSetup.tsreturns the packages whoseacceptsnames the stream’s codec and whoseoutputlists its format, in the host’s order, with the device’s buffer limit, the smaller of its largest buffer and its largest storage binding. The host passes them as the eligibility’sgpuVideoDecoders.- The host passes its audio packages, in its order, as the eligibility’s
audioDecoders. With them it passesaudioRenderTarget, the speaker layout it chose for the destination and its binaural headphone setting, so that a Dolby Atmos stream whose objects the target needs takes a package that declaresrenderChannelMaskahead of the browser’s own decoder (see Audio routes). The eligibility then setsaudioRendersObjects, and the host plays the track to the whole speaker layout. - A host that fetches packages only when a playback needs them asks first what the playback decodes:
getGPUVideoDecodeTarget(options)insrc/capability/CustomPlaybackEligibility.tsnames the video’s codec and frame pool format, andgetPlaybackAudioCodec(options)the codec of the audio track the eligibility selects. - Before a playback, a host passes what its video packages decode as
hasEligibleCustomVideoRoute‘sgpuVideoDecoderSummaries: their codecs and output formats, and where it has them, each package’s header and the device’s buffer limit, so the summary runs the offers’ fit check; see GPU decoders.parseGPUDecoderHeaderinsrc/decode/video/gpu/GPUDecoderManifest.tsreads a header without its body, such as a server’s catalog carries, with every check of the manifest parser but the body’s. - The eligibility prefers a native WebCodecs video route; otherwise it takes the first offer whose
acceptsconditions take the stream and whose pool,state,buffer:s, and packet ring each fit the buffer limit, on theframe-poolroute with thegpudecoder backend.findGPUDecoderStreamFitFailureinsrc/decode/video/gpu/GPUDecoderStreamFit.tschecks them at the container’s size. An HDR stream takes a package only when the host tone-maps frame-pool HDR, theallowFramePoolHDRpresentation option. - For audio it prefers passing the track through to the browser, then the browser’s own decoder, then the first package whose
acceptsnames the track’s codec, on the decoded PCM route. - The output format of a stream is its HEVC range extension format when it has one, otherwise the format that holds its pixel format, such as I422 for
yuv422p, or 4:2:0 at its bit depth when the stream names no pixel format. A 4:0:0 pixel format,gray,gray9,gray10, orgray12in either byte order, takes the 4:2:0 format of its depth, and only a decoder whose header declaresmonochromeis offered it. A JPEG 2000 stream inxyz12letakes I444P12, which a JPEG 2000 decoder writes as limited-range BT.709 Y’CbCr of FFmpeg’s conversion of its X’Y’Z’ to R’G’B’. - The controller takes
gpuVideoDecodeSessionFactory: createGPUVideoDecodeSessionFactory(presenter, { slicing }), whereslicingis the host’s choice of sliced decode submits, andplay()takes the eligibility’sgpuVideoDecoderandaudioDecoder. - The host calls
precompilePredictedGPUVideoDecoder(presenter, offers, options)without waiting as its negotiation starts, andprecompileGPUVideoDecoder(presenter, eligibility)without waiting before the rest of its setup, so the decoder compiles while the negotiation, the presentation, the controller, and the worker’s open of the file proceed. - A host may call
warmGPUVideoDecoders(presenter, videoPackages)after its background probe warm-up with the packages it already holds; it downloads nothing. - An audio track switch passes the new track’s
audioDecoderfrom its own eligibility tosetAudioStreamIndex, with itsaudioMetadataLayout(getAudioMetadataLayout); the previous track’s package and layout never carry over.
Verifying a package
Verification is the host’s choice and never the engine’s.
A host that wants a package checked on the user’s device calls verifyGPUDecoderPackage for each format getGPUDecoderVectorFormats lists, before it offers the package, and decides what a failure means.
Each call decodes again, so a host that keeps results keys them itself, by the package’s id and content hash and the device, since a new version of the id needs its own.
Failures
Every failure carries a code and a terse detail with the failing value.
- Loading: the
DecoderPackageErrorcodes above, such aspackage-manifest: id-syntax: 'AV1' (line 1). - A video session: its failure code, such as
compile-errorwith the body’s line,dispatch-count,decoder-errorwith the name and value of an error without a recovery,output-event,packet-size: ring 1073741824 > 268435456, orvisible-rectangle: picture 1920x1088 exceeds 1280x768, which ends the route’s attempt as any failed route does. - A recovery is no failure: its code and detail, such as
concealed coefficients: 90112orresynced parser sliceHeader: 3, count in the feed’s telemetry, and the playback goes on. - A stream change:
accepts: picture 4096x2160 in pool 4096x2176 at 8 bitswhen the decoder takes no pool for the picture, or the new session’s creation failure, such asformat-unsupported: I444P12 not in I420, I420P10, which ends the attempt as the first session’s would. - A parser:
parser <error name>: <value>, or the step that failed, such asparser reserve_input refused 4096 bytes,parser events 300 > 256,parser pictures 65 > 64 in one packet,parser plan_next status 7,parser picture format 0x109, orparser maximum picture size 1920x0. - An audio decoder:
audio decoder <error name>: <value>, which ends the audio attempt.
Bundled passes
wgpup/ is a submodule of the passes repository, alchemyyy/webgpu-player-passes, which holds the pass projects: WGSL passes the engine bundles for a host to offer as optional user passes.
Its name marks them as this player’s own module type, as wgpud/, the decoders repository’s submodule, marks the decoder projects.
The engine never loads a pass on its own: a host adds one to its users’ presentation preset through WebGPUPresenter.setPresentationPreset, as it adds any pass file a user picks.
Projects
A pass project is a folder of wgpup/ whose top-level WGSL files are its pass files.
Each file holds one or more passes in the pass file format, which run in file order, and no two projects use the same file name.
| Project | Pass files | What it does |
|---|---|---|
sharpen | sharpen.wgsl | A 3x3 kernel on the OUTPUT stage that moves each pixel away from the mean of its 8 neighbors, by its strength parameter |
saturation | saturation.wgsl | A color pass on the OUTPUT stage that scales chroma around BT.709 luma, by its saturation parameter |
temporal-blend | temporal-blend.wgsl | A kernel on the RGB stage that blends toward the previous frame while history is valid, by its blend parameter |
smooth-motion | smooth-motion.wgsl | A kernel on the OUTPUT stage that draws at the display rate and blends the two frames a refresh spans by the share each covers; see Smooth motion |
catmull-rom | catmull-rom.wgsl | A 4x4 Catmull-Rom scaler that replaces the built-in bilinear scaler; its taps read texel centers, so the result does not depend on the input’s filtering |
anime4k | anime4k-l.wgsl, anime4k-ul.wgsl | Anime4K v3.2’s Upscale+Denoise CNN x2 L and UL networks, on the PRESCALE stage when the output is more than 1.2 times the source on each axis |
nis | NVScaler.wgsl, NVSharpen.wgsl | NVIDIA Image Scaling v1.0.2, a vendor-neutral spatial scaler with sharpening, on the LUMA plane: the scaler when the output is larger than the luma plane, the sharpener when they match |
Every pass file keeps the rules that test/presentation/passes/PostprocessingPasses.test.ts checks: each pass parses, defines its shape’s entry point, reads exactly the parameters it declares, reads each input through its accessors, and reaches engine data only through the presentation globals.
The presenter, manifest, and pipeline graph tests run the four simple passes as well, and the presenter tests run smooth motion.
Build and offer
A checkout installs the pass files of the pinned passes release into bin/wgpup/ with npm run pinned-releases, beside the served assets but outside their asset key (see Pinned releases).
npm run passes (scripts/pass-files.mjs) copies every pass file of the wgpup/ submodule there instead, flat, replacing what was there; it fails on two files of one name, or when the submodule holds no pass file, as before git submodule update --init wgpup.
The passes repository’s release workflow collects the same files, flat, with the same check.
A host serves that folder and offers its files, and the sandbox’s shader panel lists them through its dev server (see Sandbox).
Anime4K
wgpup/anime4k/glsl/ holds the two upstream mpv shaders, copied unmodified from bloc97/Anime4K at commit 7684e9586f8dcc738af08a1cdceb024cc184f426.
Its PROVENANCE.txt lists their SHA-256, which the pass tests check, and wgpup/licenses/Anime4K-LICENSE is the upstream MIT license.
The generators beside them turn a shader’s convolution and depth-to-space passes into a pass file, with the weights copied verbatim:
generate_anime4k_wgsl.pyfuses the whole network into one compute pass: each workgroup evaluates a 16x16 source tile in workgroup memory, with a halo of one pixel per layer, and writes the 2x image with the bilinear-upscaled input added. It takes networks of 8-channel hidden layers whose tiles fit the default 16 KiB of workgroup storage, as L’s do.generate_anime4k_wgsl_chain.pywrites one compute pass per layer into one file: each layer stores its 4-channel parts side by side in an rgba16float texture, and the last layer does the depth-to-space step and adds the bilinear-upscaled input. UL’s eight layers become the eight passes of its file.
The generated pass files are committed, so a build needs no Python.
tools/convert_mpv_shader.py converts these shaders too, pass for pass (see Migrate an mpv shader to a wgpup); the generators fuse each network into fewer passes instead.
To regenerate them, run from the engine root:
python wgpup/anime4k/generate_anime4k_wgsl.py wgpup/anime4k/glsl/Anime4K_Upscale_Denoise_CNN_x2_L.glsl wgpup/anime4k/anime4k-l.wgsl
python wgpup/anime4k/generate_anime4k_wgsl_chain.py wgpup/anime4k/glsl/Anime4K_Upscale_Denoise_CNN_x2_UL.glsl wgpup/anime4k/anime4k-ul.wgsl
Smooth motion
In smooth-motion.wgsl, each display refresh shows the frames it spans, the newest and the one before it, blended by the share of the refresh each covers, as libplacebo’s oversample mixer, which mpv documents as SmoothMotion, does.
A refresh within one frame shows that frame untouched, and so does every refresh while the clock stands still, so a paused picture shows one frame.
The blend runs in linear light of the display’s sRGB encoding, extended past 1 and mirrored below 0 on an HDR canvas.
Whether frames blend is decided on every draw, from the refreshes a frame lasts: the media time between the stream’s recent frames over the media time a refresh covers at the clock’s rate, so a playback rate, a new frame rate, or another display takes effect within frames.
Its mode constant picks when frames blend:
- 0, the default: only where there would be motion judder without it, when a frame lasts no whole number of refreshes within
tolerance. - 1: also where it does, when a frame lasts other than one refresh within
tolerance. - 2: always.
tolerance is relative and defaults to 0.002, so 23.976 fps at 24 Hz counts as a whole multiple.
A refresh that need not blend shows the frame the clock is in, as it would without the pass; so does every refresh until the display is measured, and, in modes 0 and 1, before a frame’s duration is known.
- It hooks
OUTPUTin thedisplaydomain, so each frame is tone mapped with its own metadata before it enters history. - A deinterlacing plan leaves it out as
display-rate-unavailable. - It costs a ring of two output-size
rgba16floatimages and one pass per refresh; the passes before it run once per frame.
NIS
wgpup/nis/glsl/ holds agyild’s mpv port of NVIDIA Image Scaling v1.0.2, NVScaler.glsl and NVSharpen.glsl, copied unmodified, CRLF line endings included, from the port’s gist at revision 05f00864228871ffd157daa9beb2db8fa7412cfa.
The passes repository’s .gitattributes keeps those files and wgpup/licenses/NIS-LICENSE byte for byte, so their SHA-256, which PROVENANCE.txt lists and the pass tests check, stays upstream’s.
NIS-LICENSE is the MIT license of NVIDIAGameWorks/NVIDIAImageScaling at commit 35e13ba316c98eeecf16f37eae70ce88019911f6, which the port’s headers carry.
- The port’s scaler writes 32x24 output pixels per workgroup with 21776 bytes of workgroup storage, more than the 16384 bytes WebGPU guarantees.
generate_nis_wgsl.pyconverts it at a 32x16 block, 16208 bytes, where each of its 256 threads still writes two pixels; it changes the block height and theCOMPUTEdirective in memory and leaves the upstream file alone. - The sharpener fits as it is, so
tools/convert_mpv_shader.pyconverts it directly. - The converter tests regenerate both passes and require the committed files, so a converter change that alters them shows up there.
To regenerate them, run from the engine root, with naga-cli 30.0.1 installed (see Migrate an mpv shader to a wgpup):
python wgpup/nis/generate_nis_wgsl.py wgpup/nis/glsl/NVScaler.glsl wgpup/nis/NVScaler.wgsl
python tools/convert_mpv_shader.py wgpup/nis/glsl/NVSharpen.glsl wgpup/nis/NVSharpen.wgsl
Create a new WebGPU pass
A pass is a WGSL file with a //! header that the presentation planner places into a route’s pipeline.
This chapter is the reference for the pass file format: the header, the code the engine generates around a pass, and the reasons a pass is left out of a plan.
src/presentation/passes/PassManifest.ts parses the header, PassPrelude.ts generates the code around the body, and PresentationPlanner.ts places the pass.
You need
- A checkout set up as in Set up a checkout, for the tests and the sandbox.
- A browser with WebGPU, to see the pass run in the sandbox.
Steps
- Create
wgpup/<project>/and a pass file in it,<file>.wgsl, under a file name no other project uses. Start from the bundled pass of the same shape:sharpen.wgslfor a kernel,saturation.wgslfor a color function,catmull-rom.wgslfor a scaler,temporal-blend.wgslfor a history read,smooth-motion.wgslfor a pass that draws at the display rate, and the Anime4K files for compute passes. - Write the header, then a blank line, then the body with the entry point of its shape (see Shapes).
- To try it before building, run
npm run sandboxand drop the file on the shader panel. The panel shows a plan state per pass and an input per parameter, and the pipeline graph lists a pass the plan left out asreason: detail. - Add the project to
EXPECTED_PROJECTSintest/presentation/passes/PostprocessingPasses.test.ts, and runnpx vitest --watch=false test/presentation/passes. - Run
npm run passes, which copies the file intobin/wgpup/, where a host serves it. The sandbox lists that folder on each request, so the file then shows in the shader panel’s bundled pass selector. - Add the project to the table in Bundled passes.
A pass a user supplies at run time follows the same format, and skips steps 4 to 6.
To start from an mpv user shader instead, convert it (see Migrate an mpv shader to a wgpup).
A host loads any pass file through WebGPUPresenter.setPresentationPreset, as a PresentationPresetFile of its source and its stored parameters values.
Check the result
- The pass tests check every bundled pass file: each pass parses, its body starts with a blank line and defines its shape’s entry point, it reads exactly the parameters it declares as
parameters.<name>, it reads each input through its accessors, and it reads no@group,@binding,renderSettings,textureSample, ortextureLoad. Of thepresentationuniform it may read only the fields in the test’sPRESENTATION_FIELDS; add a field there when a pass needs it. - The sandbox’s pipeline graph shows the pass as a node, or as a fused color step of a neighboring pass, and lists it beside the graph as
reason: detailwhen the plan left it out.
Shapes
| Shape | Entry point | Reads | Writes |
|---|---|---|---|
color | fn apply(color: vec4f, position: vec2f) -> vec4f | One value at one position | One value; always fused into a neighboring pass |
kernel | fn kernel(coordinate: vec2f) -> vec4f | Neighboring pixels of its inputs | One texture |
scaler | fn scale(coordinate: vec2f) -> vec4f | Neighboring pixels of HOOKED | Exactly the output rectangle, in place of the built-in bilinear scaler |
compute | fn compute(id: vec3u) | Its inputs | One storage texture, through TARGET_store |
- A coordinate is a normalized position in the hooked input, and a position is the target pixel’s center in pixels.
- A compute pass’s
idis the global invocation ID, and its dispatch rounds up, so checkid.xyagainstTARGET_size()before storing. - The input accessors read with
textureSampleLevelortextureLoad, so they work in compute passes, where WGSL has notextureSample. - WebGPU’s default compute limits apply: 256 invocations per workgroup, and 16 KiB of workgroup storage.
Stages and color domains
The hook names the stage a pass attaches to, in pipeline order:
LUMA,CHROMA, thenNATIVE, the plane stages, run on the signal’s planes before it converts them into RGB (see Planes).RGB, thenPRESCALE, run before scaling, whereHOOKEDstarts at the source’s size, or at luma’s when plane passes replaced a plane. A pass there may change the size, as the Anime4K passes double it, and the scaler takes whatever size it is given.SCALEholds the scaler: the first user scaler that can run replaces the built-in bilinear one, and every other scaler is left out asextra-scaler. Kernels and color functions onSCALErun after the scaler.OUTPUTruns at the output’s size, before the canvas.
Passes on one hook run in preset order, and a file’s passes in file order.
The domain names the color state a pass needs on its input; the planner runs the built-in color functions, transfer decode, tone mapping, and output encode, up to that state before the pass:
| Domain | Input |
|---|---|
any | Whatever state the pipeline is in; the default |
source | Encoded RGB in the source’s primaries and transfer function, as the signal step produces it |
linear | Linear light in nits, in the source’s primaries |
display | Values encoded for the output target |
sdr | display while the output is SDR; on an HDR output the pass is left out as sdr-on-hdr-output |
The planner only moves forward along the color track, so a pass whose domain the pipeline has already passed, such as source after any built-in step has run, is left out as domain-unreachable.
Dither always runs in the pass that writes the canvas, since its noise must not pass through a scaler.
The plane stages come before the color track, so a pass there takes the domain any; any other is left out as domain-unreachable.
Planes
The plane stages hold the planes as mpv’s do, as normalized code values: a code divided by the largest code of its bit depth, so a limited-range black is 16/255 at 8 bits.
| Stage | HOOKED | Size |
|---|---|---|
LUMA | Y in .x | The frame’s visible size in samples: luma.width by luma.height |
CHROMA | Cb in .x and Cr in .y | The chroma plane’s: chroma.width by chroma.height, half luma’s across and down at 4:2:0, rounded up |
NATIVE | Y, Cb, and Cr in .xyz, chroma resampled onto luma’s grid | Luma’s |
- A pass that writes its plane stage’s image replaces that plane; the plan then merges the planes and converts them with the signal’s own range and matrix, reading a plane no pass replaced from the source, so it converts as the signal does.
- Chroma resampled onto luma’s grid, by
NATIVEor the merge, is read bilinearly through both images’ offsets; a chroma upscaler that writes chroma at luma’s size withoffset: alignis read texel for texel. - A plane is read apart from the source only when a pass reads it; a plan without plane passes reads the source as it always does.
- The planes hold the frame’s samples, so an anamorphic stream’s planes keep its non-square samples, and the image the planes merge into starts at luma’s size; the scaler takes it to the display’s shape.
- Chroma’s size is luma’s over the subsampling divisor, rounded up, and its extent on the reference grid is luma’s over the divisor exactly, as libplacebo sizes a plane’s rect; an odd luma size leaves chroma’s last pixel half outside, so chroma’s scale stays exactly the subsampling’s.
- The raw and frame-pool routes hold the decoded planes.
- The external routes import a frame the browser already converted to RGB, so they derive the planes from it through the conversion that made it: the stream’s own matrix and range, at its bit depth, which the merge inverts exactly. The external HDR route reads the codes its signal recovers from the browser’s BT.709 texture, the engine’s own labeling of the stream. Chroma takes the stream’s subsampling from its decoder configuration, read at each chroma sample’s site, so a chroma upscaler runs there too; a stream that declares none has chroma at the image’s size.
- A deinterlacing plan derives the planes from its deinterlaced image through the same conversion, chroma at the stream’s subsampling.
- Dolby Vision routes have no planes to offer, so a plane pass, and a pass that reads a plane stage, is left out there as
planes-unavailable.
Display rate
A pass with rate: display draws at the display’s refresh rate rather than once per frame, as smooth-motion.wgsl does.
- The engine draws it on every refresh from history, and the frame loop takes frames one refresh ahead of the clock, so the frame that starts within a refresh is already in history when the refresh draws.
HOOKEDis the newest frame, andHOOKED[-1]the frame before it.presentation.refreshSecondsis the media time one refresh covers, andpresentation.targetOffsetSecondsthe clock time the draw presents at minustimestampSeconds; the newest frame’s share of the refresh isclamp((targetOffsetSeconds + refreshSeconds) / refreshSeconds, 0, 1).refreshSecondsis 0 until the display rate is measured, and while the clock stands still, as when paused, when a refresh shows the frame the clock is in: the newest oncetargetOffsetSecondsreaches 0, else the one before it. The frame loop’s tick after a pause redraws at the stopped clock, so a paused picture shows one frame.presentation.frameDurationSecondsis the media time between the stream’s frames, a line fitted through the recent new frames’ timestamps, and 0 before a frame’s time is known: the first frame after a seek is timed by its own duration, if it has one.frameDurationSeconds / refreshSecondsis the refreshes a frame lasts at the clock’s rate, by whichsmooth-motion.wgsldecides on each draw whether frames blend, so a playback rate or a new frame rate takes effect within frames.- A draw from history skips the passes that read the source and every pass before the first display-rate pass, whose outputs no draw of the frame changes, so a heavy pass before it runs once per frame.
- The pass that reads the source then writes a texture rather than the canvas, and a color pass at the display rate gets a pass of its own rather than fusing into it.
- A plane stage reads the source, which a draw from history skips, so
rate: displayonLUMA,CHROMA, orNATIVEis a header error. - Where frames are timed by the browser’s video element, or the plan deinterlaces, whose history holds fields rather than the frame after the shown one, the pass is left out as
display-rate-unavailable, with the detailvideo-elementordeinterlace.
Offsets
Every image the plan holds has an offset: the pixel of the image, in its own pixels, that the reference grid’s origin falls on, as libplacebo’s texture rects place them.
- The source’s luma and the signal’s RGB have the offset 0, and chroma has its siting: 4:2:0 chroma at the location the stream declares, and 4:2:2 chroma on its left luma column. Left-sited chroma, H.264’s and HEVC’s default and the engine’s where a stream declares no location, has (0.25, 0), half a luma pixel; top-left chroma, as BT.2020 sites it, has (0.25, 0.25).
- A kernel or compute pass’s output has the hooked image’s offset scaled by the output’s size over the hooked image’s, plus its declared
offset, or 0 withoffset: align. - The engine never shifts a read:
<P>_texreads where the pass asks, and<P>_offset()gives the input’s offset, so a pass that aligns itself readsHOOKED_tex(coordinate + HOOKED_offset() * HOOKED_pt()), as libplacebo’sOFFSET ALIGNmovesHOOKED_pos. - The scaler corrects its image’s offset within its own resample, built-in or user, so an image that a pass doubled with
offset: -0.5, -0.5lands where the source did.
Header
A pass is a header, a run of lines that start with //!, and its body, which runs up to the next line that starts with //!.
A file holds one or more passes, as mpv’s user shaders do, so a network of several passes ships as one file, and any texture blocks its passes bind (see Texture blocks).
//! name: Sharpen
//! shape: kernel
//! hook: OUTPUT
//! input: HOOKED
//! output: HOOKED.size, rgba16float
//! domain: sdr
//! param strength: f32 = 0.5 in [0, 1]
fn kernel(coordinate: vec2f) -> vec4f {
...
}
| Directive | Shapes | Value |
|---|---|---|
name | All, required | The name in reports and errors, at most 64 characters; unique within a preset |
shape | All, required | color, kernel, scaler, or compute |
hook | All, required without replaces | LUMA, CHROMA, NATIVE, RGB, PRESCALE, SCALE, or OUTPUT; a scaler hooks SCALE, and a color pass no plane stage |
replaces | Color | The built-in color function it runs in place of: builtin.transfer-decode, builtin.tone-map, builtin.output-encode, builtin.display-controls, or builtin.dither |
domain | All | any, source, linear, display, or sdr; defaults to any |
input | Kernel, scaler, compute; repeats | <NAME>, or <NAME>[-<N>] for the frame N back, N from 1 to 8 |
save | Kernel, compute | A name for its output, which later passes read as an input |
output | Kernel, compute; required for compute | <NAME>.size, <format> or <width>, <height>, <format> |
components | Kernel, compute | 1, 2, 3, or 4, the channels it needs; the planner takes the format from output alone |
offset | Kernel, compute, on a hook before SCALE | <x>, <y> decimals, the output’s offset in its own pixels beyond the hooked image’s, or align; see Offsets |
rate | All | frame or display, how often the pass draws; defaults to frame; see Display rate |
when | All | A condition on the global facts and the pass’s own parameters; when it is 0, the plan leaves the pass out as condition-false |
workgroup | Compute, required | <x>[, <y>[, <z>]] positive integers, 1 where omitted; x and y at most 256, z at most 64, and at most 256 invocations in all |
dispatch | Compute | <x>[, <y>[, <z>]] expressions, 1 where omitted; defaults to ceil(<output width> / x), ceil(<output height> / y), 1 |
bind | All; repeats | The name of a texture block of the file, which the pass reads; see Texture blocks |
param | All; repeats | param <name>: <type> = <default> in [<minimum>, <maximum>], a uniform |
const | All; repeats | const <name>: <type> = <default> in [<minimum>, <maximum>], a constant the plan compiles in |
- A header line is
//! <directive>: <value>, with any whitespace around the tokens, and\r\nline endings read as\n. - Only
input,bind,param, andconstrepeat; an unknown, repeated, or empty directive is an error, as is a directive that the shape does not take. - Only blank lines may come before a file’s first header, and every line that starts with
//!belongs to a header, so a body cannot hold one. - Each pass of a file parses on its own, so a malformed header leaves the file’s other passes in place. An error carries the line of its directive, or the header’s first line for a missing required directive, counted from the file’s first line.
- An identifier is
[A-Za-z_]\w*.
Inputs and outputs
NAMEisHOOKED, the image at the hook, a stage name, which reads that stage’s final image and must not come after the hook, or a name an earlier pass saved.- A pass without inputs reads
HOOKED, and a scaler reads nothing else. - No input pairs a name and a history depth twice.
- A saved name is not
HOOKEDor a stage name, and no texture name issource,output,luma, orchroma, whose sizes are global facts, or ends in_history<digits>, which history accessors use. - A compute pass’s input is never
TARGET, the prefix of its storage target. - In
output,<NAME>isHOOKED, an input,source,output,luma, orchroma, and a comma inside parentheses belongs to a function call. - A kernel’s output defaults to
HOOKED.size, rgba16float, and its format isrgba16float,rg16float,r16float,rgba32float,r32float,rgb10a2unorm, orrgba8unorm. - A compute pass writes a core storage format:
rgba16float,rgba32float,r32float, orrgba8unorm. - An output’s width and height round to whole pixels within 1 and the device’s largest texture dimension, and each dispatch count rounds up within 1 and 65535; otherwise the pass is left out as
output-sizeorworkgroup-count.
Expressions
when,output, anddispatchtake expressions of numbers, identifiers, parentheses,abs,ceil,floor,max,min,round, and the operators! - * / % + < <= > >= == != && ||; a comparison or logic operator yields 1 or 0.- Every expression reads the global facts,
source.width,source.height,output.width,output.height,luma.width,luma.height,chroma.width,chroma.height,frame.rate,display.rate,hdr.source,hdr.output, andrgb.source, all fixed at plan time. The HDR facts are 1 or 0, and the luma and chroma sizes are the planes’ (see Planes), sochroma.width < luma.widthholds for subsampled chroma.frame.rateis the stream’s declared frame rate, its average frame rate, else its real frame rate when the average is missing or at least 1000, and 0 when it declares none.display.rateis the display’s refresh rate in Hz, which the presenter measures from the frame loop’s animation frames, and 0 until measured; the presenter plans again when the measurement moves more than 0.1% from the rate the plan read, while a pass’swhenreads it, and a pass that decides on each draw readspresentation.refreshSecondsinstead.rgb.sourceis 1 where the source codes R, G, and B planes, where mpv fires itsRGBhook rather thanLUMAandCHROMA; no route presents such video, so it is 0 on every route. whenalso reads the pass’s own parameters and constants by name, at their current values, and the presenter plans again when one that awhenreads changes.outputanddispatchalso read<NAME>.widthand<NAME>.heightforHOOKEDand each input, by its base name for a history input.- Anything else that varies per frame belongs in the body, which branches on a uniform.
Parameters
- A parameter’s name is a unique identifier, its type is
f32,i32, oru32, and its numbers are decimals with an optional sign, fraction, and exponent. - Its default lies within its range, every number fits its type, and a pass has at most 32 parameters and constants together.
- The body reads a
paramasparameters.<name>. It is a uniform, soWebGPUPresenter.setPresentationParameterchanges one from the next frame without rebuilding a pipeline, rounding integers and clamping to the range. - The body reads a
constas<name>, a WGSL constant at its current value, so it may size an array or fold into other constants. A change builds a new plan, whose modules compile the new value, as does a change to aparamthat the pass’swhenreads. - The passes of a file share each parameter’s value, so passes that declare one parameter declare it alike, of the same kind.
Texture blocks
A file may hold texture blocks beside its passes, such as a 3D LUT or a table of weights, so a host keeps storing one file per pass. A block is a header that names a texture, then its data:
//! texture: LUT
//! size: 33, 33, 33
//! format: rgba16float
//! filter: linear
//! edge: clamp
AAA8AAA8AAA8ADw...
| Directive | Value |
|---|---|
texture | Required: the name passes bind it by, an identifier other than HOOKED, a stage name, or a global size’s name |
size | Required: <width>[, <height>[, <depth>]] positive integers, which give it one, two, or three dimensions |
format | Required: r8unorm, rg8unorm, rgba8unorm, r16float, rg16float, rgba16float, r32float, rg32float, or rgba32float |
filter | linear or nearest, the default |
edge | clamp, the default, repeat, or mirror: how a read beyond the edge wraps |
- The body is the texels’ bytes in base64, whitespace ignored: little-endian, packed tightly in the format, x fastest, then y, then z. It fills the size in the format exactly.
- A block is not a pass; a pass reads it after
bind: <NAME>, under a name no input of the pass takes, and a file of blocks alone has no pass. - A block that does not parse fails the passes that bind it, with its own line and message.
- The engine uploads a block once, when a plan first binds it, keyed by a content identity of its size, format, and data, so a new version of the file replaces the old upload once no preset holds it; there is no count or LRU limit.
- A block beyond the device’s largest texture of its dimensions, 2D for one and two, is left out as
texture-size, and a linear filter on a 32-bit float format a device cannot filter astexture-format. tools/convert_cube_lut.pyturns a.cube3D LUT into a file of one color pass that grades through it (see Tools).
Generated code
The engine owns every binding, and generates around each body:
- For each input, the accessors
<P>_tex(coordinate: vec2f) -> vec4f, a bilinear read at a normalized coordinate,<P>_texOff(coordinate: vec2f, offsetInPixels: vec2f) -> vec4f,<P>_pt() -> vec2f, the size of one texel,<P>_size() -> vec2f, and<P>_offset() -> vec2f, the input’s offset in its own pixels (see Offsets).<P>is the input’s name, or<NAME>_history<N>for<NAME>[-N], as inRGB_history1_tex. A format that cannot be filtered, such asr32float, reads with four loads and a manual blend, so the accessor behaves the same. - For a compute pass,
TARGET_size() -> vec2uandTARGET_store(position: vec2u, value: vec4f), which runs the color steps fused after the pass before it stores. - For each texture block it binds,
<NAME>_tex(coordinate), a read through the block’s filter and edge mode at a normalized coordinate,<NAME>_size(), and<NAME>_load(texel), one texel clamped to the block. A coordinate isf32,vec2f, orvec3f, and a texeli32,vec2i, orvec3i, by the block’s dimensions. - The
parametersuniform, one 32-bit scalar per declaredparamin order, and eachconstas a WGSL constant. - For a body that calls them without declaring them itself:
linearize(color: vec4f) -> vec4fanddelinearize(color: vec4f) -> vec4f, the source’s own transfer function, compiled from the code of the built-in transfer decode and its inverse, in mpv’s scale of linear light, where 1.0 isreferenceWhiteNits()nits.referenceWhiteNits() -> f32, the nits of mpv’s linear 1.0: the configured SDR white for an SDR source, BT.2408’s HDR reference white, 203 nits, for HDR. A pass of thelineardomain reads nits, so it divides by it to work in mpv’s scale.rgbToPlanes(color: vec3f) -> vec3fandplanesToRGB(planes: vec3f) -> vec3f: ‘source’ RGB to the normalized Y, Cb, and Cr plane values of the source’s own matrix and range, and back, each the other’s exact inverse.
- The
presentationuniform, whose frame globals aresourceSize,outputSize, andinputSizeasvec2f,timestampSeconds,frameIndex,historyValid,skippedFrames,fieldState,refreshSeconds,targetOffsetSeconds, andframeDurationSeconds.historyValidcounts the earlier frames history holds: 0 after a reset, rising by one per new frame to the plan’s deepest history, so a pass that reads history weighs it byhistoryValid, astemporal-blend.wgsldoes.skippedFramescounts the frames dropped since the previous new frame, which a temporal pass may treat as a soft reset.refreshSeconds,targetOffsetSeconds, andframeDurationSecondstime a draw within the display’s refreshes and the stream’s frames (see Display rate). Architecture lists when history resets. - The vertex stage and the fragment or compute entry point that call the body’s entry point.
The engine renames each body’s module-scope declarations, so two passes can declare the same helper. The renaming is lexical, so a struct member that shares the name of a module-scope declaration is renamed where it is declared but not where it is read; give members names of their own.
Why a pass is left out
A pass that cannot run is left out of the plan, playback continues without it, and WebGPUPresenter.getPresentationPlanReport and setPresentationPreset report it as a reason code and a terse detail.
| Reason | Cause |
|---|---|
header-invalid | The header does not parse; the detail carries the line and message |
compile-error | The WGSL does not compile; the plan is rebuilt without the pass, and messages map back to the pass file’s lines |
duplicate-name | An earlier pass of the preset has the same name |
parameter-unknown, parameter-invalid | A stored value names no parameter of the file, or is not finite; every pass of the file is left out |
condition-false, condition-invalid | when is 0, or cannot be evaluated |
sdr-on-hdr-output, domain-unreachable | The domain cannot be met; see Stages and color domains |
extra-scaler | Another scaler runs |
replaced-function-missing, replaced-function-taken | The route’s chain has no such built-in function, or another pass replaced it |
input-unknown, input-after-hook, input-reserved | An input names nothing saved, a later stage, or TARGET |
planes-unavailable | The pass hooks or reads a plane stage, or calls the prelude’s plane transform, on a route without planes, which the detail names by its signal |
linear-light-unavailable | The pass calls linearize, delinearize, or referenceWhiteNits on a route that does not know its stream’s transfer function, which the detail names by its signal |
display-rate-unavailable | The pass draws at the display rate where the presenter cannot redraw between frames: video-element where the browser’s video element times the frames, deinterlace where the plan deinterlaces; see Display rate |
texture-size, texture-format | A texture block exceeds the device’s texture size, or needs a filter the device lacks for its format; the detail names the block |
output-size, output-missing, workgroup-count, workgroup-missing | An output size or dispatch count is out of range, or missing |
WGSL is bounds-checked and cannot reach the network or the page, so a pass can only read the frames it is given and produce pixels.
Migrate an mpv shader to a wgpup
tools/convert_mpv_shader.py converts an mpv or libplacebo user shader, the //!HOOK format of mpv’s --glsl-shaders, into a pass file (see Create a new WebGPU pass).
It splits the shader into blocks as libplacebo does, translates each pass’s GLSL to WGSL with naga, binds mpv’s texture and global names to the pass API, and validates every pass with naga before it writes the file.
Its report names each place where the result differs from mpv.
You need
- Python 3.10 or later.
- naga-cli 30.0.1, which translates the GLSL:
cargo install naga-cli --version 30.0.1 --locked. The converter refuses any other version, since its repairs answer that version’s GLSL frontend.
Steps
-
Create
wgpup/<project>/, and copy the upstream shader intowgpup/<project>/glsl/unmodified, with aPROVENANCE.txtof its source, commit, and SHA-256, and its license inwgpup/licenses/, aswgpup/anime4k/does. -
Run from the engine root:
python tools/convert_mpv_shader.py wgpup/<project>/glsl/<shader>.glsl wgpup/<project>/<shader>.wgsl--name <name>names the passes that have noDESC, which otherwise take the file’s name and their position. -
Read the report. An error leaves the file unwritten, a warning marks a difference from mpv, and a note records an emulation, a mapping, or a repair.
-
Continue from step 3 of Create a new WebGPU pass: try the file in the sandbox, add the project to the pass tests, build, and add it to the table in Bundled passes. A converted pass may read
presentation.frameIndex,sourceSize, oroutputSize; add those it reads to the test’sPRESENTATION_FIELDS.
A pass a user supplies needs only step 2; load its output in the sandbox’s shader panel, or through WebGPUPresenter.setPresentationPreset.
Check the result
-
The converter validates each pass with naga against the pass API’s declarations, and exits with status 1, writing nothing, on any error.
-
Run its tests from the engine root:
python -m unittest discover -s tools/test -p "*_test.py"The conversion tests skip when naga-cli 30.0.1 is not installed.
-
In the sandbox, the shader panel shows each pass’s plan state, and the pipeline graph lists the passes the plan left out as
reason: detail.
How passes map
- Each mpv pass becomes one pass per hook it fires on, named with the hook when it has several, and the file’s passes are sorted into mpv’s pipeline order, so a
MAINpass runs after aLUMApass wherever each sits in the file. - A fragment pass becomes a
kernel, and aCOMPUTE <bw> <bh> [<tw> <th>]pass becomes acomputepass withworkgroup: tw, thand adispatchofbwbybhblocks over its output. DESCnames the pass, cut to 64 characters and made unique within the file.SAVEbecomessave, renamed when the pass API reserves the name, andBINDbecomes the inputs the converted code reads, and every bound saved texture, read or not, since mpv skips a pass whose bound texture no pass saved. A boundTEXTUREbecomes abindof the file’s texture block (see TEXTURE blocks).WIDTH,HEIGHT, andWHEN, which mpv writes in RPN, become infix expressions.whenreads plan-time facts and the pass’s parameters, so texture sizes there become the sizes the earlier passes leave, overluma,chroma,source, andoutput, andNATIVE_CROPPEDis luma’s size.COMPONENTSgives a saved texture a smaller format and becomescomponents.OFFSET <x> <y>becomesoffset: <x>, <y>, andOFFSET ALIGNbecomesoffset: align, which libplacebo’s texture rects mean (see Offsets), so nnedi3’s and ravu’s doubled luma lands where the source did. A pass after the scaler, or onCHROMA_SCALED, has no grid of its own to shift, so itsOFFSETis reported asoffset-ignored.- A bound texture of an earlier stage, such as
PREKERNELread atPOSTKERNEL, reads the final image of the engine stage it maps to; a plane’s name reads that plane’s final image, as in mpv.
| mpv hook | Stage | Domain | Notes |
|---|---|---|---|
LUMA | LUMA | any | The luma plane |
CHROMA | CHROMA | any | The chroma plane at its own size |
NATIVE | NATIVE | any | The planes merged on luma’s grid |
RGB, CHROMA_SCALED | RGB | source | CHROMA_SCALED reads chroma from the RGB image and joins it back |
MAIN, MAINPRESUB | RGB | source | |
LINEAR, SIGMOID | PRESCALE | linear | In mpv’s scale of linear light |
PREKERNEL | PRESCALE | any | |
POSTKERNEL, SCALED | SCALE | any | After the scaler |
PREOUTPUT, OUTPUT | OUTPUT | display | |
ALPHA, ALPHA_SCALED, XYZ | None | They never fire on video, so the pass leaves them out |
mpv fires RGB on RGB sources and LUMA and CHROMA on YUV ones, so a pass that hooks RGB beside a plane hook keeps both, the RGB pass under when: rgb.source and the plane passes under when: !rgb.source, which the report notes as hook-by-source.
Planes
LUMA,CHROMA, andNATIVEpasses run on the engine’s plane stages (see Planes), which hold the planes as mpv’s normalized code values, chroma at its own size and siting. A pass that overwrites its plane writes the plane, and the engine converts the planes with the source’s own range and matrix.- So a chroma upscaler such as KrigBilateral or the Chroma from Luma prediction shaders runs as in mpv: its
WHEN CHROMA.w LUMA.w <readschroma.width < luma.width, it reads chroma at its native size, and itsOFFSET ALIGNoutput is the chroma the image converts with. On the raw and frame-pool routes the planes are the decoded ones; on the external routes and in deinterlacing plans the engine derives them from the RGB image at the stream’s own subsampling. - Dolby Vision routes leave plane passes out as
planes-unavailable. CHROMA_SCALED, chroma at the image’s size after mpv’s chroma scaler, is not held apart by the engine, so it is read from the RGB image through the prelude’srgbToPlanes, the source’s own matrix and range, and new chroma joins the image’s luma throughplanesToRGB.
Linear light
- The engine’s linear light is in nits, while mpv’s 1.0 is SDR white.
A
LINEARorSIGMOIDpass divides what it reads by the prelude’sreferenceWhiteNits(), the configured SDR white for an SDR source and the HDR reference white for HDR, and multiplies its result back. - A
SIGMOIDpass also reads through libplacebo’s default sigmoid, center 0.75 and slope 6.5, and writes through its inverse.
Names a body reads
| mpv | Converted |
|---|---|
NAME_tex, NAME_texOff, NAME_pos, NAME_size, NAME_pt | The input’s accessors, at the kernel’s coordinate |
NAME_raw in texture, textureLod, textureGrad, texelFetch, textureGather, textureSize, and their offset forms | The same accessors; a gather samples the four texel centers |
NAME_pos of the hooked texture under OFFSET ALIGN | The coordinate moved onto the texture’s offset, coordinate + HOOKED_offset() * HOOKED_pt(), as libplacebo aligns its rect |
NAME_mul, NAME_rot | 1.0, since the planes hold normalized values, and the identity |
NAME_off | The input’s <P>_offset() |
NAME_map(id) | The normalized center of texel id of the output, moved as NAME_pos is |
frame | presentation.frameIndex |
random | A hash of the frame index: one value in [0, 1) per frame |
input_size, target_size | presentation.sourceSize and presentation.outputSize |
tex_offset | 0 |
linearize(), delinearize() | The prelude’s, the source’s own transfer function in mpv’s scale of linear light |
A TEXTURE sampler in texture, texelFetch, textureGather, and textureSize | The texture block’s <NAME>_tex, <NAME>_load, four loads, and <NAME>_size |
gl_FragCoord | The output pixel’s center, from the coordinate and the pass’s size |
gl_GlobalInvocationID and the other compute built-ins | The invocation ID, and what it gives with the workgroup size |
imageStore(out_image, ...), imageSize(out_image) | TARGET_store, which drops a store outside the image, and TARGET_size |
Parameters
- A
PARAMof typefloat,int, oruint,DYNAMICor not, becomes aparamof typef32,i32, oru32, in each pass that reads it or whoseWHENreads it; a missing bound takes its type’s range. - An
ENUM intparameter becomes ani32parameter over its labels, which read as their numbers. - A
WHENthat reads a parameter becomes awhenthat reads it by name, so the engine plans again when it changes. AWIDTHorHEIGHTthat reads a tunable parameter takes its default, which the report notes asparameter-inlined. - A
CONSTANTparameter the code reads becomes aconst, which the engine compiles at its current value. The GLSL declares it as a global, which naga reads by name, unless the code needs a constant expression of it, such as an array size; then naga may fold it into the code, which the converter finds by translating again at another value, and a folded constant keeps its default, reported asconstant-folded. - A
DEFINEparameter steers the preprocessor, so the converter translates the pass once per value of theDEFINEparameters its code reads, each pass underwhen: <name> == <value>, and declares them asparams, so changing one picks another pass. Past 16 passes the parameters keep their defaults, reported asdefine-variant-limit. - A parameter named like a WGSL reserved word gains
_value.
TEXTURE blocks
- A
TEXTUREbecomes a texture block of the file (see Texture blocks), in one, two, or three dimensions, with itsFILTERandBORDERasfilterandedge; each pass that binds it gets abind, and the block follows the passes. - An 8-bit format stays 8-bit, a 16-bit float format holds halves, as mpv’s GPU texture does, whether the shader wrote halves or 32-bit floats, and a 32-bit float format stays 32-bit.
Three components take four, with alpha 1, and a 16-bit or 32-bit unorm format takes 32-bit floats, which the report notes as
texture-format-mapped. - Integer formats and
STORAGEtextures do not convert. - A texture’s name that the pass API reserves gains
mpv_, astexture-renamed.
Repairs
naga 30.0.1’s GLSL frontend rejects or mistranslates a few constructs that GLSL compilers accept. The converter rewrites them without moving any line, so naga’s line numbers still name the user shader’s lines:
- Adjacent array dimensions, which naga reverses, are reversed in declarations and constructors first.
- A for-loop condition that opens with a constructor, which naga reads as a declaration, is parenthesized.
memoryBarrierShared()and the other memory barriers are dropped, andbarrier()becomesworkgroupBarrier()alone, since naga addssubgroupBarrier(), which needs an extension.- After a naga failure, a function that can end without a return returns zero there,
dot()of scalars goes through overloads, a swizzle of a scalar, which GLSL 4.20 allows, reads the scalar or a vector of it, and a globalconstthat naga cannot fold, such as one set by a ternary, becomes a macro, with everyconstthat reads it. Each of these is reported asglsl-repaired.
Report codes
| Code | Severity | Meaning |
|---|---|---|
naga-unavailable | Error | naga-cli is missing, or not 30.0.1 |
headers-missing, directive-unknown, directive-invalid, expression-invalid, expression-name-unknown | Error | The shader’s headers or RPN expressions do not parse |
hook-unknown, hook-function-missing | Error | A hook point mpv does not have, or a pass without hook() |
bind-unknown, bind-before-stage | Error | A bound name nothing provides, or a stage that has not run at the hook |
parameter-invalid, parameter-limit | Error | A malformed parameter, or more than 32 in one pass |
texture-invalid, texture-unsupported, buffer-unsupported | Error | A TEXTURE or BUFFER block that does not convert |
raw-sampler-unsupported, image-unsupported | Error | A raw sampler or out_image used other than in the calls above |
glsl-translation-failed, wgsl-invalid | Error | naga rejects the GLSL with no repair left, or the converted WGSL |
workgroup-limit, workgroup-storage-limit | Error | A compute pass beyond WebGPU’s default limits |
offset-ignored, parameter-inlined, constant-folded, define-variant-limit | Warning | The result differs from mpv as described above |
hook-emulated, hook-never-fires, hook-by-source, stage-approximated | Note | A hook or binding mapped as described above |
parameter-baked, parameter-renamed, parameter-unbounded, define-variants, save-renamed, name-truncated, pass-unhooked | Note | A name or parameter mapped as described above |
texture-format-mapped, texture-renamed, glsl-repaired, glsl-directive-removed | Note | A TEXTURE format or name mapped, a repair made, or a #version or #extension line removed |
Limits
- A
BUFFERblock does not convert, since a pass cannot declare buffers. - A compute pass runs within WebGPU’s default limits, 256 invocations per workgroup and 16 KiB of workgroup storage, so NVScaler, which needs 21776 bytes, does not convert as it is.
A shader whose block size sets its shared memory can fit by a smaller block, as the
nisproject’s generator converts NVScaler (see Bundled passes). - A converted pass runs as mpv ran it, pass for pass. A generator that fuses a network into fewer passes, as the Anime4K project’s do (see Bundled passes), can go further than the converter.
WebAssembly build
The engine uses two kinds of WebAssembly.
wasm/ holds the engine’s own modules: the Dolby Vision RPU parser and the decoded audio output stage.
wgpud/ is a submodule of the decoders repository, alchemyyy/webgpu-player-decoders, which holds the decoder projects, each of which builds one decoder package; its name marks them as this player’s own module type, as wgpup/, the passes repository’s submodule, marks the bundled passes.
The engine never loads a decoder project’s output by itself: a host supplies packages at runtime, and the engine names no project.
A checkout installs the decoder packages from a pinned release of the decoders repository rather than building them (see Pinned releases).
Both the builds and the installs write into bin/, which is ignored like everything in bin/ but bin/codec_vector_assets/.
You need
- GNU Make, Git, and a POSIX shell (Git Bash on Windows). No container is involved.
- Node.js: the Makefiles read the engine’s folders from
tools/constants.json. - Emscripten 4.0.13 (revision
2659582941bef14008476903f48941909db1b196), from the emsdk thatEMSDKnames, whichemsdk_envsets. WithoutEMSDK, the Makefiles use the engine’s own emsdk inbin/emsdk/, and with neither,emccfromPATH.make -C wasm emsdkinstalls the engine’s emsdk: a clone of emsdk at the release tag of the pinned version, replaced when the pin moves, whose installer needs Python 3. The audio output stage and the C decoder projects need it. - rustup, for
libdoviand the Rust decoder projects. Theirrust-toolchain.tomlfiles select the same Rust, 1.96.1, with thewasm32-unknown-unknowntarget. - To build the decoder projects: CMake, Perl, and GNU patch, which the AV1 project’s libaom build needs, and on Windows long path support (see Set up a checkout). The engine’s own build needs none of them.
The engine’s modules
| Kit | Output in bin/wasm/<kit>/ | Library and license | Our source (MIT) |
|---|---|---|---|
libdovi | dovi-rpu-parser.wasm | The dolby_vision crate from dovi_tool, vendored and patched, MIT | libdovi/ |
audio-output-stage | audio-output-stage.wasm | None: the engine’s resampler and limiter kernels | audio-output-stage/audio_output_stage.c |
Each is served from libraries/<kit>/ and has no JavaScript glue: the engine instantiates each .wasm itself.
The playback worker makes one instance of libdovi when its first parser needs it (demux/dolby-vision/WorkerWASMInstanceCache.ts), and every later parser creates its own native context in that instance.
The audio decode worker instantiates audio-output-stage once, when its first decoded audio attempt starts.
Run from the engine root:
make -C wasm emsdk # Install the pinned Emscripten into bin/emsdk, once
make -C wasm all # Build both modules into bin/wasm
make -C wasm check # Rebuild both and compare them with bin/wasm
make -C wasm audio-output-stage # Rebuild one kit
make -C wasm clean
The build sets SOURCE_DATE_EPOCH, the locale, the time zone, and the Python hash seed, ignores compiler variables from the calling environment, and refuses any Emscripten other than 4.0.13.
Panics embed source paths, so the libdovi build maps the cargo home, crate, and target directories to fixed names.
With the rust-src component installed, rustc rewrites the standard library’s /rustc/<commit> paths to the toolchain’s local copy, so the build maps that copy back too.
make check rebuilds both modules and compares them byte for byte with the existing bin/wasm/; copy another machine’s bin/wasm/ in first to check that two machines build the same bytes.
The Dolby Vision parser crate
wasm/libdovi/ is our crate.
src/lib.rs wraps the dolby_vision crate and packs each RPU into the fixed snapshot layout the engine reads (demux/dolby-vision/DolbyVisionRPUDataLayout.ts, schema 2).
It has two entry points over one parser state: dovi_parser_parse reads an HEVC RPU NAL unit (type 62), and dovi_parser_parse_av1_t35 reads an AV1 ITU-T T.35 metadata message, from the country code to the end of its OBU payload.
dovi_parser_set_container_profile gives the state the RPU profile the container declares, which an RPU whose header reveals none takes.
The playback worker loads dovi-rpu-parser.wasm only for an owned HEVC or AV1 attempt on a Dolby Vision route; any other route removes the RPUs without parsing them.
The dolby_vision crate is not fetched from GitHub.
It is copied from dovi_tool rev 38adec0 into wasm/libdovi/vendor/dolby_vision/ and built as a path dependency, because the parser follows FFmpeg’s libavcodec/dovi_rpudec.c where upstream is stricter.
vendor/dolby_vision/PATCHES.md lists every deviation from upstream; update it with any change to the copy.
The Recipes cover a change.
From wasm/libdovi/:
cargo test --locked
cargo clippy --locked --all-targets
The audio output stage
wasm/audio-output-stage/audio_output_stage.c holds the decoded audio output stage’s two kernels: the resampler’s source history and windowed-sinc rendering, and the lookahead limiter’s history, attack envelope, and gain.
Timeline reconciliation, timestamps, output chunking, and the downmix stay in TypeScript, in audio/processing/.
Each kernel keeps its history in a ring, so a push copies only its own frames, and its SIMD (simd128) vectors hold channel pairs or neighboring frames, never neighboring taps.
The kernels produce the same bytes as StreamingAudioResampler and StreamingAudioLookaheadLimiter, which stay as the reference and the fallback (see Decisions).
Three rules keep them exact:
-ffp-contract=off, so no multiply and add fuse.- The filter table is built in JavaScript and copied in, because V8’s
Math.sinandMath.cosneed not match a C library’s. - The limiter’s constants from
Math.powandMath.exparrive as numbers, and its attack length reads the engine’sMath.log10through the module’s one import,math.log10.
The kit links with -sSTANDALONE_WASM and emcc’s own --no-entry, so it has no JavaScript glue.
It is a WASI reactor: _initialize runs the constructors that set up emmalloc’s heap, before any other export.
It defines emscripten_notify_memory_growth itself, so math.log10 is its only import.
audio/processing/AudioOutputStageModule.ts instantiates it, refuses a module whose audio_output_stage_abi_version differs from its own, and wraps each kernel; raise the version with any change to an export’s meaning.
After a change, rebuild the kit and run the equivalence suite, which compares both implementations byte for byte, from the engine root:
make -C wasm audio-output-stage
npx vitest --watch=false test/audio/processing/AudioOutputStageModule.integration.test.ts
Pinned releases
tools/pinned-releases.json pins one release of the decoders repository and one of the passes repository, file by file:
{
"decoders": { "repository": "alchemyyy/webgpu-player-decoders", "tag": "v0.1.0", "files": { "<name>.wasm": "<sha256>" } },
"passes": { "repository": "alchemyyy/webgpu-player-passes", "tag": "v0.1.0", "files": { "<name>.wgsl": "<sha256>" } }
}
Only the .wasm files of a decoders release and the .wgsl files of a passes release install; their license texts, source archives, and SHA256SUMS do not.
npm run pinned-releases (scripts/pinned-releases.mjs) installs them into bin/wgpud/ and bin/wgpup/, and ./build.sh and ./sandbox.sh run it first:
- A file in place with its pinned SHA-256 stays.
- A missing file, or one the script installed before and that is unchanged since, downloads from
https://github.com/<repository>/releases/download/<tag>/<file>, must match its pinned SHA-256, and replaces the old file whole. The script records what it installed, with each file’s SHA-256, inbin/installed-releases.json. - Any other file there, such as a local build from
npm run decodersornpm run passes, stays, and the script names it;--forceinstalls the pinned files over local builds too. - A file an earlier pin installed that the pin no longer names is removed, unless it changed since; with
--forceit is removed regardless.
It needs the network only for the files it installs.
tools/constants.json names the two files as pinnedReleasesFile and installedReleasesFile, which tools/constants.mjs exports as PINNED_RELEASES_FILE and INSTALLED_RELEASES_FILE.
Release a repository
Each repository releases through its .github/workflows/release.yml, run by hand on main:
-
Commit and push the change on
mainof the decoders or passes repository. -
In that repository, open Actions > Release > Run workflow, and give
versionthree numbers, such as0.2.0, which becomes the tagv0.2.0. Without a version the run is a dry run, which keeps the release files as the run’s artifact. -
In the engine, pin the release and install it:
node scripts/pinned-releases.mjs --pin decoders v0.2.0--pin <decoders|passes> <tag>reads that release’sSHA256SUMS, pins its.wasmor.wgslfiles with their SHA-256 intools/pinned-releases.json, then installs. -
Move the submodule to the commit the release is tagged at, and commit the gitlink and
tools/pinned-releases.jsontogether in one engine commit, so the submodule and its pin always move together.
The decoders workflow checks out the engine at its engine-ref input, main by default, puts the decoders repository at the engine’s wgpud/, installs the pinned Emscripten with make -C wasm emsdk and Rust, and runs the engine’s own steps: make -C wgpud sources, npm run decoders, which loads every package with the engine’s loader, and make -C wgpud source-archives.
Its release files are every <project>.wasm, the license texts of licenses/, the three source archives, which are the corresponding source of the LGPL packages, and SHA256SUMS.
The passes workflow needs no engine checkout: it collects every WGSL file at the top level of every project folder, flat, and fails on two files of one name, as npm run passes does; its release files are those, the license texts of licenses/, and SHA256SUMS.
After an install, npm run decoders -- --check compares the submodule’s build with the pinned release in bin/wgpud/, so it checks that the submodule reproduces the release.
Decoder projects
A decoder project is a folder of wgpud/ with a decoder.json.
It is a minimal project that builds one package and runs nowhere on its own.
| Project | Decodes | Module | Self-test vectors | Library and license |
|---|---|---|---|---|
av1 | AV1 Main, High, and Professional at 8 to 12 bits, 4:0:0 to 4:4:4, on the GPU | libaom’s decoder, running its parse-only pass, and the C bridge in parser/ | One stream per output format in vectors/, from FFmpeg’s libaom encoder | libaom, BSD-2-Clause with the Alliance for Open Media patent license |
hevc | HEVC Main, Main 10, and the range extensions, on the GPU | The Rust parser in parser/ | The nine HEVC range extension vectors | None, MIT |
jpeg2000 | JPEG 2000 YUV pictures at 4:2:0, 4:2:2, and 4:4:4, 8 to 12 bits, and Digital Cinema X’Y’Z’ pictures as BT.709 Y’CbCr, on the GPU | The Rust parser in parser/ | One stream per output format in vectors/, from FFmpeg’s and OpenJPEG’s encoders, beside a Digital Cinema stream in Matroska for the route tests | None, MIT |
mpeg2 | MPEG-1 and MPEG-2 Video at 4:2:0, 4:2:2, and 4:4:4, on the GPU | The Rust parser in parser/, which reads sequence headers | One stream per chroma format in vectors/, from its own syntax exerciser | None, MIT |
mpeg4 | MPEG-4 Part 2, H.263, and MS-MPEG4 versions 1 to 3 at 8-bit 4:2:0, on the GPU | The Rust parser in parser/ | One Xvid stream with GMC, quarter-sample motion, and B-VOPs in vectors/, beside the media of every codec and container its routes take | None, MIT |
vc1 | VC-1 Advanced and WMV3 (Simple, Main, and Complex), on the GPU | The Rust parser in parser/ | The VC-1 Advanced stream in src/capability/vectors/qualification/vc1/ | None, MIT |
uncompressed | Uncompressed planar packets, on the GPU | None | None | None, MIT; the template of a WGSL-only decoder |
eac3 | AC-3 and E-AC-3, and the Dolby Atmos objects of E-AC-3 with joint object coding | FFmpeg’s AC-3 and E-AC-3 decoders, our JOC decoder, and the shared object renderer in wgpud/atmos/ | None; its tests decode the media in vectors/, two to FFmpeg’s known answers | FFmpeg, LGPL-2.1-or-later; our JOC decoder, MIT |
truehd | MLP and TrueHD, and the objects of TrueHD Dolby Atmos | FFmpeg’s MLP and TrueHD decoders, our decoder of the 16-channel presentation and of single-substream streams FFmpeg refuses, and the object renderer in wgpud/atmos/ | None; its tests decode the streams in vectors/ | FFmpeg, LGPL-2.1-or-later, which truehd_presentation.c, a port of FFmpeg’s MLP decoder, and truehd_substream.c are too |
wma | WMA 1, WMA 2, WMA Pro, WMA Lossless, and WMA Voice | FFmpeg’s wmav1, wmav2, wmapro, wmalossless, and wmavoice decoders | None; its tests decode the media in vectors/ to FFmpeg’s known answers | FFmpeg, LGPL-2.1-or-later |
dts | DTS core, DTS-HD High Resolution, and DTS-HD Master Audio | libdcadec | None | dcadec, LGPL-2.1-or-later |
decoder.json names the project’s decoder source, the WGSL file or audio manifest the package carries, under decoder.
Each project’s header declares the project’s name as its package id, such as //! id: hevc, so a rebuilt package replaces the one a host stored before.
A WGSL decoder may name a list of files instead, which the build joins in order into the one source the package carries: each later file follows a // Decoder source: <file> line, so a compile message leads back to its file, and only the first file may hold header lines.
A video project lists its self-test vectors under vectors, each with its output format, the layout key of its root folder in tools/constants.json, its path there, optionally the FFmpeg decoderOptions of the reference decode, and optionally codecPrivate, the layout of the codec private data the vector passes the decoder.
That is the stream’s extradata, or with bitmap-info-header a 40-byte BITMAPINFOHEADER of the stream’s size and FourCC before it, as the engine passes the mpeg4 package (see Codec private data).
A project with a Makefile builds its module with make module, which writes wgpud/build/<project>/module.wasm; a project without one packs an empty module, as a decoder without a parser does.
Each Makefile includes wgpud/rules.mk, which pins the toolchain, FFmpeg, dcadec, and libaom, and holds the rules to build an FFmpeg audio module, a libdcadec module, libaom, and a Rust module.
wgpud/include/audio_decoder_interface.h declares the audio decoder interface for C modules.
Run from the engine root:
make -C wgpud sources # Fetch the pinned FFmpeg, dcadec, and libaom, once, for the audio and AV1 projects
npm run decoders # Build every project into bin/wgpud/<project>.wasm
npm run decoders -- hevc vc1 # Build some projects
npm run decoders -- --check # Rebuild every project into a scratch folder and compare it with bin/wgpud
make -C wgpud source-archives # Archive the corresponding source of the LGPL projects
make -C wgpud clean # Remove the decoder projects' build trees
npm run pinned-releases -- --force # Replace local builds with the pinned release again
scripts/decoder-packages.mjs iterates over the projects without naming any.
It builds every project’s module at once, since each build spends most of its time in one serial chain such as a configure script’s compiler probes, and prefixes each output line with the project’s name.
Then, in project order, it packs each module, the decoder source, and the project’s vectors file with tools/decoder-package-format.mjs, and loads the result with the engine’s own loader, as any host would, before it writes the package.
A failed build or load check fails the run after the other packages are written.
tools/pack-decoder-package.mjs packs one package by hand from the same parts.
How the build works
- Pinned sources.
FFmpeg, dcadec, and libaom are git submodules in the engine’s
vendor/, pinned to exact commits. They are shallow and markedupdate = none, so a recursive clone of the engine, or of a host that embeds it, skips them untilmake -C wgpud sourcesfetches them. It fetches only each recorded commit, at depth 1, becausegit submodule update --depth 1would first clone the tip of the default branch. - Fresh source trees.
An FFmpeg project extracts a fresh copy of the pinned tree into
wgpud/build/<project>/source/withgit archive, then configures and builds it there with only that project’s decoders and FFmpeg’s portable C. Building outside a git checkout keeps FFmpeg’s embedded version string and source paths stable. The AV1 project extracts and builds libaom the same way, after applying its patches (see The AV1 parser). - Configuration checks. The build fails unless configure enabled exactly the whitelisted decoders and kept the LGPL scope: no GPL, nonfree, or version 3 components.
- A fixed environment, as for the engine’s modules, and Rust panic paths mapped to fixed names.
- Audio modules link as standalone reactors with
-sIMPORTED_MEMORY, so each imports its memory and exports version 1 of the audio decoder interface.
Self-test vectors
scripts/codec_vector_assets/generate_decoder_package_vectors.py reads the vectors each decoder.json lists and writes wgpud/<project>/<project>.vectors, the set the package carries in its vectors section (see Self-test vectors).
A project whose streams no script elsewhere writes keeps them in its own vectors/, from a generator of its own: generate_mpeg2_decoder_vectors.py, generate_jpeg2000_decoder_vectors.py, generate_av1_decoder_vectors.py, and generate_mpeg4_decoder_vectors.py, and for the wma project, whose audio package carries no vectors section, generate_wma_decoder_vectors.py, which writes its media and FFmpeg’s known answers for its tests.
The files are committed, byte for byte (the decoders repository’s .gitattributes marks /*/*.vectors and /*/vectors/** -text), and --check fails unless they match what the generators write.
scripts/generate_all_codec_vector_assets.py runs them with the other generators, a project’s own media first.
The HEVC parser
wgpud/hevc/parser/ is our crate: the CPU half of the HEVC package, whose WGSL back end is wgpud/hevc/hevc.wgsl.
It parses each packet into one GPU record: the picture’s parameters and slices, and per CTB its SAO, its prediction units with their motion, its transform jobs and coefficients, its deblocking edges, and its PCM samples.
The WGSL expands the record and reconstructs and filters the picture on the GPU (see Decisions).
It implements version 2 of the engine’s parser interface: plan, parse, and finish, with decode running all three on one instance.
Every packet is one picture, so plan_next and decode_next report no more pictures, and a picture reports no visible size, repeat counts, or declared maximum of its own.
A 4:0:0 picture decodes into the 4:2:0 format of its depth with neutral chroma, so the header declares monochrome: yes.
hevc.wgsl declares the record’s layout and the error codes once, as const RECORD_<NAME>: u32 and const ERROR_<NAME>: u32 lines.
build.rs reads them into Rust constants and fails unless the header’s //! error: lines name exactly the error codes the WGSL declares, so the module and the WGSL of one package always agree.
A failed check reports one of the decoder’s error codes and its failing value, as a failure on the GPU does.
After a change, from the engine root:
npm run decoders -- hevc
cd wgpud/hevc/parser && cargo test --locked
npx vitest --watch=false wgpud/hevc
The JPEG 2000 parser
wgpud/jpeg2000/parser/ is our crate: the CPU half of the JPEG 2000 package, whose WGSL back end is wgpud/jpeg2000/jpeg2000.wgsl.
It reads a JP2 file, a jp2c box, or a bare codestream, picks the pixel format as FFmpeg does, runs tier-2, and writes one GPU record per picture: the code blocks with their bytes, terminations, and dequantization, the wavelet tables, and the output planes.
One parse unit is one tile, so the parse workers split a picture by its tiles.
The WGSL decodes each code block in one 32-lane workgroup, runs the inverse wavelet, and writes the output planes.
An XYZ picture, which FFmpeg picks for the Digital Cinema profiles, is laid out as the 12-bit X’, Y’, and Z’ codes FFmpeg’s converter reads, at 4:4:4, and the record’s output transform makes the output stage convert them as libswscale’s xyz12Torgb48 does, its gamma tables and fixed-point matrix included, then write the R’G’B’ as limited-range BT.709 Y’CbCr in I444P12.
Its planes therefore equal no FFmpeg decode, so decoder.json lists no XYZ vector; FFmpeg’s rgb48le decode of the Matroska stream in vectors/ is the conversion’s reference.
As for HEVC, the WGSL declares the record’s layout and the error codes once, and build.rs generates the Rust constants from them and fails unless the two agree, and every packet is one picture of version 2 of the parser interface.
The parser bounds what a crafted codestream can make it allocate and parse, and fails such a stream as resourceLimit.
It fails greyscale pictures as colourSpace, so the header declares no monochrome, and eligibility never offers the package a 4:0:0 stream.
After a change, from the engine root:
npm run decoders -- jpeg2000
cd wgpud/jpeg2000/parser && cargo test --locked
npx vitest --watch=false wgpud/jpeg2000
The MPEG-2 parser
wgpud/mpeg2/parser/ is our crate: the CPU half of the MPEG-2 package, whose WGSL wgpud/mpeg2/mpeg2.wgsl decodes everything, its headers included.
The parser reads only what the engine needs before a picture decodes: the size and chroma format of each picture’s sequence.
It reads them as the WGSL’s header stage does, from the sequence headers and sequence extensions before a packet’s first picture start code, and ignores a damaged sequence header where the WGSL and FFmpeg ignore it.
configure reads the codec private data the same way and reports its sequence’s size as the largest picture.
Every packet is one picture of version 2 of the parser interface, without parse units, and its record is the packet, byte for byte, so the WGSL decodes, conceals, and reports fields as it did without a parser.
A picture reports its sequence’s size as its visible size and its largest picture, and its sequence’s chroma format at 8 bits as its format, so a larger sequence, or one of another chroma format, moves the engine to a session of its own (see Stream changes).
A sequence its pool holds, such as a smaller one, decodes in the session it is in: the WGSL drops its references and starts the new geometry, as FFmpeg reinitializes, and each frame presents at its own picture’s visible size.
The parser decides no output events, so frames present in timestamp order as a decoder without a parser’s do, and reports no picture fields, so the WGSL’s own report stands.
MPEG-1 has no sequence extension, so its sequences are 4:2:0.
The WGSL declares the error codes, and build.rs generates the Rust constants from them and fails unless the header’s //! error: lines name exactly those; the parser’s only failure is recordInvalid, a call the engine never makes.
After a change, from the engine root:
npm run decoders -- mpeg2
cd wgpud/mpeg2/parser && cargo test --locked
npx vitest --watch=false wgpud/mpeg2
The VC-1 parser
wgpud/vc1/parser/ is our crate: the CPU half of the VC-1 package, whose WGSL back end is wgpud/vc1/vc1.wgsl.
It parses each packet’s picture as FFmpeg’s vc1 decoder does: the segments and their escapes, entry points, the frame, field, and slice headers, bitplanes, the intensity compensation lookups, and the macroblock layer, with motion vector prediction, coefficients, and intra DC and AC prediction, scan, and dequantization.
configure takes the codec private data the engine passes for vc1 and wmv3 (see Codec private data) and reports the Advanced profile’s MAX_CODED_WIDTH and MAX_CODED_HEIGHT, or the Simple and Main profiles’ coded size, as the largest picture.
One parse unit is one slice or one field, so the parse workers split a picture by its slices and fields; a picture without slices parses on one worker.
Each picture’s record holds its header, each field’s parameter block, the lookup sets those read, and per macroblock its flags, transform types, motion compensation operations, and dequantized coefficients at their raster positions.
expandRecord writes them into the buffers the reconstruction, overlap smoothing, and loop filter passes read, one workgroup per macroblock.
An IntraX8 picture decodes entirely in the parser, its prediction, transform, and loop filter included, and its record carries the picture’s samples, which copyPicture writes.
A B picture’s direct mode reads the next anchor’s motion, which only that anchor’s parse yields, so its plan reports blocked until every earlier anchor finished, then carries that motion.
Output events follow FFmpeg’s: a B or BI picture, and any picture of a low-delay stream, presents at once, and an anchor presents the anchor before it, so the header declares reorder: 1.
An entry point that changes the coded size drops the anchor waiting to present and every reference, as FFmpeg’s decoder does when it reinitializes, and the next record tells the WGSL to release its anchors.
A picture reports its coded size as its visible size, and parser_picture_fields reports its fields from FCM, TFF, and RFF.
As for HEVC, the WGSL declares the record’s layout and the error codes once, and build.rs generates the Rust constants from them and fails unless the two agree, and every packet is one picture of version 2 of the parser interface.
The damage of a packet, pictureHeader, bitplane, startCodes, macroblockLayer, bitsExhausted, and pictureTooShort (a field picture of one macroblock row, FFmpeg’s “image too short”), resyncs.
The configuration, the sequence header, an unsupported feature, an invalid record, and pictureSize, a size beyond the pool or the package’s 16384 macroblocks, end the attempt, since the pictures after it would fail the same way and a resync would hold the last good frame for good.
After a change, from the engine root:
npm run decoders -- vc1
cd wgpud/vc1/parser && cargo test --locked
npx vitest --watch=false wgpud/vc1
The MPEG-4 parser
wgpud/mpeg4/parser/ is our crate: the CPU half of the package that decodes MPEG-4 Part 2, H.263, and MS-MPEG4 v1 to v3, whose WGSL back end is wgpud/mpeg4/mpeg4.wgsl.
It is a port of FFmpeg’s decoders: mpeg4videodec.c for MPEG-4 Part 2 (mpeg4/), ituh263dec.c for H.263 and the H.263+ annexes FFmpeg decodes (h263.rs), msmpeg4dec.c for MS-MPEG4 (msmpeg4.rs), the slice loop and resync of h263dec.c (h263dec.rs), the macroblock state and dequantization of mpegvideo.c (mpv.rs), and the operation geometry of mpegvideo_motion.c (motion.rs).
configure reads the BITMAPINFOHEADER the engine passes for every codec of the package (see Codec private data), picks the codec from its FourCC as FFmpeg’s riff.c classes it, reads an MPEG-4 Part 2 track’s VOL from the extradata after it, and reports the coded size as the largest picture.
FFmpeg’s encoder detection runs on the FourCC and the user data as in FFmpeg, and sets the same bug workarounds and IDCT, the Xvid IDCT included.
One parse unit is one picture, so the parse workers decode pictures at once, each from a fresh copy of the stream’s tables, as FFmpeg’s decoder holds no state between pictures besides the headers and the anchors.
A DivX packed packet’s second VOP, which the fork’s AVI demuxer unpacks but a Matroska track can still carry, plans as a second picture of the same packet.
FFmpeg’s decoder keeps that VOP and decodes it with the next packet instead, so in Matroska the two pictures share one packet’s time, and a stream’s last such VOP, which FFmpeg never decodes, presents.
Each picture’s record holds per macroblock its flags, the H.263 loop filter’s quantizers, the motion operations FFmpeg would run (half-pel, quarter-pel, one-point and general global motion compensation, and overlapped block motion compensation, each with its source position, fraction, and rounding), and its dequantized IDCT blocks, dense or as sparse entries.
The WGSL runs a picture’s operations in order, then the simple or Xvid IDCT, then the H.263 loop filter over all horizontal and then all vertical edges.
A B-VOP’s direct mode reads the next anchor’s motion, which only that anchor’s parse yields, so its plan reports blocked until every earlier anchor finished, then carries that motion.
Output events follow FFmpeg’s: a B picture, and any picture of a low-delay stream, presents at once, and an anchor presents the anchor before it, so the header declares reorder: 1.
An H.263 picture of another size starts new anchors, as FFmpeg reinitializes, and reports its size as its visible size and the largest so far; parser_picture_fields reports an MPEG-4 Part 2 VOP’s interlacing and top field first.
As for HEVC, the WGSL declares the record’s layout and the error codes once, and build.rs generates the Rust constants from them and fails unless the two agree, and every packet is one picture of version 2 of the parser interface.
The damage of a packet, sequenceHeader, pictureHeader, macroblockLayer, and bitsExhausted, resyncs; the configuration, an unsupported feature, pictureSize, and an invalid record end the attempt.
The parser’s reference is FFmpeg’s bit-exact, single-threaded decode (see decisions).
After a change, from the engine root:
npm run decoders -- mpeg4
cd wgpud/mpeg4/parser && cargo test --locked
npx vitest --watch=false wgpud/mpeg4
The AV1 parser
wgpud/av1/ decodes AV1 with libaom’s decoder as its parser and WGSL for everything after the syntax.
libaom v3.15.2 is the git submodule vendor/aom, pinned in wgpud/rules.mk (AOM_COMMIT), shallow and marked update = none, as FFmpeg and dcadec are.
The build extracts the pinned tree with git archive, applies the patches in wgpud/av1/parser/patches/ in order with patch --fuzz=0, and builds the decoder alone with libaom’s CMake and Emscripten’s toolchain file: generic C, one thread, and WebAssembly exception handling for libaom’s longjmp error path (-sSUPPORT_LONGJMP=wasm), so the module needs no JavaScript glue.
The patches turn the decoder into a parser:
0001-parse-only-decoding.patchparses every tile with libaom’s parse-only flag, so the decoder reads modes, motion, palettes, transform sizes, coefficients, CDEF indices, and restoration units, and runs no prediction, reconstruction, or filter; hooks hand each transform block, block, frame, and missing reference to the bridge.0002-intra-edge-parameters.patchexports the edge sample counts and the edge filter type that intra prediction derives.0003-cfl-store-extent.patchkeeps the extent of the chroma-from-luma buffer the decoder would have stored, and exports CfL’s alpha.0004-filter-level.patchexports a block’s deblocking level.
libaom’s frame buffers point at an address no module memory maps, so the parser holds no samples, and a stray sample access traps.
libaom refuses a frame buffer past an eighth of AOM_MAX_ALLOCABLE_MEMORY, 2 GiB on 32-bit targets, which a level 6 4:4:4 frame with a scaled reference’s borders passes, so the build raises it to 4 GiB less 64 KiB; the 1 GiB instance memory still bounds every allocation that maps memory.
parser/av1_parser.c implements version 2 of the parser interface.
plan parses a temporal unit’s frames, one picture per frame, hidden frames and show_existing_frame included, and plan_next hands out the rest.
The unit’s last shown frame presents and the others drop, as libaom and FFmpeg’s libdav1d output one frame per temporal unit at operating point 0.
A picture reports its visible size, the upscaled width and the frame height, its intra wave pairs, its output format, and its sequence header’s maximum frame size, and configure reports the configured sequence header’s maximum.
So a picture of a new sequence that changes the bit depth or the chroma format, or raises the maximum past the pool, moves the engine to a new session, from that sequence’s key frame on.
After a start or a reset, temporal units before the first key frame make no picture.
parser/av1_record.c writes each frame’s GPU record: the blocks with their motion, warps, transform partitions, and palettes; each superblock’s intra jobs per plane in decode order, with their edges; transform jobs with libaom’s dequantized coefficients, dense or sparse, those with a 64-point side last; and the restoration units, CDEF indices, deblocking levels, references with their scale factors, global motion, and film grain.
av1.wgsl declares the record’s layout and the error codes once, as const RECORD_<NAME>: u32 and const ERROR_<NAME>: u32 lines, and parser/record_layout.mjs turns them into the bridge’s C header, and fails unless the //! error: lines name exactly the error codes the WGSL declares and the //! packet: line is the bound the RECORD_PACKET_ constants describe.
That bound is a record’s worst case for its pool, between 64 MiB and the engine’s 512 MiB: per 4x4 luma unit the words of a 4x4 block and its side data, and per 4x4 unit of each plane an intra job, a transform job, and 16 coefficients of the bit depth plus 8 bits, libaom’s dequantized range.
The parser judges each record against the bound of its frame’s upscaled size rounded up to 64, which never passes its session’s, and fails it as recordSize beyond.
The package takes pictures up to AV1’s level 6.x, 16384x8704 and 35,651,584 samples, 8K included; its record fields hold mode info coordinates up to 16384 samples.
decoder.json lists av1.wgsl first, with the header, which declares id: av1, the record layout, the frame store, and the entry points, then the four family files: wgsl/transform.wgsl (inverse transforms, film grain, and the output copy), wgsl/intra.wgsl, wgsl/inter.wgsl, and wgsl/filters.wgsl (deblocking, CDEF, super-resolution, and loop restoration).
Each record runs validation, inter prediction per 16x16 tile, the transforms in two passes, the transforms with a 64-point side in a kernel of their own, an intra wavefront over superblocks for intra, inter-intra, and IntraBC blocks, deblocking, CDEF, super-resolution, loop restoration, and the output copy with film grain.
References stay in the package’s own frame store, ten 16-bit frame buffers the parser assigns, so the header declares references: 0, and only shown frames reach the frame pool.
The store spans two buffers, frames with frame buffers 0 to 4 and framesHigh with the rest, so a device whose largest binding holds half of it decodes: 1.07 GB at 8192x4352 4:2:0 becomes two of 535 MB, and the 2.14 GB of 4:4:4 two of 1.07 GB.
A 4:0:0 stream decodes into the 4:2:0 format of its depth with neutral chroma, so the header declares monochrome: yes.
After a change, from the engine root:
npm run decoders -- av1 # About 3 minutes
npx vitest --watch=false wgpud/av1
The WMA bridge
wgpud/wma/ffmpeg_wma_bridge.c puts FFmpeg’s five WMA decoders behind the audio decoder interface.
Every one of them needs the track’s WAVEFORMATEX, so a decoder opens only when audio_decoder_configure reads it, and refuses packets as unconfigured until then.
It reads the header as FFmpeg’s ff_get_wav_header does: the sample rate, channels, bit rate, block alignment, bits per sample, a WAVE_FORMAT_EXTENSIBLE header’s channel mask when it describes that many channels, and the bytes after the header as extradata.
A header shorter than 18 bytes or than its cbSize, an extensible one without its 22 bytes, or one without channels or a sample rate fails as waveFormat; a format tag, or an extensible SubFormat’s, of another codec as formatTag; and a header the decoder refuses as open.
Each configure after the first opens a fresh decoder.
The decoders hold frames back until the end of the stream, WMA 1 and 2 their last overlap and WMA Lossless the frames it buffered, so the module exports the interface’s optional audio_decoder_drain, which its Makefile names as the second argument of link_ffmpeg_audio_module.
A drain before a configure fails as unconfigured, a second drain outputs nothing, and audio_decoder_clear starts a new stream after one.
Blocks are planar float: WMA Lossless’s 16-bit and 24-bit integers are scaled by the exact power of two of full scale, and WMA Voice’s packed mono float is copied.
A layout without a channel mask reports the mask of FFmpeg’s default layout of its channel count.
FFmpeg’s C decode, -cpuflags 0, is the reference, and the module decodes every vector to the same bytes, as WMA decoder vectors describes.
After a change, from the engine root:
npm run decoders -- wma
npx vitest --watch=false wgpud/wma
The TrueHD bridge
wgpud/truehd/ffmpeg_truehd_bridge.c puts FFmpeg’s MLP and TrueHD decoders behind the audio decoder interface, and adds Dolby Atmos object rendering for TrueHD.
FFmpeg decodes every packet, and its decode is the output unless the render target needs objects, as the renderChannelMask and renderBinaural options of the audio decoder interface set it: a stream FFmpeg reports as Dolby TrueHD + Dolby Atmos, with binaural rendering on, or with a target mask that has a speaker FFmpeg’s output layout lacks.
FFmpeg stops at the 8-channel presentation: it decodes at most three substreams, holds 8 channels, and refuses the 0x31EC restart sync word of the fourth substream, which carries the 16-channel presentation.
So MLP, TrueHD without Atmos, and an Atmos stream at a target its 7.1 or 5.1 presentation covers, such as 5.1, 7.1, or stereo without binaural, output FFmpeg’s decode unchanged, and report objectCount 0.
When the target needs objects, three sources of ours take over, packet by packet:
truehd_presentation.cdecodes one presentation of a TrueHD stream, here the 16-channel one, to 24-bit samples in the presentation’s channel order. It is a port of FFmpeg’smlpdec.c,mlpdsp.c,mlp.c, andmlp_parse.c(major sync, restart headers, decoding parameters, prediction filters, Huffman residuals, primitive matrices and their noise, output shifts, and channel assignment), generalized from 8 channels and 3 decoded substreams to 16 channels and 4. It adds what FFmpeg lacks: the0x31ECsubstream’s matrix syntax (a coefficient mask and shift per matrix, bypassed LSBs of up to 3 bits, and coefficient deltas that interpolate across each access unit), the substreams each presentation is built from (the 16-channel one is substream 3 together with the lower substreams thatextended_substream_infonames), the extra channel meaning of the major sync, and the extra data after the substreams, whose Evolution frame carries object audio metadata as payload 11 in the EMDF payload syntax. Those layouts are documented only by the truehd crate of truehdd (Apache-2.0), which we read to learn them; the code is our own, and the file’s header cites both. It also checks each restart header’s lossless check against the samples it decoded.truehd_objects.chands the presentation to the renderer: its channels are the metadata’s objects in payload order, beds first, and a presentation without metadata is the channel bed its major sync’s 16-channel channel assignment names (L R, C, LFE, Ls Rs, Lb Rb, Tfl Tfr, Tsl Tsr, Tbl Tbr, Lw Rw, LFE2). Each access unit’s payloads are parsed byatmos_parse_oamdand queued at their Evolution sample offset before the access unit renders.- The renderer in
wgpud/atmos/renders the bed and the dynamic objects into the target, or binaural stereo.
A packet renders only when every access unit in it decoded and rendered; otherwise it outputs FFmpeg’s decode, so rendering starts at the first packet after a major sync and the first object metadata, and a switch back to a covered target returns to FFmpeg’s decode at once.
A packet whose access units could hold more than 16,384 frames, the engine’s bound on a block, plays FFmpeg’s decode too.
Any packet the object decoder does not read, because FFmpeg dropped it or the target needed no objects, resets that decoder, so it resumes at the next major sync rather than with stale filter and matrix state.
A rendered packet is one block at the packet’s timestamp, with the target’s mask and objectCount, the scene’s dynamic objects; FFmpeg’s frames of that packet are discarded.
Binaural rendering delays its output by 128 frames, so the module exports audio_decoder_drain, which renders that tail after the last packet; a stock decode drains nothing.
audio_decoder_clear resets both decoders and the renderer.
FFmpeg also refuses a TrueHD stream whose one substream carries more than two channels, such as 5.1 coded without a separate 2-channel substream: its read_major_sync names the channels of a single substream only for a stereo or mono stream, so every restart header fails its channel assignment (Assignment of matrix channel 0 to invalid output channel -22) and no access unit decodes, which also leaves the server unable to transcode the track.
truehd_substream.c decodes such a stream with truehd_presentation.c, whose presentation 0 is that substream, and lays its channels out as FFmpeg lays out a stream it decodes: the major sync’s 8- or 6-channel arrangement with the decoded channel count names them in TrueHD order, and the block takes them in WAVE order under that arrangement’s WAVE mask, or in TrueHD order under mask 0 when a channel has no WAVE name.
Each packet’s first major sync decides which decode plays until the next one, and the decode that takes over starts at that major sync, so FFmpeg never sees such a stream and a stream that changes back plays FFmpeg’s decode again.
A packet whose access units do not all decode is dropped, and its block is one block at the packet’s timestamp, with bitsPerSample 24 and the pcmFingerprint of FFmpeg’s packing.
truehd_presentation.c decodes every presentation of the eight TrueHD streams among the truehd crate’s test assets and of the engine’s three TrueHD vectors bit for bit as truehdd (commit 77ddc52) does, the three 16-channel presentations included, and presentations 0 to 2 bit for bit as FFmpeg does wherever FFmpeg’s downmix option can select them, with every lossless check passing.
To repeat that, build truehdd natively, decode with truehdd decode --presentation <n>, and compare its 24-bit samples with a native build of truehd_presentation.c; neither the oracle nor the harness enters the repository.
The wgpud/truehd/vectors/ streams copied from the truehd crate’s test assets carry that crate’s Apache-2.0 license and PROVENANCE.txt.
single-substream-5.1.thd, ours, is 5.1 in one substream, which FFmpeg’s TrueHD encoder writes only after the three changes PROVENANCE.txt gives; the package decodes it to the encoded samples exactly, which its own lossless checks confirm, and stock FFmpeg refuses every access unit of it.
After a change, from the engine root:
npm run decoders -- truehd
npx vitest --watch=false wgpud/truehd
The E-AC-3 bridge
wgpud/eac3/ffmpeg_eac3_bridge.c puts FFmpeg’s AC-3 and E-AC-3 decoders behind the audio decoder interface, and adds Dolby Atmos object rendering for E-AC-3 with joint object coding (JOC, ETSI TS 103 420).
FFmpeg decodes every packet, and its decode is the output unless the render target needs objects: binaural rendering on, or a target mask with a speaker the decoded core lacks, such as 7.1 or any height layout from a 5.1 core.
A stream without JOC, AC-3, and a JOC stream at a target its core covers, such as 5.1 or stereo without binaural, output FFmpeg’s decode unchanged and report objectCount 0.
When the target needs objects, our sources take over, frame by frame, on FFmpeg’s decoded channels:
eac3_emdf.cfinds the EMDF container (ETSI TS 102 366 Annex H) in the access unit’s last syncframe, as TS 103 420 clause 8.2 places it, and copies out its OAMD (payload 11) and JOC (payload 14) payloads. The container is in the frame-end auxiliary data, whichauxdatallocates, or in an audio block’s skip field, as Dolby’s encoder writes it. Reaching a skip field exactly takes the audio block syntax up to it, so the search takes an EMDF syncword whose preceding ten bits read as a setskipleand askiplthat holds the container, and whose container parses completely within it. FFmpeg’savpriv_ac3_parse_headersplits a packet into access units as FFmpeg’s decoder takes them.eac3_joc.cparses the JOC payload (clause 6.2) and decodes it: the Huffman trees of Annex A.1, differential decoding, dequantization, the parameter band mapping of table 54, interpolation between data points, and the upmix of the 5 or 7 downmix channels to up to 16 objects in the filter bank domain.eac3_qmf.cis the 64-band complex filter bank of clause 7, computed through a 128-point FFT; analysis and synthesis delay the objects by 577 samples.eac3_joc_tables.cholds the Huffman trees and the 640-coefficient window from the specification’s companion archivets_103420v010201p0.zip.- The renderer in
wgpud/atmos/renders the objects with the OAMD: each LFE bed object takes the core’s LFE, and the other scene objects, beds then dynamic objects, take the JOC objects in order.
Object blocks keep their packets’ timestamps exactly: the module holds each block until the frames after it fill the filter bank delay and the binaural latency, so the first packet after a start, a clear, or a switch from FFmpeg’s decode outputs no block, and audio_decoder_drain outputs the last one.
A switch back to a covered target outputs the held block and FFmpeg’s block of the same packet, the later frames of a packet keep their access units’ timestamps, and a change of speaker layout or binaural rendering finishes the held blocks in the old target and restarts the pipeline.
Objects start only at a frame whose OAMD scene the renderer takes; a scene it refuses, such as one with intermediate spatial format objects, plays FFmpeg’s decode.
A damaged JOC payload keeps the last matrices, and a damaged OAMD payload the objects’ properties.
objectCount is the scene’s dynamic objects that have a JOC object.
The JOC decoder rebuilds the same objects as the JOC decoder of OpenJOC (Apache-2.0, commit e3e330a), run natively on the same FFmpeg downmix and payloads: within 6e-8 of full scale (129 dB or more per object) on Dolby’s sample_eac3joc.ec3 from androidx/media, and within 4e-10 on OpenJOC’s synthetic fixtures; both extract the same payload bytes from all 64 access units of the Dolby stream.
Neither the oracle nor the temporary harness enters the repository.
The wgpud/eac3/vectors/ media, and how to repeat their generation, are in E-AC-3 Atmos vectors.
After a change, from the engine root:
npm run decoders -- eac3
npx vitest --watch=false wgpud/eac3
The object audio sources
wgpud/atmos/ holds our own object audio code, which the eac3 and truehd modules compile with their bridges; it has no decoder.json, so it is no project and builds no package.
atmos.h is the interface those bridges code against: atmos_parse_oamd decodes one object audio metadata (OAMD) payload, and an AtmosRenderer renders a scene’s object planes with that metadata into a target layout.
A package’s Makefile passes $(ATMOS_MODULE_SOURCES) from wgpud/rules.mk, the four sources and their include flag, as the third argument of link_ffmpeg_audio_module, whose first argument lists the project’s own sources.
The code depends on no other Atmos library; ETSI TS 103 420 V1.2.1 and Recommendations ITU-R BS.2051 and BS.2127 are its references.
atmos_oamd.cdecodes anobject_audio_metadata_payloadas TS 103 420 clause 5 defines it: the program assignment, whose bed objects come first and are named by FFmpeg’s channel bits (the LFE of a dynamic-object program is a bed object), then ISF objects, then dynamic objects; and the object element’s updates, each with its start (sample_offsetplus 32 timesblock_offset_factor) and ramp duration, and per object its activity, gain, priority, room-anchored position (differential coding, extended precision, and a position outside the room projected onto its walls), zones, elevation, size, screen factors, and channel lock. Other elements, such as trim, are skipped by their declared size. A payload holds at most 32 objects and 8 updates; every read is bounded, and a truncated payload, an element that overruns its size, a version other than 0, counts that do not add up, or a reserved ISF index returns an error code. Where the clause’s pseudo-code and its tables disagree on which bit of a flag array is which, which happens only forobj_render_info[], the tables win: element[k]of an array is the k-th bit the payload carries, as real streams confirm forcontent_description[]and the bed assignments.atmos_renderer.crenders to every layout the engine qualifies: stereo, 5.1 (back or side), 7.1, 5.1.2, 5.1.4, 7.1.2, and 7.1.4, in WAVE channel order. Speakers stand on the room’s walls as TS 103 420 Table B.10 places them, a 5.1 layout’s surround pair in the back corners. The panner is separable and room-centric: an object pans between the floor and the heights, then between the rows of speakers in front of and behind it, then between the speakers left and right of it in each row, each step with the sine and cosine law, so its gains’ squares sum to 1 and an object at a speaker plays from that speaker alone. An object with a size averages that power over a 3 by 3 by 3 grid across its extent; zone constraints and elevation take the excluded speakers (TS 103 420 Annex A.2) out of the panner, falling back to all of them when none remain; channel lock plays an object from its nearest speaker within 0.4 in ADM units. A bed channel plays on its own speaker, or folds as the engine folds layouts: a side or back channel onto the other of its side’s pair, a top front or top back channel onto the floor channel under it (folding on from there), a wide channel onto its side’s front, and LFE2 onto the LFE; a top side channel, which the engine’s layouts lack, splits over its side’s top front and top back at sqrt(1/2) each, or plays from the top front of a layout with only that pair, or else from the side under it. Every bed channel that lands whole on a speaker, its own included, takes 1/sqrt(n) of the n channels landing there, as the engine’s layout routing does, so a 5.1 surround with Ls, Lb, and Tsl takes each at sqrt(1/3) and one alone keeps unit gain; a split channel takes sqrt(1/2) on each of its two. Stereo takes that fold into 7.1 and mixes it with the engine’s default Lo/Ro matrix: fronts at 1, center, sides, and backs at sqrt(1/2), the LFE dropped. ISF objects are parsed but not rendered: a scene with them fails as unsupported. Updates queue at their start sample, at most 32 at once; an update’s properties ramp linearly from the ones the object has when it starts, gain vectors computed every 32 samples and the gains moving linearly in between, so they reach the update’s values at its start plus its ramp duration whatever the render calls’ lengths. A target changes between render calls: objects keep their properties and ramps and move to the new layout’s gains at once. Nothing allocates afteratmos_renderer_create.atmos_binaural.crenders binaural stereo at 48 kHz: the scene renders to a virtual 7.1.4 layout, and each of its 11 speakers is convolved with its pair of head-related impulse responses by uniformly partitioned overlap-save convolution, 128-sample partitions in 256-point transforms, with two real signals sharing each complex transform, so a block of 128 samples costs six forward transforms, one inverse, and the products of two partitions. The output is 128 samples late, whichatmos_renderer_latencyreports; the LFE joins both ears at 0.5 without HRIRs, delayed the same.atmos_hrir.cholds the 11 virtual speakers’ responses, 160 taps per ear at 48 kHz, whichnode scripts/generate-atmos-hrir.mjs [diffuse.zip]writes. The source is the diffuse-field equalized set of Bill Gardner and Keith Martin, “HRTF Measurements of a KEMAR Dummy-Head Microphone”, MIT Media Lab, 1994 (diffuse.zipfrom https://sound.media.mit.edu/resources/KEMAR.html, SHA-256a48e73137390626ef1e45d1de6a4d98530c6aa570e71b40f7c4442241ed5fa22, which the script checks). The directions follow BS.2051’s 4+7+0 layout: the floor speakers at azimuths 0, 30, 90, and 135 at elevation 0, the heights at azimuths 45 and 135 at KEMAR’s 40 degree elevation, the left speakers taking their mirror images with the ears swapped, as the set’s symmetric responses intend. The script resamples each 128-tap 44.1 kHz response with a Kaiser-windowed sinc, fades its last 16 taps, and scales the set to a mean energy of 0.5 per ear, a stereo pan’s level for one channel.
A test module of these sources and test/atmos_test_exports.c, which adds exports to build scenes and read parsed metadata, backs test/Atmos.test.ts.
The tests parse hand-built payloads and real ones from TrueHD and E-AC-3 streams, pan objects onto every layout’s speakers, check power, fold-down, zones, ramps, and retargeting, and compare the binaural output with a direct convolution by the table’s taps.
The parser decodes the object metadata of the truehd crate’s Atmos test assets as truehdd does, positions within 3.2e-7 in ADM units.
After a change, from the engine root:
make -C wgpud/atmos module # Builds wgpud/build/atmos/module.wasm, the tests' module
npx vitest --watch=false wgpud/atmos
npm run decoders -- eac3 truehd # The packages that compile these sources
Licenses
- The engine’s modules, the bridges, the object audio sources in
wgpud/atmos/, the HEVC, JPEG 2000, MPEG-2, and VC-1 parsers, the decoder sources, and thelibdovicrate are MIT, like the rest of the engine. The vendoreddolby_visioncrate is MIT too and keeps its ownLICENSE. - FFmpeg and libdcadec are LGPL-2.1-or-later, and only the
eac3,truehd,wma, anddtspackages contain them.wgpud/licenses/holds their license texts,FFmpeg-COPYING.LGPLv2.1andlibdcadec-COPYING.LGPLv2.1.wgpud/truehd/truehd_presentation.cand its header, a port of FFmpeg’s MLP decoder, are LGPL-2.1-or-later too, and so aretruehd_substream.cand its header, which build on it. Theeac3package’s JOC sources (eac3_emdf.c,eac3_joc.c,eac3_qmf.c,eac3_joc_tables.c) are our own code, MIT; the tables are facts of ETSI TS 103 420. - libaom is BSD-2-Clause with the Alliance for Open Media Patent License 1.0, and only the
av1package contains it.wgpud/licenses/holds itsLICENSEaslibaom-LICENSE. - The HRIR table in
wgpud/atmos/atmos_hrir.c, which theeac3andtruehdpackages contain, comes from the MIT KEMAR measurements, “Copyright 1994 by the MIT Media Laboratory”, “provided free with no restrictions on use, provided the authors are cited when the data is used in any research or commercial application”; the file’s header cites them, as whoever distributes those packages must. - The engine ships no decoder package; the decoders repository’s releases do.
Whoever distributes the LGPL packages must offer their corresponding source:
make -C wgpud source-archiveswrites the exact FFmpeg and dcadec trees, asffmpeg-<commit>.tar.gzanddcadec-<commit>.tar.gz, and the decoders repository at its checked-out commit, aswebgpu-player-decoders-<revision>.tar.gz, towgpud/build/source-archives/, and every release carries the three.
Layout
| Path | Holds |
|---|---|
wasm/Makefile | The engine modules’ build |
wasm/libdovi/ | The Rust crate that wraps the dolby_vision RPU parser, and its vendored, patched copy of that crate |
wasm/audio-output-stage/ | The audio output stage’s kernels |
wasm/build/ | The engine modules’ build trees, ignored |
wgpud/ | The submodule of the decoders repository, with its own README, LICENSE, .gitattributes, .gitignore, and release workflow |
wgpud/Makefile | The decoder projects’ shared jobs: the pinned sources, the source archives, and clean |
wgpud/rules.mk | The rules every decoder project’s Makefile includes |
wgpud/<project>/ | One decoder project: its decoder.json, decoder source, vectors file, module sources, and tests in test/ |
wgpud/atmos/ | The object audio sources the eac3 and truehd modules compile, and their tests’ module |
wgpud/include/ | The audio decoder interface for C modules |
wgpud/licenses/ | Upstream license texts |
wgpud/build/ | The decoder projects’ build trees and source archives, ignored |
Codec vectors
A codec vector is a known input with a known answer. Qualification vectors ship to the browser, where the capability probes check native decoders against them before a route is advertised. A decoder package’s self-test vectors ship inside the package, and run only when a host verifies it (see Decoder packages). Test vectors only feed tests and generators.
The generators are Python 3.10 or later, in scripts/codec_vector_assets/, with their tests in scripts/codec_vector_assets/test/.
They write the committed bin/codec_vector_assets/, except the local playback media generators, which write the ignored bin/playback_smoke_media/.
A generate_ script makes its output from nothing but the repository and FFmpeg, while a create_ script derives it from media you supply.
Hand-made vectors live in src/capability/vectors/.
Generate or check everything
From the engine root:
python scripts/generate_all_codec_vector_assets.py # Writes bin/codec_vector_assets/
python scripts/generate_all_codec_vector_assets.py --check # Fails unless every committed asset matches
python -m unittest discover -s scripts/codec_vector_assets/test -p "*_test.py"
generate_all_codec_vector_assets.py runs every generator of committed assets in order, and passes --regenerate-sources to the TrueHD generator so its source streams are re-encoded and compared too.
It continues past a failure and exits with status 1 if any generator failed.
It does not run the local playback media generators.
bin/codec_vector_assets/ is the one committed folder in bin/, so every change to an asset shows in review.
.gitattributes stores it without line-ending conversion, so the committed bytes are the generated bytes.
Every committed asset reproduces with FFmpeg and FFprobe 2026-03-01-git-862338fe31-full_build-www.gyan.dev on PATH.
The HEVC range-extension, AV1 raw-format, H.264 raw-format, AV1 Dolby Vision, HDR10+ AV1, HDR10+ VP9, and WMA decoder generators refuse any other build.
Committed assets
| Script | Writes, in bin/codec_vector_assets/ |
|---|---|
generate_truehd_capability_vectors.py | truehd/TrueHDExactCapabilityVectors.ts, from the synthetic streams in truehd/. --regenerate-sources re-encodes those streams first |
generate_seven_point_one_downmix_reference.py | downmix-reference/seven-point-one.json. --verify-external also runs the referenced mpv and FFmpeg builds against it |
generate_HEVC_range_extension_vectors.py | hevc-range-extension/*.hevc. --inspect prints the evidence that capability/HEVCRangeExtensionCapabilities.ts records |
generate_AV1_raw_format_vectors.py | av1-raw-format/*.obu. --inspect prints the evidence that capability/AV1RawFormatCapabilities.ts records |
generate_H264_raw_format_vectors.py | h264-raw-format/*.h264. --inspect prints the evidence that capability/H264RawFormatCapabilities.ts records |
generate_mpeg2_decoder_vectors.py | Outside bin/: wgpud/mpeg2/vectors/, one Matroska MPEG-2 stream per chroma format from a deterministic syntax exerciser, since no available encoder writes field pictures, dual prime, 16x8 motion, concealment vectors, and 4:4:4 together; FFmpeg checks each stream |
generate_jpeg2000_decoder_vectors.py | Outside bin/: wgpud/jpeg2000/vectors/, one stream per output format from FFmpeg’s native and OpenJPEG encoders, across both wavelets, tiles, quality layers, every progression order, and SOP and EPH markers, in QuickTime files and in Motion JPEG 2000 files it muxes itself, and for the route tests OpenJPEG’s Digital Cinema 2K profile in X’Y’Z’ at 12 bits, which FFmpeg’s Matroska muxer writes as a V_MS/VFW/FOURCC track of mjp2 beside 5.1 FLAC and a JPEG cover attachment |
generate_av1_decoder_vectors.py | Outside bin/: wgpud/av1/vectors/, one Matroska stream per output format from FFmpeg’s libaom-av1 encoder, as AV1 decoder vectors describes |
generate_mpeg4_decoder_vectors.py | Outside bin/: wgpud/mpeg4/vectors/, the mpeg4 project’s self-test stream from libxvid and a stream per codec and container its routes take from FFmpeg’s encoders, as MPEG-4 decoder media describes |
generate_wma_decoder_vectors.py | Outside bin/: wgpud/wma/vectors/, the wma project’s media, FFmpeg’s WMA 1 and WMA 2 encodes in AVI and Matroska, remuxes of FFmpeg’s FATE samples of the other three codecs, and the WMA 2 packets beside H.264 video for the decode worker tests, and known-answers.json, FFmpeg’s decode of each audio-only medium, as WMA decoder vectors describes |
generate_eac3_joc_vectors.py | Outside bin/: wgpud/eac3/vectors/, the eac3 project’s Dolby Atmos media, remuxes of freely licensed E-AC-3 JOC streams, and known-answers.json, FFmpeg’s decode of each, as E-AC-3 Atmos vectors describes |
generate_decoder_package_vectors.py | Outside bin/: each decoder project’s wgpud/<project>/<project>.vectors, from the streams its decoder.json lists, demuxed with FFprobe and hashed from FFmpeg’s decode, so a project’s vectors are FFmpeg’s known answers; a vector’s codec private data is the stream’s extradata, or with codecPrivate bitmap-info-header a BITMAPINFOHEADER of its size and FourCC before it |
generate_dolby_vision_AV1_vectors.py | dolby-vision-av1/profile10.{0,1,2,4}.{mp4,mkv} and expectations.json, from the RPU payloads in src/capability/vectors/test/dolby-vision-rpu/ |
generate_HDR10_plus_AV1_vectors.py | hdr10plus-av1/hdr10plus.{mp4,mkv} and expectations.json |
generate_HDR10_plus_VP9_vectors.py | hdr10plus-vp9/hdr10plus.{webm,mkv} and expectations.json |
generate_AVI_demux_vectors.py | avi/*.avi, FFmpeg encodes in FFmpeg’s AVI muxer: H.264 High with B-frames in a pyramid and VBR MP3, H.264 with CBR MP3, VP8 with PCM, VP9 with AAC, AV1 with AC-3, HEVC with B-frames in a pyramid and an open group under HEVC and under the zero tag FFmpeg writes without one, MPEG-4 Part 2 under XVID (no route), and VP9 with 5.1 24-bit and float PCM and 5.1 FLAC as WAVE_FORMAT_EXTENSIBLE, stereo FLAC, stereo Vorbis, and three-channel 16-bit PCM as 2.1 and 3.0(back); and, rewritten by avi_rewriting.py and required to decode in FFmpeg to the encode’s frames, the VP9 encode in three OpenDML RIFF lists as FFmpeg’s muxer writes a file past 1 GiB, and an MPEG-4 Part 2 encode with B-frames under DX50 packed as DivX 5 packs it |
generate_transport_stream_demux_vectors.py | mpeg-ts/*.{ts,m2ts}: MPEG-1 with MP3 in MPEG-TS alone, since BDAV M2TS carries neither; MPEG-2 with B-frames and AC-3, and as mpeg2video-ac3-start600 with stereo AC-3 and every timestamp 600 s on, as a Blu-ray title’s program clock starts, FFprobe checking each encode’s start time; and the VC-1 qualification vector, which FFmpeg cannot encode, rebuilt as an elementary stream with its headers and frame start codes in band and remuxed, and in MPEG-TS also as vc1-advanced-unregistered.ts, whose PMT the generator strips of the VC-1 registration descriptor FFmpeg’s muxer writes, recomputing its lengths and CRC |
generate_program_stream_demux_vectors.py | mpeg-ps/*.{mpg,vob}: the MPEG-TS vectors’ MPEG-1 with MP3 in an MPEG-1 system stream from FFmpeg’s mpeg muxer, and MPEG-2 with B-frames and AC-3 in a DVD-Video program stream from its vob muxer, which carries the AC-3 in private stream 1 |
generate_chroma_siting_vectors.py | chroma-siting/*: H.264 and HEVC whose SPS declares a 4:2:0 chroma location only in band, in MPEG-TS, M2TS, AVI (H.264), and avc3 or hev1 MP4 and Matroska whose record the generator empties of parameter sets; and HEVC outside the bounded SPS parser’s bounds with its SPS in its hvcC: an interlaced field sequence, Main 12, and 4:2:2 10-bit. FFprobe checks each file’s codec, tag, pixel format, chroma location, frame count, and emptied record size |
Two helpers serve them: generated_output.py (shared write, check, and install helpers) and engine_layout.py (the engine’s folders, read from tools/constants.json through tools/constants.py).
avi_rewriting.py rewrites an FFmpeg AVI into the OpenDML layout FFmpeg’s muxer writes past 1 GiB, with a smaller RIFF list limit, and into DivX’s packed bitstream.
The TrueHD generator computes the TypeScript import paths it embeds from the same constants, so moving a folder only needs a rerun.
The AV1 Dolby Vision and both HDR10+ generators take the error they raise, the pinned FFmpeg check, JSON comparison, and bit packing from media_tools.py.
Both HDR10+ generators take the HDR10+ frame model, its ITU-T T.35 serialization, and its known answers for expectations.json and FFprobe from HDR10_plus_metadata.py.
The truehd decoder project’s tests import the TrueHD module as #codec_vector_assets/*, and scripts/library-assets.mjs serves the range extension, AV1 raw-format, and H.264 raw-format streams.
The generators work in one of three modes:
- Deterministic generators rewrite their output, and with
--checkfail when a committed file differs from a fresh run: the TrueHD module, the 7.1 downmix reference, the MPEG-2 decoder streams, and the decoder projects’ vectors files. - Encoder-dependent generators compare a fresh encode with the committed bytes: the JPEG 2000, AV1, and MPEG-4 decoder media and the TrueHD source streams under
--regenerate-sources. Another encoder build writes different bytes, so a differing encode never replaces a committed asset, while a missing one is installed. With--check, a missing asset fails as well. - The HEVC range-extension, AV1 raw-format, H.264 raw-format, AV1 Dolby Vision, HDR10+ AV1, HDR10+ VP9, and WMA decoder generators refuse any FFmpeg build but the pinned one. Before writing each encode the HEVC generator checks the access-unit lengths, the picture types, the profile and constraint flags of the VPS and SPS, and the decoded fingerprints. The AV1 raw-format generator runs the checks listed in AV1 raw-format vectors, and the H.264 raw-format generator those in H.264 raw-format vectors. The AV1 Dolby Vision generator runs the checks listed in AV1 Dolby Vision vectors, the HDR10+ AV1 generator those in HDR10+ AV1 vectors, and the HDR10+ VP9 generator those in HDR10+ VP9 vectors. The WMA decoder generator installs its media as the encoder-dependent generators do and rewrites its known answers from the committed media.
HEVC range-extension vectors
These Annex B files are deterministic WebCodecs capability probes. They are 192x192, because common NVIDIA HEVC decoders reject smaller coded sizes. They prove only that the exact codec configuration decodes, copies to the expected WebCodecs raw format, and produces the expected fingerprints. They prove nothing about performance, resolution or level limits, seeking, or transport.
Toolchain: FFmpeg 2026-03-01-git-862338fe31-full_build-www.gyan.dev, libavcodec 62.24.100, libx265 4.1+225-1b48507eb.
The source is FFmpeg testsrc2 at 192x192 and one frame per second.
Each of the nine vectors is one IDR access unit followed by one P access unit, which qualifies general inter decode for the exact tuple.
Every vector uses these x265 settings, serial because there is no thread pool and one frame thread:
-preset fast -crf 32
-x265-params info=0:pools=none:frame-threads=1:wpp=0:log-level=error:keyint=30:min-keyint=30:scenecut=0:bframes=0:repeat-headers=1
info=0 prevents the x265 user-data SEI, so nothing is stripped after encoding.
For example:
ffmpeg -f lavfi -i "testsrc2=size=192x192:rate=1:duration=2" \
-frames:v 2 -pix_fmt yuv422p10le -c:v libx265 \
-profile:v main422-10 -preset fast -crf 32 \
-x265-params "info=0:pools=none:frame-threads=1:wpp=0:log-level=error:keyint=30:min-keyint=30:scenecut=0:bframes=0:repeat-headers=1" \
-f hevc -y main422-10.hevc
After each encode the generator rewrites the profile, tier, and level fields of the VPS and SPS, removing and reinserting the emulation-prevention bytes around the rewritten RBSP:
- Level: every vector signals Level 3.1 (
general_level_idc93). x265 gets nolevel-idc, because with CRF it would enforce the level through VBV, which it reports as non-deterministic. Without it x265 signals Level 1 and the slices are byte-identical, so the generator writes 93 instead. - Profile: x265 does not emit general profile IDC 4 for 4:2:0 at 8 or 10 bits, so for
rext420-8andrext420-10the generator starts from conforming Main or Main 10 syntax and rewrites the fields to profile IDC 4, compatibility flag 4, and the general constraints9F.88or9D.88.
| File | Pixel format | x265 profile |
|---|---|---|
rext420-8.hevc | yuv420p | main, rewritten |
main422-8.hevc | yuv422p | main422-10 |
main444-8.hevc | yuv444p | main444-8 |
rext420-10.hevc | yuv420p10le | main10, rewritten |
main422-10.hevc | yuv422p10le | main422-10 |
main444-10.hevc | yuv444p10le | main444-10 |
main12-420.hevc | yuv420p12le | main12 |
main422-12.hevc | yuv422p12le | main422-12 |
main444-12.hevc | yuv444p12le | main444-12 |
For every vector the generator verifies the FFprobe access-unit lengths and I/P types, the VPS and SPS constraint flags, and raw plane fingerprints computed as in production.
Without --check, it replaces each committed file once its checks pass.
--inspect prints the measured evidence without comparing or writing.
test/capability/HEVCRangeExtensionCapabilities.test.ts verifies the VPS constraint bytes, the intra and one-picture flags, the access-unit lengths, and the I/P slice sequence.
Fingerprints use the production 32-bit FNV-1a sampler in capability/CustomDecodeCapabilities.ts: the plane dimensions, then a 64 by 36 uniform grid of samples over each output plane.
Re-encoding with another toolchain may not be byte-identical. Do not update an access-unit length, a constraint string, or a fingerprint until the static vector tests and the decoded raw output have been inspected together.
AV1 raw-format vectors
These low-overhead OBU streams (AV1 Section 5) qualify the raw-plane route for the AV1 formats outside Main 10, which the raw AV1 HDR probe qualifies.
Like the HEVC range-extension vectors they are 192x192, fetched as library assets, and prove only that the exact codec configuration decodes, copies to the expected WebCodecs raw format, and produces the expected fingerprints.
scripts/library-assets.mjs serves each av1-raw-format/<variant>.obu as webgpu-player/av1-raw/<variant>.bin.
Toolchain: FFmpeg 2026-03-01-git-862338fe31-full_build-www.gyan.dev, libavcodec 62.24.100, libaom 3.13.1-271-g85d21cdb24, libdav1d 1.5.3-18-gdaef396.
The source is FFmpeg testsrc2 at 192x192 and one frame per second.
Each of the eight vectors is one key temporal unit followed by one inter temporal unit, back to back, and only the key unit carries the sequence header.
Every vector uses these libaom settings, serial because there is one thread, no row multithreading, and one tile:
-usage good -cpu-used 6 -crf 40 -b:v 0 -g 30 -lag-in-frames 0 -threads 1 -row-mt 0 -tiles 1x1
Good-quality usage clamps its speed to 6, so a higher -cpu-used writes the same bytes.
No lookahead keeps each temporal unit to one shown frame, with no hidden alternate reference.
For example:
ffmpeg -f lavfi -i "testsrc2=size=192x192:rate=1:duration=2" \
-frames:v 2 -map_metadata -1 -fflags +bitexact -flags:v +bitexact \
-pix_fmt yuv422p10le -c:v libaom-av1 \
-usage good -cpu-used 6 -crf 40 -b:v 0 -g 30 -lag-in-frames 0 \
-threads 1 -row-mt 0 -tiles 1x1 \
-f obu -y professional422-10.obu
| File | Pixel format | Jellyfin profile | seq_profile | Codec string |
|---|---|---|---|---|
main420-8.obu | yuv420p | Main | 0 | av01.0.00M.08.0.110 |
high444-8.obu | yuv444p | High | 1 | av01.1.00M.08.0.000 |
high444-10.obu | yuv444p10le | High | 1 | av01.1.00M.10.0.000 |
professional422-8.obu | yuv422p | Professional | 2 | av01.2.00M.08.0.100 |
professional422-10.obu | yuv422p10le | Professional | 2 | av01.2.00M.10.0.100 |
professional420-12.obu | yuv420p12le | Professional | 2 | av01.2.00M.12.0.110 |
professional422-12.obu | yuv422p12le | Professional | 2 | av01.2.00M.12.0.100 |
professional444-12.obu | yuv444p12le | Professional | 2 | av01.2.00M.12.0.000 |
Before writing each vector the generator checks:
- the FFprobe temporal-unit lengths, one OBU demuxer packet each;
- the I/P picture types of libdav1d’s decode;
- the sequence header’s
seq_profile,BitDepth,mono_chrome0, and subsampling; - raw plane fingerprints of libdav1d’s decode, computed as in production.
Without --check, it replaces each committed file once its checks pass.
--inspect prints the measured evidence without comparing or writing.
test/capability/AV1RawFormatCapabilities.test.ts reads the committed bytes and verifies the temporal-unit lengths, the key then inter frame types, and the sequence header’s profile, depth, and subsampling.
The fingerprints use the same production sampler as the HEVC range-extension vectors.
Re-encoding with another toolchain may not be byte-identical, so update a length or a fingerprint only after inspecting the static vector tests and the decoded raw output together.
H.264 raw-format vectors
These Annex B streams qualify the raw-plane route for H.264 beyond 8-bit 4:2:0, the profiles beyond High.
Like the AV1 raw-format vectors they are 192x192, fetched as library assets, and prove only that the exact codec configuration decodes, copies to the expected WebCodecs raw format, and produces the expected fingerprints.
scripts/library-assets.mjs serves each h264-raw-format/<variant>.h264 as webgpu-player/h264-raw/<variant>.bin.
Toolchain: FFmpeg 2026-03-01-git-862338fe31-full_build-www.gyan.dev, libavcodec 62.24.100, libx264 core 165 r3223 0480cb0, whose version the generator reads from the user data SEI of an unfiltered encode.
The source is FFmpeg testsrc2 at 192x192 and one frame per second.
Each of the five vectors is an IDR access unit, a P access unit, and a B access unit, back to back in decode order, and only the IDR access unit carries the SPS and PPS.
The B picture presents between the other two, so its frame outputs before the P frame’s, and a probe that expected decode order would fail.
The capability definitions stamp each access unit with its presentation time: 0 s, 2 s, and 1 s.
Every vector uses these libx264 settings, serial because there is one thread and no sliced threads, with one B-frame, no adaptive B-frame placement, and no pyramid:
-preset medium -crf 30 -threads 1
-x264-params keyint=30:min-keyint=30:scenecut=0:bframes=1:b-adapt=0:b-pyramid=none:threads=1:sliced-threads=0
-bsf:v filter_units=remove_types=6
The bitstream filter drops every SEI NAL unit, x264’s version and option string among them. For example:
ffmpeg -f lavfi -i "testsrc2=size=192x192:rate=1:duration=3" \
-frames:v 3 -map_metadata -1 -fflags +bitexact -flags:v +bitexact \
-pix_fmt yuv422p10le -c:v libx264 -profile:v high422 \
-preset medium -crf 30 -threads 1 \
-x264-params "keyint=30:min-keyint=30:scenecut=0:bframes=1:b-adapt=0:b-pyramid=none:threads=1:sliced-threads=0" \
-bsf:v filter_units=remove_types=6 -f h264 -y high422-10.h264
| File | Pixel format | x264 profile | profile_idc | Codec string |
|---|---|---|---|---|
high420-10.h264 | yuv420p10le | high10 | 110 | avc1.6E000B |
high422-8.h264 | yuv422p | high422 | 122 | avc1.7A000B |
high422-10.h264 | yuv422p10le | high422 | 122 | avc1.7A000B |
high444-8.h264 | yuv444p | high444 | 244 | avc1.F4000B |
high444-10.h264 | yuv444p10le | high444 | 244 | avc1.F4000B |
Before writing each vector the generator checks:
- the FFprobe access-unit lengths, one packet of FFmpeg’s H.264 demuxer each;
- the slice types I, P, B in decode order, from each access unit’s first slice header, and the picture types I, B, P of FFmpeg’s decode in presentation order;
- the SPS’s
profile_idc,chroma_format_idc, and both bit depths, and the codec string, which is the SPS’s profile, constraint flags, and level; - raw plane fingerprints of FFmpeg’s decode in presentation order, computed as in production.
FFmpeg’s H.264 decoder is bit-exact, and Chrome’s software H.264 decoder is FFmpeg’s, so Chrome decodes each vector to the pinned fingerprints.
Without --check, it replaces each committed file once its checks pass.
--inspect prints the measured evidence without comparing or writing.
test/capability/H264RawFormatCapabilities.test.ts reads the committed bytes and verifies the access-unit lengths, the NAL unit types, the slice types, the SPS’s profile, chroma format, and bit depths, and the codec string.
Re-encoding with another toolchain may not be byte-identical, so update a length or a fingerprint only after inspecting the static vector tests and the decoded raw output together.
AV1 decoder vectors
These are the av1 decoder project’s self-test media, one Matroska stream per output format.
wgpud/av1/decoder.json lists them with the decoder options -c:v libdav1d, so the package’s known answers are libdav1d’s decode, film grain included.
libaom’s decoder decodes every frame of every stream to the same bytes as libdav1d.
generate_av1_decoder_vectors.py --print-declarations prints that list.
FFmpeg’s libaom-av1 encoder encodes each stream in one thread at speed 0 and CRF 50, and the generator runs the nine encodes at once.
Each stream is 13 frames at 24 frames per second with a key frame every 12, so it holds two key frames.
A 19-frame lookahead makes four hidden alternate references, three or four of which show_existing_frame shows.
The source is a lavfi canvas of 256x192 that rotates, zooms, and pans as a whole, from the identity at the first frame, which libaom codes with global and local warped motion:
- a frozen 64x64 Game of Life pattern of two colours at (0, 0), copied to (128, 64) and (128, 128), where IntraBC may copy it;
- a second pattern at (64, 0), for palette;
- two
testsrc2pictures, whose moving parts give OBMC, compound, and inter-intra prediction; - smooth planes that
geqdraws at (192, 64), drifting each frame, for 64-point transforms. lavfi’sgradientssource draws random points whatever its seed, so it is not used.
libaom lets an IntraBC block copy only from more than four 64x64 superblocks back in its tile, in raster order. With 64x64 superblocks the tiles are 3+1 columns and 2+1 rows of superblocks, so the copy at (128, 64) and the pattern at (0, 0) share a tile three superblocks wide. With 128x128 superblocks there is one tile, and the copy at (128, 128) is one superblock row below the pattern.
Every encode also asks for screen content tools (tune-content=screen), segmentation (-aq-mode 1), delta quantizer and loop filter levels, and quantization matrices.
It turns off the temporal filtering of key frames, which blurs the copies IntraBC needs.
| File | Format | Superblocks and tiles | Film grain |
|---|---|---|---|
av1-420-250x186.mkv | I420 | 64x64, four non-uniform tiles | Schedule A |
av1-420p10-253x187.mkv | I420P10 | 64x64, four non-uniform tiles | libaom test table 3 |
av1-420p12-256x192-sb128.mkv | I420P12 | 128x128, one tile | Schedule B |
av1-422-254x190.mkv | I422 | 64x64, four non-uniform tiles | libaom test table 1 |
av1-422p10-249x183-sb128.mkv | I422P10 | 128x128, one tile | Schedule A |
av1-422p12-256x192.mkv | I422P12 | 64x64, four non-uniform tiles | libaom test table 6 |
av1-444-251x189-sb128.mkv | I444 | 128x128, one tile | Schedule B |
av1-444p10-256x188.mkv | I444P10 | 64x64, four non-uniform tiles | libaom test table 14 |
av1-444p12-247x191.mkv | I444P12 | 64x64, four non-uniform tiles | Schedule A |
A schedule is a libaom film grain table the generator writes beside the encode, one entry per range of frames:
- schedule A: autoregression lag 0 with chroma scaled from luma, then no grain, then lag 1 without overlap, then lag 3;
- schedule B: lag 2 with chroma scaled from luma and without overlap, then lag 3, then no grain, then lag 1.
The table has bare line feeds, because libaom’s reader stops at a carriage return without an error and the encode then has no grain. libaom’s test tables carry no lag 0 or 1, but they clip to the restricted range, which a table cannot ask for.
The streams hold no super-resolution, reference scaling, dual filters, lossless frames, intra-only frames, or switch frames, so a self-test does not cover them.
The pinned FFmpeg cannot ask its libaom for super-resolution or a frame size change, because that libaom takes no superres-mode or resize-mode key-value option, and libaom’s speed features disable dual filters at every speed.
MPEG-4 decoder media
These are the mpeg4 decoder project’s media: its self-test stream, and a stream of each codec in each container the engine routes it from, which the route tests play.
wgpud/mpeg4/decoder.json lists the self-test stream with the decoder options -threads 1 -flags +bitexact, FFmpeg’s C code in one thread, and with codecPrivate bitmap-info-header, the codec private data the engine passes the package.
The self-test stream is 200x120 from libxvid, with global motion compensation, quarter-sample motion, and two B-VOPs between anchors.
Its source is testsrc2 at twice the size, turned and zoomed into its center a little more each frame, so libxvid codes S-VOPs among its P-VOPs.
libxvid writes a raw stream, from which the generator drops the N-VOPs libxvid writes for its delayed frames, as a native Matroska muxer drops them.
FFmpeg then remuxes it into Matroska with the presentation timestamps it generates, since FFmpeg’s libxvid output in Matroska stamps each P-VOP before the B-VOPs shown ahead of it.
| File | Stream |
|---|---|
mpeg4-gmc-qpel-b.mkv | The self-test stream, V_MPEG4/ISO/ASP |
mpeg4-b.mp4 | FFmpeg’s MPEG-4 Part 2 with B-VOPs, mp4v |
mpeg4-b-xvid.avi | FFmpeg’s MPEG-4 Part 2 with B-VOPs under XVID, its VOL in the first packet |
mpeg4-b-vfw.mkv | FFmpeg’s MPEG-4 Part 2 with B-VOPs in a V_MS/VFW/FOURCC track of XVID, at decode-order timestamps |
msmpeg4v3.avi | MS-MPEG4 V3 under DIV3 |
msmpeg4v3.mkv | MS-MPEG4 V3 in V_MPEG4/MS/V3 |
msmpeg4v2-vfw.mkv | MS-MPEG4 V2 in a V_MS/VFW/FOURCC track of MP42 |
msmpeg4v2.avi | MS-MPEG4 V2 under MP42 |
h263.3gp | H.263 at sub-QCIF, s263 |
h263.avi | H.263 at sub-QCIF under H263 |
h263p-vfw.mkv | H.263+ at 200x120 with advanced intra coding, the deblocking filter, and unrestricted motion vectors, in a V_MS/VFW/FOURCC track of H263 |
The other streams are 16 frames with a key frame every 8, the MPEG-4 Part 2 and MS-MPEG4 ones at 176x144.
Every encoder runs in one thread, since the MPEG-4 Part 2 encoder’s slice threads change its bitstream with the thread count.
FFmpeg’s Matroska muxer writes MPEG-4 Part 2 only as a native track, so the generator rewrites its file into mpeg4-b-vfw.mkv.
The rewrite sets the codec ID, a CodecPrivate of the BITMAPINFOHEADER FFmpeg’s muxers write followed by the VOL, and block timestamps one frame apart in decode order, as an AVI stamps its frames.
It drops the seek head and the cues, whose offsets it moves.
Each medium must probe as its codec, FourCC, and size, show its picture types, and decode one frame per packet in the reference decode without an error. The self-test stream must also present its frames in the order of their timestamps.
WMA decoder vectors
These are the wma decoder project’s media and FFmpeg’s known answers for them, which wgpud/wma/test/WMAPackage.test.ts decodes through the package and the engine’s WAVEFORMATEX reader, and two media with video for the engine’s decode worker tests, which play only sources with video.
The generator writes them with the pinned FFmpeg, libavcodec 62.24.100.
| File | Stream | Source |
|---|---|---|
wmav1.avi | WMA 1, 48 kHz stereo, 96 kb/s, 12 packets | FFmpeg’s wmav1 encoder |
wmav1.mkv | The same packets in an A_MS/ACM track | A remux of wmav1.avi |
wmav2.avi | WMA 2, 44.1 kHz stereo, 128 kb/s, 11 packets | FFmpeg’s wmav2 encoder |
wmav2.mkv | The same packets in an A_MS/ACM track | A remux of wmav2.avi |
wmapro-5.1.mkv | WMA Pro, 48 kHz 5.1 with a WAVE_FORMAT_EXTENSIBLE header, 2 packets | wmapro/latin_192_mulitchannel_cut.wma |
wmalossless-16.mkv | WMA Lossless, 44.1 kHz stereo 16-bit, 2 packets | lossless-audio/luckynight-partial.wma |
wmalossless-24.mkv | WMA Lossless, 48 kHz stereo 24-bit, 2 packets, the whole sample | lossless-audio/Mega_Weird_Audio_Test_24bit.wma |
wmavoice.mkv | WMA Voice, 8 kHz mono, 4 packets | wmavoice/streaming_CBR-7K.wma |
wmav2-h264.avi | The packets of wmav2.avi beside 5 frames of 64x64 H.264 at 10 frames per second; no known answer | libx264 and the packets of wmav2.avi |
wmav2-h264.mkv | The same, the audio in an A_MS/ACM track | libx264 and the packets of wmav2.avi |
The two encodes are half a second of a 440 Hz tone on the left channel and a 660 Hz tone on the right, from lavfi’s aevalsrc, so a decode in the wrong channel order shows:
ffmpeg -f lavfi -i "aevalsrc=exprs=0.5*sin(2*PI*440*t)|0.25*sin(2*PI*660*t):sample_rate=44100:duration=0.5:channel_layout=stereo" \
-c:a wmav2 -b:a 128k -map_metadata -1 -fflags +bitexact -flags:a +bitexact -f avi wmav2.avi
ffmpeg -i wmav2.avi -map 0:a -c copy -map_metadata -1 -fflags +bitexact -flags:a +bitexact -f matroska wmav2.mkv
The Matroska files are remuxes of the AVI files, so both carry the same packets, which the generator checks, and no Matroska CodecDelay. With a CodecDelay, FFmpeg’s decode would skip the encoder’s 2048 priming frames instead of the 4096 frames of its decoder’s own delay, which the package, without the container’s skip, skips.
The two media with video take the packets of wmav2.avi too, beside libx264’s encode of testsrc2 in one thread with the ultrafast preset and a key frame every 5 frames:
ffmpeg -f lavfi -i testsrc2=size=64x64:rate=10 -i wmav2.avi -map 0:v -map 1:a -frames:v 5 -pix_fmt yuv420p -c:v libx264 -preset ultrafast -g 5 \
-threads 1 -flags:v +bitexact -c:a copy -map_metadata -1 -fflags +bitexact -flags:a +bitexact -f matroska wmav2-h264.mkv
FFmpeg has no WMA Pro, WMA Lossless, or WMA Voice encoder, so the other media come from FFmpeg’s FATE suite, https://fate-suite.ffmpeg.org/. The generator downloads each sample over HTTPS, refuses one whose SHA-256 differs, and remuxes its first packets into Matroska:
| Sample | SHA-256 |
|---|---|
wmapro/latin_192_mulitchannel_cut.wma | 3f3a18ce22ac061d4abc38b553a1d00cfe6795d76e2366c1aa5aa7a4c8c230b8 |
lossless-audio/luckynight-partial.wma | 33d7dc28f717abcc8ef7f35c1c836840503e2d888c3dec833c53314eba81aa1b |
lossless-audio/Mega_Weird_Audio_Test_24bit.wma | 1ce71dcd98db91d23896e84c602c774fc93eeb6afc94fd762382744d680d2bbd |
wmavoice/streaming_CBR-7K.wma | 049a5a4c758b07b874af9a8a2473ff2a3cc8234f79e387dfd6daf6780a58c6d5 |
ffmpeg -i latin_192_mulitchannel_cut.wma -map 0:a -c copy -frames:a 2 -map_metadata -1 -fflags +bitexact -flags:a +bitexact -f matroska wmapro-5.1.mkv
The 5.1 WMA Pro sample is the suite’s one multichannel WMA Pro sample; its fourth packet is cut short, and the remux keeps the first two, about three seconds. FFmpeg’s Matroska muxer writes its six channels as WAVE_FORMAT_EXTENSIBLE, with the channel mask of 5.1 with back speakers.
known-answers.json names the FFmpeg build and the decoder options -cpuflags 0, FFmpeg’s C code without assembly, and records per vector without video its codec, sample rate, channel count, WAVE channel mask, frame count, and the SHA-256 of FFmpeg’s interleaved 32-bit float PCM:
ffmpeg -cpuflags 0 -c:a wmapro -i wmapro-5.1.mkv -f f32le wmapro-5.1.f32
An answer is FFmpeg’s whole decode, the frames FFmpeg’s decoder holds back until it drains at the end of the stream included, which WMA Lossless and WMA 1 and 2 have; the test drains the package after the last packet. A layout without a channel mask, as WMA 1 and 2 have, records the mask of FFmpeg’s default layout of its channel count, which the bridge reports.
The package decodes every vector to the same bytes as FFmpeg’s C code, WMA Lossless’s integers and every lossy codec alike, so the test compares the SHA-256. FFmpeg’s default decode, with its x86 assembly, differs from it by up to 7e-7 in WMA Voice and 2e-7 in WMA 1, WMA 2, and WMA Pro, and matches it in WMA Lossless.
E-AC-3 Atmos vectors
These are the eac3 decoder project’s Dolby Atmos media and FFmpeg’s known answers for them, which wgpud/eac3/test/EAC3Package.test.ts decodes through the package at every render target.
generate_eac3_joc_vectors.py downloads each source at a pinned commit, requires its pinned SHA-256, and remuxes its first packets into Matroska with the pinned FFmpeg, checking that the packets are the source’s.
| File | Stream | Source |
|---|---|---|
joc-5.1.mkv | E-AC-3 with JOC, 48 kHz 5.1 core, 15 JOC objects, OAMD of a 5.1 bed and 10 dynamic objects, EMDF in audio-block skip fields, 16 packets | The first packets of sample_eac3joc.ec3 from androidx/media commit 8c6678b, libraries/test_data/src/test/assets/media/ts/, Apache-2.0, a Dolby encode |
joc-5.1.2-dependent.mkv | E-AC-3 with JOC, a 5.1 independent substream and a dependent one with the top front pair, JOC downmix configuration 2, 15 objects, EMDF in the dependent substream’s auxiliary data, 1 packet | idx2.ec3 from OpenJOC commit e3e330a, crates/openjoc-api/tests/fixtures/admission/, Apache-2.0, a synthetic encode |
known-answers.json holds FFmpeg’s decode of each vector with its C code (-cpuflags 0): the stock decode the package outputs whenever its target needs no objects, which it matches byte for byte.
AV1 Dolby Vision vectors
These test vectors are Dolby Vision Profile 10 in MP4 and in Matroska, one pair per sub-profile: 10.0 (CCID 0), 10.1 (CCID 1, BT.2020 PQ base), 10.2 (CCID 2, BT.709 base), and 10.4 (CCID 4, BT.2020 HLG base).
Each is a libaom AV1 Main 10 encode of testsrc2, 192x192 and four frames with a key frame every second frame.
Every temporal unit carries one Dolby Vision ITU-T T.35 metadata OBU, and the container signals the configuration record: a dvvC box in a dav1 (10.0) or av01 sample entry, or a Matroska BlockAdditionMapping.
The RPUs are the dovi_tool payloads from src/capability/vectors/test/dolby-vision-rpu/, carried byte for byte as the EMDF payload of the T.35 message:
- 10.0 alternates
profile5.binandprofile5-02.bin; - 10.1 and 10.2 use
profile8.bin; - 10.4 uses
profile84.bin.
Each frame’s RPU therefore parses to the same output as the HEVC parse of its source file, which is the known answer.
expectations.json records, per vector, the container, sample entry, Dolby Vision configuration, and color, and per frame the source RPU, the key flag, and the SHA-256 of the T.35 message.
FFmpeg’s own AV1 Dolby Vision encoding is not used.
Its encoders rebuild each RPU from the decoded metadata, which reorders extension blocks, and its command line cannot signal 10.0, which needs the RPU header when the encoder is set up.
Instead the generator inserts the T.35 OBUs after the sequence header, where libaom places metadata, adds a dvvC box to an intermediate MP4, and lets FFmpeg remux that file into both vectors, so FFmpeg writes the container records.
It then renames the 10.0 MP4 sample entry to dav1, which FFmpeg cannot write.
Before writing, it checks:
- the RPU and key flag of every temporal unit;
- the sequence header color;
- the MP4 sample entry and boxes;
- what FFprobe reads back from both containers;
- that FFmpeg parses the same Dolby Vision metadata from each T.35 OBU as from the same RPU carried as HEVC NAL 62.
test/demux/dolby-vision/DolbyVisionAV1DemuxIntegration.test.ts opens every vector with Mediabunny, splits each temporal unit with the engine’s splitter, and requires the parser’s packed output to equal the HEVC parse of the source RPU.
The vectors prove the demux, the split, and the parse; they prove nothing about decode or presentation.
HDR10+ AV1 vectors
These test vectors are one AV1 Main 10 encode in BT.2020 PQ, in MP4 (hdr10plus.mp4) and in Matroska (hdr10plus.mkv).
It is a libaom encode of testsrc2 with the settings of the AV1 Dolby Vision vectors, 192x192 and six frames with a key frame every third frame.
The generator writes every metadata payload itself, field by field, and inserts the metadata OBUs before each temporal unit’s frame, where libaom places metadata:
- each key frame’s unit carries a mastering display color volume (MDCV) OBU and a content light level (CLL) OBU: BT.2020 primaries, a D65 white point, 1000 and 0.005 nits, MaxCLL 940, and MaxFALL 410;
- the first, second, and sixth units carry a profile B HDR10+ ITU-T T.35 OBU, each with its own curve, targeted display, and distribution;
- the fourth unit carries a profile A HDR10+ OBU, without a curve and with a targeted display of 0, whose message ends in a zero byte that must stay when the trailing bits are removed;
- the third and fifth units carry no HDR10+.
FFmpeg muxes the stream into the MP4 and remuxes the MP4 into Matroska.
Both keep the metadata OBUs in each sample, and the av1C record FFmpeg writes repeats the first unit’s MDCV and CLL OBUs.
expectations.json records the color, the coded MDCV and CLL values, and per frame the key flag, whether its unit carries MDCV and CLL, the coded HDR10+ values, and the T.35 message without its trailing bits.
Before writing, the generator checks:
- the metadata OBUs of every temporal unit, read back from the stream;
- the codec, color, packet sizes, and key flags FFprobe reads from the stream and from both containers, and that neither container carries static HDR metadata of its own;
- the side data FFmpeg decodes for every frame of both containers, the independent oracle: libdav1d reports the MDCV and CLL on every frame from the first key frame on, and HDR10+ only on the frame whose unit carries it.
test/demux/hdr/AV1HDR10PlusDemuxIntegration.test.ts reads both vectors’ packets with Mediabunny and requires the engine’s extraction and static scan to read every frame as recorded.
test/pipeline/CustomDecodeWorkerAV1Route.test.ts plays them through the worker with a stand-in WebCodecs decoder, and requires every posted frame to carry its recorded HDR10+ result.
The vectors prove the demux, the extraction, and the routing; they prove nothing about decode or presentation.
HDR10+ VP9 vectors
These test vectors are one VP9 Profile 2 encode, 10-bit in BT.2020 PQ, in WebM (hdr10plus.webm) and in Matroska (hdr10plus.mkv).
It is 192x192 at 24 frames per second, eight frames with a key frame every fourth frame, and each group of four frames is one cluster.
VP9 has no metadata of its own, so each frame’s HDR10+ is an ITU-T T.35 message in a BlockAdditional of its block, under FFmpeg’s BlockAddID 4:
- the first, second, sixth, seventh, and eighth frames carry profile B metadata with different curves, targeted displays, and distributions, up to 15 percentiles and 15 anchors, and the last two are identical;
- the fourth frame carries profile A metadata, without a curve and with a targeted display of 0;
- the third frame and the second key frame carry none.
Toolchain: the pinned FFmpeg, with libx265 4.1+225-1b48507eb and libvpx v1.16.0-56-g4fcebeabe.
FFmpeg writes it all:
- libx265 encodes
testsrc2with onedhdr10-infoentry per frame, so FFmpeg decodes HDR10+ frame side data. - The
sidedatafilter deletes that side data from the frames without HDR10+, andsetparamstags every frame BT.2020 PQ, because libvpx-vp9 keeps HDR10+ only when the encoder opens on a PQ transfer, which it reads from the first frame. - libvpx-vp9 encodes Profile 2 in one thread, without lookahead or alternate reference frames, so every packet is one shown frame, and passes each frame’s HDR10+ to its packet.
- The WebM muxer writes each packet’s HDR10+ as a BlockAdditional, without a BlockAdditionMapping, which WebM does not have.
- FFmpeg remuxes the WebM into Matroska, which adds the BlockAdditionMapping of BlockAddID 4 to the ITU-T T.35 type.
expectations.json records the color, and per frame the key flag, the coded HDR10+ values, and the BlockAdditional bytes.
Before writing, the generator checks:
- every block’s BlockAdditionals, read back with its own EBML walk, against the frame’s ST 2094-40 serialization in
HDR10_plus_metadata.py, and the DocType, Colour, BlockAdditionMapping, key frames, and clusters of each container; - the codec, profile, color, key flags, and HDR10+ side data FFprobe reads from every packet of both containers, the independent oracle.
test/demux/MatroskaBlockAdditions.test.ts reads both vectors’ block additions from the fork’s packet side data, through Mediabunny’s formats and the worker’s, and fails when any block’s additions no longer arrive as recorded.
test/demux/hdr/HDR10PlusVP9DemuxIntegration.test.ts plays both through the owned VP9 path with a stand-in decoder, from the start and from the second key frame, and requires every frame’s HDR10+ result to be the recorded one.
test/pipeline/CustomDecodeWorkerOwnedVP9.test.ts plays them through the worker with a stand-in WebCodecs decoder, and requires every posted frame to carry its recorded HDR10+ result.
The vectors prove the demux, the extraction, and the routing; they prove nothing about decode or presentation.
Local playback media
These write media for smoke tests through a host in a real browser.
The generate_ scripts write to the ignored bin/playback_smoke_media/ unless given --output-directory, and the create_ scripts write to the output path they are given.
Real playback evidence comes from the host, not from the checks above.
| Script | Writes |
|---|---|
generate_playback_smoke_media.py | HDR10 and HLG HEVC Main 10 playback media, and audio-switch variants |
generate_static_HDR_validation_vectors.py | Static HDR validation media |
generate_native_HEVC_High_Tier_validation_vector.py | A native HEVC High Tier stream |
generate_dolby_vision_AV1_playback_media.py | 1080p Dolby Vision Profile 10 AV1 with stereo AAC, per sub-profile, in MP4 and Matroska. The RPUs do not describe the test pattern, so the files prove only that the pipeline runs |
create_separate_track_dolby_vision_vector.py | From an interleaved Profile 7 Matroska: one BL track, and one Profile 7 EL and RPU track |
create_dual_track_dolby_vision_MP4_vector.py | From the separate-track output: an MP4 with a base HEVC track and a dependent dvh1 or dvhe enhancement track |
create_profile7_playback_vector.py | From the separate-track output: a dual-track MP4 for full playback tests. Its BL is scaled to 1080p and re-encoded, so it is no color reference |
create_dual_PID_dolby_vision_TS_vector.py | From a two-track Profile 7 file: a dual-PID MPEG-TS whose EL entry carries a Profile 7 dependency descriptor |
create_container_only_HVCE_vector.py | A Matroska copy whose wrapped EL parameter sets are replaced by filler, so the EL decodes only from the container’s hvcE |
The create_ scripts transform a Profile 7 source you supply.
They and both AV1 Dolby Vision generators take an option for each tool they run: FFmpeg and FFprobe from --ffmpeg and --ffprobe or PATH, and MKVToolNix from --mkvtoolnix-directory, then %ProgramFiles%\MKVToolNix on Windows, then PATH.
create_container_only_HVCE_vector.py rewrites the Matroska file itself and runs no tool.
media_tools.py holds that lookup, the bounded tool runner they share, and a check that refuses an output path naming the input file, by case variant or link included.
Hand-made vectors
| Folder | Holds |
|---|---|
src/capability/vectors/qualification/vc1/ | The VC-1 Advanced stream that the vc1 decoder project’s vector holds |
src/capability/vectors/test/dts/ | Public-domain dcadec samples, packet tables, and reference WAVs, and FFmpeg’s stereo and 5.0 DTS core encodes with one tone per channel (PROVENANCE.txt) |
src/capability/vectors/test/ac3/ | FFmpeg’s AC-3 encode of a 3/2 bed that gains its LFE, one tone per channel, for the eac3 package’s 5.0 and 5.1 layouts (PROVENANCE.txt) |
src/capability/vectors/test/truehd/ | A Matroska remux of one generated TrueHD stream (PROVENANCE.txt) |
src/capability/vectors/test/dolby-vision-rpu/ | RPU payloads, copied unmodified from dovi_tool assets/tests (MIT), for the RPU parser, color transform, and Dolby Vision integration tests and the AV1 Dolby Vision generators |
src/capability/vectors/test/hdr10plus-tool/ | x265’s --dhdr10-opt HDR10+ profile A sample and the metadata hdr10plus_tool extracts from it, copied unmodified from hdr10plus_tool assets/hevc_tests (MIT, PROVENANCE.txt pins the commit and SHA-256s), for the HDR10+ parser and carry tests |
Tests
Most scripts have a test module in scripts/codec_vector_assets/test/; the TrueHD, 7.1 downmix reference, and decoder package vector generators have none.
A test adds the generator folder to the import path, so it also runs on its own:
python scripts/codec_vector_assets/test/generated_output_test.py
Tools
tools/ holds development and validation tooling.
Nothing in it ships.
Run every command from the engine root.
tools/constants.json is described in Repository layout.
Diagram renderer
node tools/render-diagrams.mjs [--check | --watch] [book directory ...]
render-diagrams.mjs renders each book’s PlantUML sources from diagrams/ into light and dark SVGs in src/diagrams/, and removes SVGs whose source is gone.
With no book directory it renders this book; the Jellyfin plugin passes its own docs/ too.
--check renders into a temporary folder and fails on any difference.
It needs Java, and downloads the pinned PlantUML jar into bin/plantuml/ on first use.
See Maintaining this book.
Decoder package tools
node tools/pack-decoder-package.mjs --decoder <source> [--module <module.wasm>] [--vectors <vectors>] --output <package.wasm>
pack-decoder-package.mjs packs one decoder package by hand: a decoder source, the module of its parser or audio decoder, and its vectors file.
It checks the result with the engine’s own loader, so a package it writes loads wherever the engine runs.
decoder-package-format.mjs holds the writer it shares with scripts/decoder-packages.mjs, which builds the decoder projects (see WebAssembly build).
mpv shader converter
python tools/convert_mpv_shader.py <input.glsl> <output.wgsl> [--name <name>] [--naga <executable>]
convert_mpv_shader.py converts an mpv or libplacebo user shader into a pass file, through naga-cli 30.0.1, and reports where the result differs from mpv.
Its tests are in tools/test/.
.github/workflows/tools.yml checks out the wgpup submodule and runs the tests in tools/test/ on every push or pull request that changes tools/ or the wgpup gitlink, with the naga-cli version the converter pins, which it builds once and caches.
See Migrate an mpv shader to a wgpup.
Cube LUT converter
python tools/convert_cube_lut.py <input.cube> <output.wgsl> [--name <name>] [--hook <stage>] [--domain <domain>]
convert_cube_lut.py turns a .cube 3D LUT into a pass file: one color pass that grades each pixel through the LUT, which the file holds as a three-dimensional texture block of half floats, read trilinearly at texel centers over the LUT’s DOMAIN_MIN and DOMAIN_MAX.
By default the pass grades the source’s encoded RGB, at RGB in the source domain, which a LUT made for Rec. 709 video expects of an SDR source; --hook OUTPUT --domain sdr grades what the display shows instead.
The pass name defaults to the LUT’s TITLE, else the file’s name.
A 1D LUT, a size outside 2 to 256, or a table of the wrong length does not convert, with a code and a detail.
pass_texture_block.py writes the texture block, as the mpv shader converter does for a TEXTURE.
Its tests are in tools/test/.
See Texture blocks.
Browser probes
| File | Does |
|---|---|
probe-browser-runtime.mjs | Configuration-only browser diagnostics |
probe_dynamic_HDR_shader.py, probe-dynamic-HDR-shader-browser.mjs, emit_dynamic_HDR_shader.ts, probe_dynamic_HDR_vector.ts | Compile the production HDR10+ WGSL in headless Chromium |
Sandbox
The sandbox is a local web app for testing the engine against local media.
It plays files from a folder you pick on a full-window surface, and every control and diagnostic is an overlay panel above it.
It lives in sandbox/ and never ships.
Run it
You need:
- A checkout set up as in Set up a checkout, with the engine’s modules built, since the sandbox serves the engine’s assets.
- To play codecs no browser decodes, decoder packages, such as the pinned release
npm run pinned-releasesinstalls intobin/wgpud/, or local builds fromnpm run decoders. - A browser with WebGPU and WebCodecs.
Steps:
- From the engine root, run
./sandbox.sh, in Git Bash on Windows. It checks that the npm dependencies are installed, runsnpm run pinned-releases, which installs the pinned decoder packages and pass files intobin/wgpud/andbin/wgpup/, then runsnpm run sandbox, which builds the served assets withnode scripts/build.mjsand starts the Vite dev server fromsandbox/vite.config.tson port 5180. It opens the sandbox in the default browser, or in the oneBROWSERnames, unless you pass--no-open. Its other options go to Vite, such as--port 5190. Without the script, runnpm run sandboxin the engine root, ornpm run sandbox -w webgpu-playerfrom a host’s root, afternpm run pinned-releasesfor the packages and passes. - When no browser opened, open
http://localhost:5180/.localhostis a secure context, so WebGPU works without HTTPS. - Click Pick folder and choose a folder of media.
The library panel then lists the folder’s playable files, and clicking one plays it.
Layout
| Region | Panels |
|---|---|
| Left | The library: the picked folder’s tree of playable files |
| Right of the library | Playback info |
| Top | The pipeline graph of the active presentation plan |
| Right | Tone mapping, the route override, the capability probes, the decoder packages, and the WGSL shader passes |
| Bottom | The transport bar, with the audio and subtitle selectors |
Subtitle cues show at the bottom center of the video, above the transport bar.
Press H to hide every panel and see the video unobstructed, and press it again to show them.
Subtitles stay while the panels are hidden.
The key is ignored while an input, a select, or a text area has focus, and it is OVERLAY_TOGGLE_KEY in sandbox/src/ui/OverlayToggle.ts.
The library
- A file is listed when the engine’s container rules in
capability/CustomContainerCodecSupport.tsaccept its extension, throughisCustomPlaybackContainer, so the list follows the engine’s container table. - Entries whose names start with
.are skipped with everything below them, which covers.gitand the._files macOS writes beside media on other file systems. - A
.srt,.vtt,.ass, or.ssafile in the same folder belongs to a media file when its base name is the media’s base name, or starts with it and a., ignoring case.movie.en.srtbelongs tomovie.mkv, andmovie 2.srtdoes not. - Where the browser has
showDirectoryPicker, as Chromium does, the picked folder’s handle is remembered in IndexedDB. After a reload the library reopens it, or shows Grant access when the browser wants a click before it allows reading again. - Elsewhere, as in Firefox, the library picks the folder through a folder file input (
webkitdirectory), and nothing is remembered across reloads. NOTE: Brave turnsshowDirectoryPickeroff by default, so it takes the folder input and its browser-enforced “Upload” confirmation;brave://flags/#file-system-access-apiturns the directory picker back on. - A folder or file that cannot be read is recorded as a failure and left out, and the scan goes on.
How a file plays
- Clicking a file opens an audio context while the click’s user activation lasts, since the probe’s waits would outlast it.
- The session probes the file with ffprobe compiled to WebAssembly, from ffmpeg.wasm.
One command reads the streams and the container format, and a second reads the first video frame’s side data, where HDR10+ metadata shows.
A first frame that cannot be read is recorded as
first-frame-unreadable, and the file plays without HDR10+. sandbox/src/session/PlaybackMediaSource.tsturns the probe into the media description the engine’s eligibility reads: the container, and each video and audio stream with its codec, profile, level, size, bit depth, color, and dynamic range. The dynamic range is Dolby Vision when the stream has a Dolby Vision configuration record, else PQ, HLG, or SDR from its transfer, and PQ is HDR10+ when the first frame carries HDR10+ metadata. ffprobe’s field order passes asFieldOrder, and any order butprogressivemakes the streamIsInterlaced.- The route starts on the audio track marked default, else the first, through the engine modules a host uses: the capability probes, the presentation authorizations and color pipeline from
presentation/PresentationSetup.ts, the eligibility,CustomPlaybackController, andCustomPlaybackFrameLoop. The controller reads the picked file itself, as afilesource, and takes the duration from the container.
When things go wrong:
- A route that fails to start records why, and stops with everything it set up.
- The controller’s request to fall back to a browser player, which the sandbox does not have, is recorded as
fallbackwith its reason and media time, and stops the route. - Changing the audio track or the route override restarts the media where its clock stopped, after a fallback too.
About ffmpeg.wasm:
- Its core is about 32 MB of WebAssembly, and every command loads a fresh core and terminates it, about 50 ms once the browser caches the core.
A core keeps a command’s state for the next: ffprobe keeps its options in globals, so on a used core a stream probe kept the first-frame probe’s
-show_framesand walked every frame of the next file, which never ended for a large one. - It runs in its own worker, one command at a time, and each command reads the file in place through a WORKERFS mount instead of copying it into memory.
- It reports -1 for every ffprobe run that returns, failed or not, because only FFmpeg’s
exit_programsets the exit code. So the probe passes-show_error, and a failed probe is the error section in ffprobe’s output, recorded asffprobe-errorwith FFmpeg’s message. @ffmpeg/ffmpegand@ffmpeg/coreare dev dependencies of the engine.@ffmpeg/coreis GPL-2.0-or-later, and only the sandbox serves it, so nothing that ships includes it.
Playback controls
- Clicking the video outside the panels pauses it while it plays, and resumes it while it is paused.
The decision is
getSurfaceClickActioninsandbox/src/ui/SurfaceClick.ts. - Scrolling over a slider or a value box moves it and applies the value at once, as a drag or a committed entry does.
A notch moves a slider a fiftieth of its range, or a five-hundredth with Shift, and a value box one step, never past its limits;
sandbox/src/ui/ControlWheel.tsholds the rules. - A value typed into a box applies only when Enter or leaving the box commits it, and the panels’ refreshes leave a value being typed alone.
- Each slider, value box, and select has a Default button beside it, whose title names the default. The defaults are what a file opens with: the engine’s tone mapping settings, the pass manifest’s parameter default, full volume, Auto routes, the media’s default audio track, and subtitles off. A route or audio track change restarts the media, so those buttons do nothing at the default.
- The timeline seeks on click.
While it is dragged, it seeks at most once per
SCRUB_SEEK_INTERVAL_MILLISECONDS, insandbox/src/ui/panels/TransportBarPanel.ts, and once more where the drag ends. - The playhead and the time follow the session clock every animation frame, while the rest of the state refreshes a few times a second.
- The transport bar’s playback rate select offers the add-on’s rates, 0.1x to 16x, through
SandboxSessionCommands.setPlaybackRate, and every later start, such as a new route’s, keeps the rate. A change that restarts the generation, across the audible range’s boundary (see Clock), is handled as a seek’s result is. - The audio selector lists the media’s audio tracks. The subtitle selector lists its text-based subtitle tracks and the subtitle files beside it, and leaves out image-based tracks such as PGS and DVD subtitles. Both have an Off choice.
- A selected subtitle track or file is converted to WebVTT with ffmpeg and shows as plain-text cues, without styling tags.
- The tone mapping panel edits
HDRToSDRRenderSettingswithinHDR_RENDER_SETTING_RANGES, both frompresentation/RenderSettings.ts, and switches HDR output. It checks every change withassertValidRenderSettingsbefore sending it, and shows the engine’s reason for a value it rejects. - The route override asks for a frame transport, raw planes or a
VideoFrame, and a video decoder: Native (WebCodecs), or GPU (package), which decodes on the presenter’s device. The session then restarts the media where its clock stopped. The panel shows the route the session plays, with the GPU decoder’s id on the frame-pool route, and the reason for its choice.
The route override
The override narrows what the engine’s eligibility may choose from rather than replacing its choice, so the eligibility still validates every route the session plays.
It is in sandbox/src/session/RouteOverride.ts.
- A transport override withdraws the presentation authorizations of the other transport.
VideoFramewithdraws every raw-plane route, Dolby Vision reconstruction included, and raw planes withdraw the native HDR, native Dolby Vision, and external HDR routes. An SDRVideoFrameroute needs no authorization, so raw planes cannot withdraw it. - A decoder override narrows the video decoders.
Native offers no GPU decoder package, so only WebCodecs remains, and it cannot force a route the capability probes rejected.
GPU withdraws every WebCodecs video route, so the file plays the frame-pool route whenever a package takes it and fits the device.
Auto offers the packages too, after the WebCodecs routes, as a host does.
Every package offered is the user’s, verified or not;
allowsGPUVideoDecodersandnarrowCapabilitiesForDecoderhold the rules. - When the chosen route leaves part of the override unmet, the session records
route-override-unmetwith what was asked and what was chosen, and plays the route anyway. The frame-pool route is neither transport, so GPU with a transport override always reports the transport unmet.
Capability probes
The capability probe panel shows what the engine’s probe store holds, in sandbox/src/ui/panels/ProbeCachePanel.ts; see Probes and caching.
- It names the stored verdicts’ count, the age of their last write, and their fingerprint’s build key, adapter, and browser version, and how long the latest start waited for its probes.
- It lists the quarantine one line each:
blocked <label>for a step that stopped a page alone,suspect <label>for a step awaiting its retry alone, andceilingwith only its capped dimensions, such asceiling 47 decodersorceiling <n> bytes <m> decoders, when a crash came from concurrency. - Clear probe cache empties the store, quarantine included, so the next start, or the next page load, probes again.
- Warm up probes starts the engine’s background warm-up, which a host starts at load; the sandbox does not, so a start right after a load measures the cold or stored path.
- The build key is the asset key joined with a value the dev server sets at each start, since the server serves changed engine sources without a new asset build. The sandbox tells the engine that a loading, playing, paused, or seeking file holds background probes.
Decoder packages
The sandbox has no decoders of its own; the decoder package panel holds the ones the user adds, which every later start of a file uses where WebCodecs cannot decode.
It is in sandbox/src/ui/panels/DecoderPackagePanel.ts, over sandbox/src/session/DecoderPackages.ts.
- Open packages, or drop
.wasmfiles on the panel, to add them. Load built packages adds every package inbin/wgpud/at once: the dev server lists that folder’s files at/__sandbox/built-decoder-packages(sandbox/src/session/BuiltOutputRoutes.ts), and a static build of the sandbox, which has no such route, reportsbuilt-packages-unavailable. Each file loads with the engine’s checks before it is stored, and a file the engine refuses is recorded with the check’s code, such aspackage-import, and skipped. - The packages stay in the sandbox’s own IndexedDB database across reloads, through
DecoderPackageStore, by the id each header declares. A stored package that no longer loads, as after an engine change tightened a check, is recorded aspackage-stale. - Adding a package of an id the panel lists, such as a rebuilt one, replaces that package in its place, and its row drops the replaced version’s vector results.
- Each package shows its name, file, codecs, and output formats, the formats it carries self-test vectors for, and its version, the leading digits of its file’s SHA-256; the name’s tooltip holds the id and the whole hash.
- Verify runs the package’s vectors on the presenter’s device, one format at a time, and shows each result:
passed, or the failed check and its detail. Nothing verifies a package unless you press it, and the results last until the page reloads or the package is replaced. - The eligibility takes the packages in the order they were added, and Remove deletes one.
Diagnostics
- The pipeline graph draws the active presentation plan from left to right: the source with its route, one node per planned pass with its kind, targets, and color steps, and the canvas with its format and tone mapping.
Edges follow each texture from the pass that writes it to every pass that reads it, and history reads are dashed and labeled with their offset, such as
[-1]. The passes the plan left out are listed beside the graph asreason: detail. - An interlaced file’s plan shows
Presentation deinterlaceafter the signal, reading the signal’s frame,[-1], and[-2]. The Deinterlacing select above the graph switches between Double rate, the default, Single rate, and Off, throughWebGPUPresenter.setDeinterlaceMode, and keeps its mode across files. - The Crop black bars checkbox beside the Deinterlacing select, off by default, crops the black bars encoded into the video through
WebGPUPresenter.setBlackBarCropEnabled, and keeps its choice across files. While it is on, the plan ends withPresentation black bars, a compute passper source linethat reads the source and writes each row’s and column’s statistics to a storage buffer (see Black bars); the picture the bars leave then fits the canvas, so the crop shows on a canvas of another shape than the frame’s. - Playback info groups the media and its tracks, the route and the override, the clock, video decode, audio, presentation, authorization, and the failure log, newest first.
Its sections collapse, and stay collapsed across refreshes.
The clock section’s source row names
media clock, without audio at this playback ratewhile a rate outside the audible range sets the audio track aside. The presentation section’s Black bars row shows the bars the presenter crops, in the frame’s samples:offwhile the crop is off,noneuntil bars are found, elsetop,bottom,left, andright, and the standard aspect ratio the picture presents at, such as2.39:1 Modern Anamorphic Scope, when it rounds to one. On the frame-pool route, video decode adds a GPU decoder row: its name, state, and failure, its pool’s free slots, and its decoded, held-back, reused, and stream change counts. - The shader panel adds the bundled passes, passes from files, or files dropped on it.
Its bundled pass selector lists the pass files in
bin/wgpup/, whichnpm run pinned-releasesinstalls from the pinned passes release, ornpm run passescopies from thewgpup/submodule: the dev server lists them at/__sandbox/built-passes, and a pick fetches that file and recordsbuilt-pass-unavailablewhen it cannot. A static build of the sandbox has no such route, so its selector stays disabled. It lists each file with its order controls, a plan state per pass, which names the pass when the file holds several, and an input per parameter, which applies when committed to every pass of the file that declares it. - Files dropped anywhere but the shader panel and the decoder package panel are ignored, so a stray drop never navigates away from the sandbox.
How it is built
| Path | Holds |
|---|---|
sandbox/src/contracts/SandboxContracts.ts | The contract between the layers: the state the session and the library publish, and the commands the panels send |
sandbox/src/ui/Store.ts | One immutable state object, shallow-merged updates, and selector subscriptions |
sandbox/src/library/ | The media library: the folder pickers, the scan, the tree, and the remembered folder |
sandbox/src/session/ | The playback session, which drives the engine, the user’s decoder packages, and the ffprobe probe with its translation into the engine’s media description |
sandbox/src/ui/App.ts | The layout, which mounts each panel into its region, toggles them, and binds the click on the video |
sandbox/src/ui/panels/ | One module per panel, each with its own stylesheet |
sandbox/src/ui/diagnostics/ | What the diagnostics panels show, as pure functions of the state: the graph layout, the playback info sections, and the shader file rows |
sandbox/src/ui/theme.css | The shared variables every stylesheet reads instead of restating colors |
test/sandbox/ | The sandbox’s unit tests, which npm test runs with the engine’s |
Every part follows these rules:
- Panels read the state and send commands; they never call the engine.
- Media time is integer microseconds,
Microsecondsfromsrc/MediaTime.ts, andsandbox/src/ui/TimeFormat.tsformats it. - A failure is a code and a terse detail with the failing value, recorded in the state’s failures with the layer that failed.
A command records its failure before it rejects, and panels end command promises with
reportCommandRejectionfromsandbox/src/ui/commands.ts, which only logs. sandbox/src/ui/App.cssdraws every panel’s box and the shared control look at zero specificity, so a panel stylesheet styles its contents and overrides only what it needs.
Recipes
Short procedures for the changes that come up most. Each assumes a checkout set up as in Set up a checkout.
Add or change a codec route
- Capability.
Add an exact-output probe to
src/capability/CustomDecodeCapabilities.ts. Give a video probe an identifier inCustomDecodeVideoProbeandCUSTOM_DECODE_VIDEO_PROBES, and select it inselectItemVideoProbesfor the streams that need it, or no item runs it. For an HEVC range extension:- add its definition to
src/capability/HEVCRangeExtensionCapabilities.ts; - add its vector to
scripts/codec_vector_assets/generate_HEVC_range_extension_vectors.pyand generate it intobin/codec_vector_assets/hevc-range-extension/; - add its served path to
src/EngineAssets.ts. The asset build serves every vector in that folder, and theEngineAssetstest checks that the two agree.
- add its definition to
- Presentation:
- add the route key to
src/validation/RawHDRPresentationAuthorization.ts, or to the external or Dolby Vision authorization beside it; - add the route’s built-in signal to
src/presentation/passes/builtin/BuiltinSignals.tsand its chain toBuiltinChains.ts; - add the plane uploads to
src/presentation/passes/PlaneTextureSource.ts, and the raw copy tosrc/decode/video/RawVideoFrameCopy.ts; - add any new raw format to
src/pipeline/DecodeWorkerProtocol.ts.
- add the route key to
- Runtime.
src/capability/CustomPlaybackEligibility.tsselects the route, andsrc/presentation/PresentationInput.tsparses the color and Dolby Vision descriptors. - Container pairing stays only in
src/capability/CustomContainerCodecSupport.ts. Add no resolution, level, frame rate, or bitrate gate, and no decoder pair blacklist. - Tests.
Cover the route’s selection and fallbacks in
test/capability/CustomPlaybackEligibility.test.ts. - Update HEVC and Dolby Vision support, and Eligibility and routes if the route catalog changed.
- Advertise the route in the host. The Jellyfin plugin’s book has the recipe for its device profile and its support matrix tests.
Check: the engine checks from Set up a checkout, and the host’s checks.
Add a codec that only a package decodes
- Name it as FFmpeg does, in
CUSTOM_VIDEO_CODECSandCUSTOM_PACKAGE_VIDEO_CODECSor in the audio codec lists, where its capability readsno-engine-decoder. Map the names a media server reports for it in both name tables, insrc/capability/CustomPlaybackEligibility.tsandCustomDecodeCapabilities.ts, and give it a case in every switch the type checker names. - Pair it with its containers in
src/capability/CustomContainerCodecSupport.ts. - For video, give the worker its frame-pool route in
src/pipeline/CustomDecode.worker.ts: the container codec IDs it reads, and the codec private data and packets a package receives, with any container reader undersrc/demux/. Describe that codec private data in Decoder packages, since every package of the codec depends on it. - Build a decoder project for it in
wgpud/<project>/, as the next recipe says, whose header accepts the codec’s name. - Cover the eligibility, the container reach, and the worker’s route in
test/, and the project in its owntest/.
Change or add a decoder project
- In the
wgpud/submodule, a checkout of the decoders repository, edit, or create,wgpud/<project>/: itsdecoder.json, its decoder source, whose header declares the project’s name as itsid:, and, for a module, aMakefilethat includes../rules.mkand buildsmodule. List its self-test vectors indecoder.json, one per output format at most, and runpython scripts/codec_vector_assets/generate_decoder_package_vectors.pyto write its vectors file. - Run
npm run decoders -- <project>; it needs the decoder toolchain (see WebAssembly build), and a C module needsEMSDKset or the engine’s emsdk frommake -C wasm emsdk. - Run its tests,
npx vitest --watch=false wgpud/<project>, which load the package frombin/wgpud/. - Run
npm run decoders -- --check, which must report every package identical. A change that is not reproducible shows up here. - Update the project table in WebAssembly build.
- Commit the change in the decoders repository and push it to
main, then release it with its Release workflow, as Release a repository says. - In the engine, run
node scripts/pinned-releases.mjs --pin decoders <tag>, move thewgpudsubmodule to the released commit, and commit the gitlink andtools/pinned-releases.jsonin one engine commit.
Change or add a pass project
- In the
wgpup/submodule, a checkout of the passes repository, edit, or create,wgpup/<project>/with its pass files: WGSL files at the project’s top level, each of one or more passes in the pass file format, under a file name no other project uses. A generated pass keeps its generator and inputs in the project and is committed, so a build needs no generator. An mpv user shader converts withtools/convert_mpv_shader.py(see Migrate an mpv shader to a wgpup). - For a new project, add it to
EXPECTED_PROJECTSintest/presentation/passes/PostprocessingPasses.test.ts. Runnpx vitest --watch=false test/presentation/passes, whose pass rules every pass file must keep. - Run
npm run passes, which copies the pass files intobin/wgpup/, and pick the pass in the sandbox’s shader panel to see it. - Update the project table in Bundled passes.
- Commit the change in the passes repository and push it to
main, then release it with its Release workflow, as Release a repository says. - In the engine, run
node scripts/pinned-releases.mjs --pin passes <tag>, move thewgpupsubmodule to the released commit, and commit the gitlink andtools/pinned-releases.jsonin one engine commit.
Rebuild one engine module
- Edit
wasm/<kit>/,libdovioraudio-output-stage. - Run
make -C wasm <kit>, withEMSDKset or the engine’s emsdk frommake -C wasm emsdk. - Run
npm test. - Run
make -C wasm check, which must report every output identical.
Change the Dolby Vision parser
- Edit
wasm/libdovi/src/lib.rs, or the vendored crate inwasm/libdovi/vendor/dolby_vision/. - For any change to the vendored crate, add an entry to its
PATCHES.mdnaming the file, the change, and the FFmpeg behavior it follows. - If the packed snapshot changes, update
src/demux/dolby-vision/DolbyVisionRPUDataLayout.ts, the decoder inDolbyVisionRPUParser.ts, the reshaping insrc/color/DolbyVisionColorTransform.ts, and the schema version inlib.rsandDolbyVisionRPUParser.tstogether. The revision prefix names the vendored dovi_tool commit and changes only when the copy moves to another upstream revision. - From
wasm/libdovi/, runcargo test --lockedandcargo clippy --locked --all-targets. - Run
make -C wasm libdovi, thenmake -C wasm check KITS=libdovi. - Run
npm test. The parser tests intest/demux/dolby-vision/run the rebuiltdovi-rpu-parser.wasm.
Revise a committed codec vector
- For an encoder-dependent vector (the TrueHD sources, the JPEG 2000 decoder vectors), delete the committed file. A differing encode never replaces one.
- Run its generator with the pinned FFmpeg on PATH, for example
python scripts/codec_vector_assets/generate_truehd_capability_vectors.py --regenerate-sources. A stream a decoder project’sdecoder.jsonlists also needspython scripts/codec_vector_assets/generate_decoder_package_vectors.py <project>, which rewrites that project’s self-test vectors. - Run
python scripts/generate_all_codec_vector_assets.py --checkand the generator tests. - Review the diff of
bin/codec_vector_assets/and of thewgpud/submodule, and update any pinned fingerprint or length only after inspecting the decoded output. A changed decoder project’s vectors are committed in the decoders repository and reach the engine as the recipe Change or add a decoder project says.
Move an engine folder
- Change the folder in
tools/constants.json. Every reader in Repository layout follows. - Edit by hand the files that cannot read it:
.gitignore,.gitattributes,.gitmodules, andtsconfig.json, whosepathsentry names the Mediabunny fork’s folder;sandbox/vite.config.ts, whose alias names that folder too, and the host’s webpack, Vitest, and tsconfig aliases of it;package.json: its scripts, and itsimportsmap, which sends#codec_vector_assets/*to the generated modules;- each decoder project’s
decoder.json, whose vectors name a layout key and a path under it; - this book.
- Rerun the DTS and TrueHD generators, which embed import paths computed from the constants, then run every
--checkand the full engine checks.
Capture a timing trace
- Call
startTimingTrace()fromwebgpu-player/TimingTracebefore the play request, so the decode run the play starts is asked for its events. A run started before the trace sends none; the next seek’s run does. - Play the passage under investigation.
- Call
exportTimingTrace(metadata)and save the result as JSON.stopTimingTrace()stops recording but keeps the events exportable until the next start orclearTimingTrace(). - Read the events in time order.
timeMillisecondscounts from the trace start,realmsays whether the page or the decode worker recorded it, anddroppedEventCountis nonzero when the ring buffer overwrote the oldest events.
Ship an engine change to the host
Commit the change in the engine repository and push it before the host commits the new submodule pointer, so the host never pins a commit nobody can fetch.
Update this book
Decisions
This chapter records settled decisions about the engine.
The Jellyfin plugin’s book records the decisions about its integration.
Dates are month-day in 2026, UTC.
Commit hashes refer to the Jellyfin Web fork’s webgpu-player branch, where the engine was developed until it became its own repository on 10-02.
Negotiation
- No static performance gates (08-06).
Width, Height, VideoLevel, VideoFramerate, startup throughput benchmarks, and FPS or headroom tiers were removed from every custom route (
db8dbc7622). A capability means the path implements the codec, profile, and output contract, and vector geometry is output evidence only. The real limits stay: codec and profile, bit depth, interlacing, container, and exact decoder acceptance. Since 10-15 interlacing limits only HEVC, JPEG 2000, and the Dolby Vision routes (see Deinterlacing). - No artificial size limits (10-08). Nothing refuses media for its frame size, sample rate, or packet size alone. The bundled HEVC, JPEG 2000, and MPEG-2/VC-1 size caps, the raw-copy byte budget, the packet and container read caps, the 3 kHz to 192 kHz audio window, and the per-packet audio frame counts were removed. Above 192 kHz the resampler widens its kernel in proportion, so its band edge stays where the 192 kHz qualification put it. The real bounds remain: the level’s DPB, a representable copy layout, a decoder package’s 1 GiB memory and its own header’s limits, the device’s largest storage binding for a frame pool, the adapter’s texture limit, and what a browser decoder accepts. Until the CPU decoders left (10-13), the WASM heap bounded them too: 4 GiB for HEVC and MPEG-2/VC-1, and 2 GiB in the prebuilt OpenJPEG decoder. Limits that pace or chunk work stay as tuning: transfer credits, queue depths, pending windows, and output chunk sizes. So do guards against corrupt data that no real stream reaches, such as header and RPU size bounds and the 2 MiB audio packet bound.
- Live performance adaptation is deferred to a separate runtime controller that would react to sustained drops, queue starvation, underruns, and A/V drift, with warm-up, hysteresis, and cooldown.
- One composition matrix (08-05,
9a1c3f2922).capability/CustomContainerCodecSupport.tsdecides only whether a container carries a codec. Each track is qualified on its own. Never add decoder pair blacklists. hasEligibleCustomVideoRouteanswers for one item (10-06). Jellyfin labels some Dolby Vision streams in ranges a host’s generic routes do not pair with their profile and depth, so the engine tells a host whether this item has a runtime route, and the host advertises exactly that item’s route. Since 10-16 it judges GPU decoders by package summaries, because offers need the packages and the presenter’s device, which negotiation may not have. Since 10-27 a summary may carry its package’s header, which a host reads from a server’s catalog withparseGPUDecoderHeaderbefore it has the package, and the buffer limit of the device a playback would decode on. With them it runs the offers’ fit check,monochromeincluded, so an item past the package’sacceptsconditions or the device’s limit, such as an 8K Dolby Vision item on an HEVC base, is not advertised. A summary without a header still takes every stream of its codecs and output formats.- Video probes run per item; audio probes always run (10-08).
Probing every codec on the first negotiation of a page blocked PlaybackInfo for about 2.6 s, mostly on decoders the item did not contain.
A video stream cannot change within a negotiation, since another version or item renegotiates, so the video probes follow the item’s streams and run on demand.
An audio track can change during playback, so every audio probe runs for every item.
A probe outside the selection reads
not-probed, never a verdict, so an unprobed codec is not advertised. - Probe downloads run in parallel and outside the decode timeouts (10-08). The exact probes armed their timeouts before downloading their vectors and binaries, and the range-extension probes downloaded inside the queue’s 2 s timed slot, so a slow cold link failed a capable decoder as unsupported or timed out every later probe. A run now starts every selected probe’s downloads at once and each probe decodes only once its own are done; decoding then stayed one probe at a time, until the probe manager ran a negotiation’s steps in parallel (see “A negotiation’s probe steps run in parallel within derived limits, and the background’s one at a time”, 11-05). Worker scripts and glue were warmed in the HTTP cache rather than spawned early, so no worker or compile competed with a running probe’s real-time-factor measurement. The exact probes and their measurements left with the CPU decoders (10-13), so only the range-extension probes download vectors, and nothing is warmed.
- Probes warm in idle time and their verdicts persist per fingerprint (10-27). Even per item, a page’s first negotiation waited for its probes, about 2.6 s cold on some clients, and every page load paid it again. A warm-up competes with browsing, so each background step waits for an idle period with a 2 s timeout, for no playback, and for 10 s after a negotiation, the time its playback takes to start; its downloads are low priority. A negotiation promotes a background probe it needs and never waits behind more than the one background step already running, which since the probe manager (11-05) a negotiation’s steps may even join; a background timeout halts only the background, so it never poisons a negotiation’s probes. Clients that ask to save data or have fewer than four threads get no warm-up, since speculative work costs them most; their stored verdicts still rerun after use. Only verdicts are stored, keyed by a fingerprint of the host build, the adapter, and the full browser and OS versions, because the user agent alone carries only a major version and a frozen OS. What no fingerprint shows, a driver or codec pack change, the acceleration setting, or a software fallback after a GPU process crash, is caught by rerunning each stored verdict after use and by clearing the store when a start it may have admitted fails before its first frame.
- A probe that stops the page never runs again for its fingerprint, and a browser it stopped probes in the background no more (11-05).
Since the step quarantine (see “A stopped page quarantines its steps, not its probes”, 11-05), the quarantine is per step, probes are no longer skipped whole, and the background keeps probing.
On iOS, a host that starts the warm-up at load reloaded about every 8 s, in Safari and in an app’s web view, until Safari reported that a problem repeatedly occurred, as it does when a page’s WebContent process keeps ending.
On WebKit the warm-up runs a probe’s first browser call after two 2 s idle bounds, and a probe whose browser call ends the WebContent process reaches no verdict, so every reload runs it again.
Each page now records a probe while the browser works on it, under a Web Lock of the page’s own, and the next page reads the record of a page whose lock is free as a crash: the probe read
not-probedfrom then on, and the warm-up and the refreshes stopped, until the step quarantine replaced both. Web Locks, rather than a record in sessionStorage, tell a stopped page from one that runs, since a crash may come back in another tab or a recreated web view, and another tab’s live record must never read as a crash. A page holds its lock only while it has a record, and lets go of both as it enters the back-forward cache: Chrome 156 caches a page that holds or held a Web Lock, but evicts it once another page requests a lock it holds, and no page asks for the lock of a page without a record. Only the browser calls were recorded, the configuration checks and each heavy step until its own work settled, so a page closed while it downloads or waits for a turn records nothing; nor does a hidden page, which a browser may end without a crash. A crash is kept per fingerprint like a verdict, so a new build or browser version probes again, and the store names the probe that stopped the page inreadStoredCapabilityProbeVerdictState(). Each recorded call also carries a short label of the step, such ascheck mp4a.40.2ordecode aac, which the record keeps and the crash inherits, and the page that finds a crash tells the host throughonCapabilityProbeCrash, because a probe name alone did not say which call ended the WebContent process, and an affected user can report the crash without Web Inspector. Crashes and records stored before the labels read as crashes without steps, so the schema version stayed 1; the step quarantine moved it to 2. - Audio probes copy each AudioData in its own format (11-05).
Safari 26.0 to 26.3 ends the WebContent process in
AudioData.copyTo(..., { format: 'f32-planar', planeIndex })with a plane index of 2 or more on its interleaved multi-channel output (WebKit bug 302521, fixed in Safari 26.4). The 5.1 and 7.1 audio steps asked exactly that, the likely cause of a WebContent crash on iOS at load, when a host starts the warm-up then.copyAudioDataPlanescopies interleaved data once and planar data once per plane, in the data’s own format, and converts to float in JavaScript, so the browser never converts a sample layout; Mediabunny avoids the same call at playback. - Known browser defects are a declared table (11-05).
A step that matches
capability/BrowserDefects.tsnever runs and readsunsupportedwith the reasonbrowser-defect, because a call that may end the page is not worth a verdict. Its entry is WebKit bug 308322 (rdar 170845543): AAC decoding with more than 2 channels on WebKit before Safari 27, or of unknown version, since AudioToolbox can return no format description for the raw AudioSpecificConfig and the first decode dereferences it; fixed in Safari 27 (WebKit 625). iOS 26 freezes the OS version in the user agent at 18.x and a web view has noVersion/, so an unknown version counts as affected. It is not confirmed that the engine’s configurations trigger it; the step is skipped as a precaution. - Raw format probes first ask whether the browser has the pixel format (11-05).
canHoldVideoFrameFormatbuilds a 2x2 VideoFrame of the format, and aTypeErrormeans the browser has no such format, as every shipping Safari lacks the 10-bit and 12-bit formats. The raw HDR, range-extension, AV1 raw format, and H.264 raw format probes then readunsupportedwithoutput-copy-unsupportedwithout downloading their vector or decoding. - The HEVC exact vectors are the recipe’s output from the pinned toolchain, which VideoToolbox decodes (11-11).
Chrome on macOS and FFmpeg’s VideoToolbox hwaccel rejected the old
main-1080pandmain10-4kaccess units, sonativeHDRHEVCreadunsupportedon Apple Silicon. Those bytes were not what the recipe produces: the recipe run with FFmpeg2026-03-01-git-862338fe31-full_build-www.gyan.devand its x265 4.1+225-1b48507eb reproduces the committed vectors byte for byte, decodes on both, and its 4K frame matchesHEVC_MAIN10_BLACK_DECODED_FRAME_FINGERPRINT. - A negotiation’s probe steps run in parallel within derived limits, and the background’s one at a time (11-05).
The heavy queue ran one probe at a time, so a cold page waited for the sum of its decodes.
CapabilityProbeManageradmits every browser call as a step with a cost from the codec specs; a negotiation’s steps run side by side while the running decoders and bytes stay within limits, and a step larger than the limits runs alone. A background step that is not promoted starts only when nothing runs and no negotiation step waits, and none joins it but a negotiation’s, so playback never waits on the warm-up and a crash in the background alone names its one call. In Chrome, a full warm-up ran 105 calls with at most one in flight. The byte limit is the largest cost any probe definition can issue, so parallel probing never holds more than the heaviest single test’s own estimate, and the decoder limit isnavigator.hardwareConcurrency, else 1. A step holds its reservation until its call settles, not until its timeout, since a timed-out decoder still holds its memory; timeouts count from admission, so queue time never fails a decoder. A timed call still unsettled one more timeout after its own stops the page’s probing, because the browser has then stopped answering; a background timeout still halts only the background. The MSE AC-3/E-AC-3 playbacks run through the same manager as untimedplaysteps, since each playback bounds its own stages, and each H.264 profile’s check and decode is a step of its own rather than one step for every profile. A decode whose call overlapped another and settles with a negative result runs again alone, timed anew, and its caller gets that result (confirm); a call that ran alone, a timeout, or an error is never rerun. A hardware decoder pool may refuse a decoder only because the calls beside it hold theirs, and a refusal would be stored asunsupportedfor the fingerprint. In Chrome, a parallel negotiation confirmed 12 decodes that fail there, the HEVC range extensions and 12-bit AV1 among them, and reached the verdicts of a run one call at a time. One wrapper,CapabilityProbeSession.record, blocks, orders, and records every step, so no probe can reach the browser around the quarantine. - A stopped page quarantines its steps, not its probes (11-05).
With steps in parallel, a stopped page may have several calls in flight, and blocking every probe they belong to would hide working decoders.
One label in flight is blocked for the fingerprint and reads
unsupportedwith the reasonstopped-page. Several labels become suspects, which the next round retries once, each alone ({ alone: true }) and after the steps ahead of it, with the decoder limit at 1 until the last suspect settles: a suspect that stops the page alone is blocked, and the others clear. Another step that stops the page alone in that round is blocked too, but it is not the suspects’ culprit, since it was not in their crash. When no suspect is the culprit, the crash came from concurrency, and the store caps each dimension the crash held some of, decoders and bytes, one below its in-flight total, which caps the limits for the fingerprint. A dimension the crash held none of, such as the bytes of checks alone, says nothing about that dimension, so it stays uncapped (CapabilityProbeCeilingholds null for it). The background keeps probing, running probes that hold suspects last; fingerprints include the build key, so the quarantine resets on every build or browser update. - External HDR authorization decodes as recorded probe steps (11-05).
It is the one authorization that opens a hardware decoder, so a crash in its check or decode would end the page with no record, and every reload would run it again.
Its check and decode now run through the page’s probe session under the probes
external-hdr-authorization:hevcandexternal-hdr-authorization:av1, so they are quarantined like capability probe steps, and a blocked or defective step rejects the route asstopped-pageorbrowser-defect. The decode step decodes, imports, renders, and reads back within the manager’s step timeout, and returns decisions rather than the frame, so no frame outlives its reservation. The routes of one codec and target format asked for in one task share one check and one decode, and a codec’s batches run one after another, so a device never holds two of that codec’s decoders; a deadline that passes during the check opens none. External Dolby Vision returnsvideo-frame-creation-failedbefore compiling anything when the browser cannot hold anI420P10VideoFrame, as shipping Safari cannot. - Authorization error scopes are pushed and popped in one task (11-05). The runners at device creation, 36 while external HDR still started there, share the device’s one error scope stack, so a runner that popped its scope after an await could take another runner’s. Each runner, and the canvas readback, now pushes and pops its validation scope around its submit in the same task, and awaits the popped result later.
- An authorization timeout is not kept (11-05).
A runner’s 5 s deadline counts the time it waits behind the other runners on the device, so a
timeoutordevice-lostdecision says nothing about the route. Such a decision stays visible, and the next authorize call probes the route again. - External HDR authorization starts on first use (11-05).
It was the only authorization at device creation that runs a hardware decode, so a page that never plays HDR, such as a phone whose host probes only at playback, still did decoder work at load.
Device creation now prewarms only the raw SDR, raw HDR, and Dolby Vision authorizations, none of which decodes.
The HEVC PQ and HLG keys start together, sharing one decode, when an HDR negotiation waits for them (
waitForExternalHDRAuthorizationPrewarm) or a playback that requests the external HDR route starts them (prewarmExternalHDRPresentationAuthorization); the AV1 keys still authorize when their route is first used. The first HDR negotiation pays one 1080p decode instead. - A host may turn off the refresh of stored verdicts after use (11-05).
A host that runs probes only at playback, such as on a phone used as a remote control or on iOS WebKit, which a probe can end, wants no probe outside a negotiation, and a refresh is one.
configureCustomDecodeCapabilityBackground({ refreshAfterUse: false })keeps a used stored verdict as the page’s result without a rerun, so what no fingerprint shows is then caught only by clearing the store when a start it may have admitted fails before its first frame. A refresh queued before the switch still runs, since dropping it would need a cancel path for one page’s queue and the host decides at page load, before any negotiation. - AVI carries only what the fork’s AVI demuxer and the engine’s routes play (10-15).
The rule takes H.264, HEVC, VP8, VP9, and AV1 with AAC, MP3, AC-3, FLAC, Vorbis, and the PCM formats WAVEFORMATEX maps, all checked against FFmpeg-made AVI vectors.
AVI times its chunks in decode order and keeps no presentation times, so HEVC stayed out at first, since the owned HEVC path presents packets as stamped, and on WebKit, whose sample sink orders decoded frames by their packets’ timestamps, eligibility refused H.264 that could code B-frames (10-16).
Since 10-27 the fork’s demuxer stamps each H.264 and HEVC packet with its picture’s presentation time, from the picture order count in its slice header as FFmpeg derives it, so no route depends on a decoder’s or a sink’s reordering, and both refusals are gone.
The pictures of each group from one key frame to the next take that group’s frame slots in order count order, delayed by the reorder depth the sequence parameter set declares, which gives FFmpeg’s best-effort timestamps; a group’s headers are read only when one of its packets is asked for.
The fork also identifies HEVC under the zero codec tag FFmpeg’s AVI muxer writes for it unless told one, by the parameter sets of its first chunk; FFmpeg itself reads such a stream as uncompressed video, so Jellyfin’s metadata names it
rawvideo. FLAC, Vorbis, and WAVE_FORMAT_EXTENSIBLE joined the rule once the fork read them (10-27): the fork maps the SubFormat GUID as FFmpeg’sriffdec.cdoes, a format tag under the media subtype bases or AC-3 and E-AC-3 by their own GUIDs, and builds FLAC’sfLaCdescription from the STREAMINFO in the extradata, while Vorbis’s Xiph-laced headers are already WebCodecs’s form. The fork times a FLAC chunk as one frame whatever block alignment the format declares: FFmpeg’s muxer declares the bytes of a sample frame, which FFmpeg’s own reader ignores only up to 4 bytes, so it stretches a 5.1 FLAC frame over a minute. The fork gives the channel mask throughInputAudioTrack.getChannelMask(), null where the container declares none, set from the WAVE_FORMAT_EXTENSIBLE header of AVI and WAVE files; a WAVE mask rather than a list of named channels, because the engine’s output stage and its decoder packages already speak masks. The worker lays Mediabunny-decoded channels out by that mask instead of their count, and refuses a mask the output stage does not qualify rather than play it in the wrong speakers, checked against FFmpeg’s decode of 2.1 and 3.0(back) vectors whose third channel is not the front center a count of three means. E-AC-3 stays out of the AVI rule: FFmpeg writes it as WAVE_FORMAT_EXTENSIBLE, which the fork now maps, but no vector checks it. The fork’s demuxer reads each stream’s OpenDML index as FFmpeg’sread_odml_indexdoes, theindxsuper index and theix##standard indexes it locates across theAVIXRIFF lists, and keeps a stream toidx1when that index cannot be read in full (10-27). Until then it read onlyidx1, so the worker refused an OpenDML file’s video, whichidx1covers only up to about 1 GiB, asopendml-index-unread.AVIIndexCoverage.tsstill refuses a video track whose stream header’s declared length outruns its indexed packets by more than a second, now asavi-index-incomplete, rather than stopping where the index ends: a truncated file, or an OpenDML file whose index is damaged. Since 10-27 the rule also takes MPEG-4 Part 2, H.263, and MS-MPEG4, which only thempeg4decoder package decodes. The fork names only MPEG-4 Part 2 among them, so it keeps an AVI video track of a FourCC it names no codec for, codec-less, with the FourCC as its internal codec ID, as its Matroska demuxer keeps an unknown track, and the worker routes a track whose FourCC FFmpeg’sriff.cgives one of the package’s codecs, such as DIV3, MP42, MPG4, or H263, and refuses any other codec-less track by its FourCC. AVI keeps no presentation times for MPEG-4 Part 2 B-VOPs, which the fork’s demuxer orders for H.264 and HEVC only, so the worker restamps them as it restamps VC-1 VfW tracks (see “Matroska VC-1 and WMV3 present in picture order”). - The fork’s AVI demuxer unpacks DivX packed B-frames (10-27).
DivX 5 and Xvid’s packed bitstream puts a B-VOP after its reference VOP in one chunk and holds the slot it leaves with an N-VOP, so each chunk is one displayed frame.
The demuxer follows FFmpeg’s
mpeg4_unpack_bframesfilter on a stream that DivX user data ending inpmarks, as FFmpeg’s decoder unpacks only such a stream: a chunk of two VOPs gives its first, and the next chunk of one VOP gives the waiting B-VOP, so packets stay one VOP each, in decode order, with their chunks’ timestamps. Packets are read in any order, so the B-VOP a packet waits on is found by reading back to the last chunk that settles it, and kept per packet. Unlike the filter, a key frame of one VOP drops a B-VOP whose N-VOP is missing, so a seek to a key frame reads nothing before it; only a damaged stream differs from FFmpeg’s output. - ASF plays through an ASF demuxer in the fork (10-28).
Mediabunny reads no ASF, so
.wmv,.wma, and.asffiles, which Jellyfin namesasfby ffprobe’s format name, fell back to the host; the fork’ssrc/asf/follows the ASF specification and FFmpeg’sasfdec_f.crather than taking a dependency. It reads the Header Object’s File Properties (the one data packet size, the preroll, the play duration), Stream Properties (a WAVEFORMATEX, or a BITMAPINFOHEADER and its extradata), the Header Extension’s Extended Stream Properties (the average time per frame, and a stream properties object it may embed), Stream Bitrate Properties, the content descriptions as metadata, and the Codec List. Data packets are read on demand: each media object is reassembled from its payloads, single, multiple, or compressed into sub-payloads, descrambled when its audio is spread, and timestamped by its presentation time less the preroll; a file cut short ends in the object its last fragment cuts, as FFmpeg’s demuxer ends it. A key frame search starts from the Simple Index or Index Object when the file has one, and otherwise bisects the data packets by send time, as FFmpeg’sasf_read_ptsdoes, then reads back in growing windows; every lookup matched a full read of FATE’s and FFmpeg’s samples, whose packets and data matchedffprobe -show_packets. It refuses a DRM-protected file (Content Encryption, Extended Content Encryption, or Advanced Content Encryption Object, or a stream flagged as encrypted) and a live broadcast, whose data packets vary in size. ASF timestamps are in decode order, as FFmpeg reads them (asdts) and as its muxer writes them, so the worker restamps VC-1, WMV3, and MPEG-4 Part 2 B-frames from ASF as it restamps Matroska VfW tracks; H.264 and HEVC are left out of ASF, since the fork has no picture-order timing for them there. A frame lasts the Extended Stream Properties’ average time per frame, or else the time between the stream’s first two media objects, as FFmpeg’s muxer writes no such object, so the last anchor’s restamped time is a frame past the last packet. A stream opens as the AVI demuxer’s does:WVC1andWMVAas VC-1, WMV3 and the MPEG-4, MS-MPEG4, and WMA tags codec-less with the FourCC or tag as their codec ID, and the BITMAPINFOHEADER or WAVEFORMATEX in their stream format, whichVFWStreamFormat.tsandWaveFormatAudioConfiguration.tsread. With it, a codec-less AVI MPEG-4, H.263, or MS-MPEG4 track also gets itsstrfextradata as codec private data, rather than none, as FFmpeg’savidec.chands every video stream’s extradata to its decoder; FFmpeg’sM4S2encodes in ASF carry their video object layer only there. The VC-1 and WMV3 routes read FourCC codec IDs from ASF streams only, as no container rule places VC-1 in AVI. - MPEG program streams play through a program stream demuxer in the fork (10-29).
Mediabunny reads no program stream, so
.mpgand DVD-Video.vobfiles, which Jellyfin namesmpegby ffprobe’s format name, fell back to the host; the fork’ssrc/mpeg-ps/(branchv2-mpeg-ps-demuxer) reads MPEG-1 system streams and MPEG-2 program streams from their pack headers, as ISO/IEC 11172-1 and 13818-1 and FFmpeg’smpeg.cread them. It reads MPEG-1 and MPEG-2 video and MPEG audio by their stream IDs, and the AC-3, DTS, and 16-bit LPCM substreams of private stream 1 by their substream IDs; it skips subpictures, DVD-Video navigation packets, and padding. Streams become tracks in the order of their first PES packets in the file’s first 5 MB or 5 seconds, as FFmpeg’s demuxer creates them while it probes, so tracks keep FFmpeg’s, and so Jellyfin’s, order. Frames are split by the reading context of the fork’s MPEG-TS demuxer, which the two share, so each MPEG video packet is FFmpeg’smpeg1_find_frame_endframe; the packets, sizes, key flags, data, and set PTS of files from FFmpeg’smpegandvobmuxers, and of a DVD-Video VOB, matchedffprobe -show_packets. A packet takes the PTS of the PES packet it begins in, at 90 kHz, unwrapped as in MPEG-TS. DVD-Video often stamps only the I pictures, so a picture without a PTS of its own is timed by MPEG video’s display order, a B picture when it is decoded and an I or P picture after the B pictures that follow it, each shown for the fields itsrepeat_first_fieldgives, which are the times FFmpeg’s decoder presents. A search bisects the packs by their SCR, then reads forward, starting further back in doubling windows when nothing qualifies, each window read only up to where the last began, and no search reads past where a stream’s packets ran out. The fork detects a program stream ahead of MP3 and ADTS, whose scans pass an MPEG-1 system stream whose first packs carry MPEG audio, and leaves out a track whose packets it cannot read, such as AAC under an MPEG audio stream ID, rather than refuse the file. The worker treats a program stream track as it treats a transport stream track, named by its codec, with its first sequence header’s extradata as codec private data, by one predicate on the input format (isMPEGSystemStreamFormat). The container rule carries MPEG-1 and MPEG-2 with MP3, AC-3, and DTS. MPEG audio Layer II, which Jellyfin namesmp2, has no route, and neither has LPCM: Jellyfin names itpcm_dvdwhatever its sample depth, the engine has no such codec, and the fork reads only 16-bit LPCM, aspcm-s16be. - A known codec without a route is refused as such (10-15, 10-27).
Mediabunny identifies MPEG-4 Part 2, and until 10-27 no WebCodecs decoder or package decoded it, so eligibility answered
video-codec-without-routefor it, H.263, MS-MPEG4, and Theora, and the worker refused an MPEG-4 Part 2 track withThe selected video codec has no decode route: mpeg4. Since 10-27 thempeg4decoder package decodes MPEG-4 Part 2, H.263, and MS-MPEG4 v1 to v3, so they are package-only codecs and the worker’s refusal is gone; Theora is the one codec left without a route. - A track whose edit list Mediabunny cannot honor is refused (10-15).
getUnsupportedFeatures()reports an MP4 or MOV edit list Mediabunny applies only in part,multiple_editsornon_unit_playback_rate, whose packets it then times as if the rest were absent.UnsupportedTrackFeatures.tsturns the first reported feature into a refusal of the video or audio track, since playing it would put the track at the wrong times.
Video decode and Dolby Vision
These were settled on stock Chrome on Windows, with a Chromium 153 source audit.
- Hardware decode output is opaque.
WebCodecs HEVC Main 10 hardware frames are P010 surfaces with
VideoFrame.format === null, socopyTo()andallocationSize()cannot expose planes. Exact planes came only from the bundled software HEVC decoder (WASM, CPU), until GPU decoder packages replaced it (10-13); their frames stay in their frame pool. - The software raw path is too slow for 4K. The bundled HEVC decoder that preceded FFmpeg’s ran at about 15.3 fps on a 23.976 fps 4K source, and plane extraction plus upload cost about 2.4 ms per frame: low frame rate, then a freeze.
- The GPUExternalTexture path is 8 bits per channel in stock Chrome.
High-bit-depth frames go through an N32 (8 bpc) surface before page shaders see them (
third_party/blink/renderer/modules/webgpu/external_texture_helper.cc).copyExternalImageToTexture, an F16 canvas, ImageBitmap, and WebGL RGBA16F take the same path, and no flag avoids it. Native external HDR and Dolby Vision routes therefore tone-map 8-bit-quantized input. - Native-base-first Dolby Vision (08-08).
P8.1 (HDR10 base), P7 with CCID 6 (HDR10 base), and P8.4 (HLG base) prefer native
VideoFramepresentation of the compatible base layer; the RPU and FEL are discarded, and raw reconstruction is the fallback. P5 has no compatible base, so it keeps RPU processing, natively through the external P5 route. - Every Dolby Vision compatibility ID is supported (10-06). No 4-bit CCID is rejected. RPU reconstruction never reads the CCID. The CCID only declares a base layer that can be shown on its own: HDR10 (1) and Ultra HD Blu-ray (6) declare PQ, HLG (4) HLG, and SDR (2) SDR. The native base route takes the PQ and HLG bases of P7 and P8, and any declared base is the last fallback after reconstruction. Other IDs (0, reserved, none) declare nothing, so those streams need their RPU. The raw Dolby Vision route advertises DOVIInvalid for the same reason, and the MPEG-TS dual-PID dependency accepts any CCID.
- Every Dolby Vision profile gets a route (10-06, 10-08). P4, P20, P7 without its EL, P8 with an EL flag, and AV1 P10 all play, as do Dolby Vision over Rext, Main 12, or 8-bit Main, MPEG-TS descriptor version 2, and compressed display metadata. Routes are tried in this order: native HEVC P5, native AV1 P10 as P5 or P8 (10-27), the native compatible base (HEVC P7, P8), RPU reconstruction, then the declared base through the ordinary routes. A stream fails closed only when none applies. Reconstruction comes before the declared base, apart from the native-base-first case, because a base without its RPU is not the graded picture. P20 reconstructs its MV-HEVC base view, and P10 its AV1 picture, as P5 (CCID 0 or none) or as P8. P9 (AVC) has no RPU route, because the engine owns no AVC decode path, and plays its declared base.
- Profile 10 decodes through the engine’s own AV1 path (10-08).
Mediabunny’s decoded video samples hide the packet bytes that carry the RPU, so a P10 route reads packets and feeds its own WebCodecs decoder.
Each temporal unit’s Dolby Vision ITU-T T.35 metadata OBUs are removed before decode, so no browser decoder sees them.
The libdovi T.35 entry point parses them in decode order.
A temporal unit has exactly one shown frame, so its timestamp keys its RPU, as a PTS keys an HEVC RPU.
The route profile comes from the container descriptor; the profile the crate infers from the RPU header (5 for 10.0, 8 otherwise) only validates the snapshot.
P10 at Main 10 reconstructs natively from the hardware AV1 decoder’s frames where authorized, as P5 or as P8 whatever base its CCID declares, and otherwise from raw planes, I420P10 for Main at 10 bits, else its AV1 raw format variant’s; a declared PQ or HLG base still takes the native AV1 route where its key is authorized (10-27).
The external route keeps the RPU, where an HEVC P8 prefers its native compatible base without it (08-08), because it reconstructs the graded picture natively, and a base without its RPU is not that picture.
Every sequence header is neutralized to limited BT.709 on this route without a color check, because the RPU, not the header, describes the picture; Chrome already treats an untagged full-range AV1 frame, as P10.0 signals, as limited BT.709.
External P8 has its own key,
dovi-p8, checked with the P8 synthetic RPU and authorized on first use, and the route also needs the AV1 PQ external key, whose vector proves the AV1 decoder’s frames import exactly; the P5 and P8 keys check a frame Chrome builds from planes, not a decoded one. - Every AV1 track decodes through the engine’s own AV1 path (10-09). HDR10+ and the MDCV and CLL travel in metadata OBUs, which Mediabunny’s decoded samples hide as they hide the RPU, and Jellyfin’s HDR10Plus label is not a reliable sign of them, so the route does not depend on any label. Each temporal unit’s OBUs are walked once: its Dolby Vision RPU OBUs are removed before decode on every route, and parsed only on a Dolby Vision route, the one route that loads the RPU parser. Its HDR10+ T.35 messages lose their OBU trailing bits as dav1d removes them, then parse as HEVC SEI payloads do, and their OBUs stay in the unit. Mediabunny’s decoder wrapper has no AV1 workaround, and a unit’s one shown frame keeps the unit’s timestamp, so frames need no timestamp reassignment. The one feature of Mediabunny’s sample decoding the owned path drops is the merge of a Matroska alpha channel (BlockAddID 1) into each frame. The raw route’s request names no transfer, so an AV1 track without an RPU route scans its MDCV and CLL OBUs at startup when its first sequence header signals PQ, and the native external route does the same.
- Every VP9 track on the native backend decodes through the engine’s own VP9 path (10-09).
VP9 has no metadata of its own, so its HDR10+ travels beside each frame in a Matroska or WebM BlockAdditional, which Mediabunny’s sample decoding never reads, and Jellyfin’s HDR10Plus label is not a reliable sign of it, so the route does not depend on any label.
Packets decode unchanged, and each packet’s BlockAdditionals parse as the ITU-T T.35 messages of HEVC SEI do.
WebM and MP4 put one shown frame in each packet, a superframe holding any hidden frames before it, and a
show_existing_frameheader shows a frame too, so a packet’s timestamp keys its frame’s metadata. A packet of hidden frames alone, which those containers forbid, records nothing, because the decoder outputs no frame for it. The sample sink’s alpha merge is the one VP9 feature the owned path drops: Mediabunny decodes a BlockAddID 1 alpha channel with a second decoder and merges it into each frame, while the owned path decodes the color frames only, and the presenter’s canvas is opaque. - Raw AV1, H.264, and VP9 planes prefer software (10-08, 10-27, 10-28).
Chromium’s hardware AV1 and VP9 decoders return opaque 10-bit surfaces whose planes
copyTocannot expose, while its software decoders (dav1d, libvpx) return copyable I420P10. Raw-plane decode of AV1 and VP9 therefore requestsprefer-software, in the raw probes and at runtime alike, so the raw HDR, raw SDR, and P10 routes qualify on GPUs with AV1 or VP9 hardware decode. Since 10-28 raw-plane H.264 does too: Chrome 156 reports every H.264 profile beyond High unsupported withprefer-hardware, while its software decoder, FFmpeg’s, decodes them withno-preferenceorprefer-software. HEVC keepsno-preference, because Chromium has no software HEVC decoder. Chrome 156’s libvpx output pads the coded width of the 4K VP9 probe vector from 3840 to 3904, so a probe that compared the coded size read raw VP9 as unqualified and no raw VP9 route was eligible; since 10-27 the raw probes compare the visible rectangle, whichcopyTocopies and the fingerprint covers. - Dual-layer reconstruction runs in every raw format (10-08). P4 and P7 over a range extension reconstruct with the BL in its own raw format and the EL in I420P10, the only format the bundled EL decoder was qualified for. No decoder has decoded an EL since the CPU decoders left (10-13), so these routes reconstruct from the BL alone, and 8-bit Main, which only the bundled decoder read as raw planes, has no RPU route. An EL that decodes in another format or at another size than its configuration leaves the stream to its BL, as a failed EL decoder does. A frame whose RPU names an EL depth other than 10 bits presents without its EL, because the EL texture holds 10-bit codes. The FEL fallback presents the BL at the format’s own depth, which the per-frame check holds equal to the RPU’s BL depth. Each format has its own base and FEL keys; only the I420P10 Profile 7 keys are prewarmed.
- The bundled EL decoder decided whether a dual-layer route decodes its EL (10-08, 10-13).
The P4 and P7 EL was always decoded by the bundled HEVC decoder.
Without that decoder’s Main 10 qualification, dual-layer reconstruction was still selected, with
discardDolbyVisionEnhancementLayerset, so the worker created no EL decoder: MEL reconstructs exactly from the BL, and FEL presents its base, which the declared base route would also present. Before 10-08 the route started the unqualified decoder and dropped the EL only when it failed. Gating the route on the qualification instead would cost MEL its reconstruction and leave P7 with CCID 0 no route. Since the CPU decoders left (10-13) no decoder decodes an EL, so every dual-layer route reconstructs from its BL alone, and the flag went with the qualification it read. The worker still pairs a separate EL track to read its RPUs, and the presenter keeps its FEL composition for an EL decoder, which no route reaches. - 8-bit Main Dolby Vision reconstructed through the bundled decoder (10-08), until the CPU decoders left (10-13).
Native HEVC Main decodes to hardware surfaces that no probe proves copyable, so an 8-bit Main base layer reconstructed from the bundled decoder’s I420 planes, with the
I420:dovi-rpu-v1key or the I420 dual-layer keys. It had no RPU route until 10-27, when the frame-pool route began reconstructing an HEVC package’s I420 frames; a declared base still plays through the ordinary routes where no package is offered. - 10-bit SDR has a raw route (10-08, 10-27).
AV1 Main and VP9 Profile 2 have no 10-bit VideoFrame route, and HEVC Main 10 has none without native decode, so 10-bit 4:2:0 SDR presents from raw I420P10 through the raw SDR keys.
Those keys were BT.709 only until 10-27, so BT.601 and BT.2020 SDR at 10 bits negotiated, since a profile condition cannot express primaries, and then failed eligibility.
Since 10-27 every matrix and primaries pair the metadata names has an SDR key in every raw format, and the raw shader converts identity SDR outside BT.709 primaries to BT.709 (
builtin.sdr-gamut): the BT.709 curve decodes each component, the primaries’ linear matrix converts it, the result clips to [0, 1], and the same curve encodes it, as FFmpeg’scolorspacefilter does when the transfer stays. The CPU reference does the same inprocessEncodedRGB, and the frame-pool route, which shares the color track, converts too. Only the BT.709 keys are prewarmed: the other 270 authorize on first use, so a host waits for the presented stream’s own key before eligibility, and their vector doubles the chroma excursion, since the original vector moved BT.470 BG by only 2.4/255 under a 3/255 tolerance. The same route presents a declared 10-bit SDR base, such as P10.2’s. - AV1 outside Main 10 qualifies its exact raw format (10-27).
Chrome’s software decoder (dav1d,
prefer-software) decodes AV1 Main at 8 bits, High 4:4:4 at 8 and 10 bits, and Professional 4:2:2 at 8 and 10 bits, and copies each to its exact WebCodecs format; it refuses 12-bit AV1 atdecode(), so the 12-bit variants read unsupported there. Each of the eight variants has a probe of its own, as the HEVC range extensions do, with 192x192 libaom vectors of one key and one inter temporal unit and pinned libdav1d fingerprints. High and Professional streams present from their exact format before a GPU decoder package, and Dolby Vision Profile 10 at 8 bits or outside Main reconstructs from it, where before 10-27 it had no RPU route and played only a declared base. 8-bit Main SDR keeps its VideoFrame route; its I420 probe serves only Dolby Vision. - H.264 beyond 8-bit 4:2:0 qualifies its exact raw format (10-28).
On Chrome 156.0.8078.49 on Windows, WebCodecs decodes libx264 Annex B streams of every High-family profile in software and copies each to its exact format: High 10 at 4:2:0 to I420P10, High 4:2:2 to I422 and I422P10, and High 4:4:4 Predictive to I444 and I444P10, lossless included.
isConfigSupportedaccepts their codec strings, such asavc1.6E000C,avc1.7A000C, andavc1.F4000C, withno-preferenceandprefer-software, and refuses them withprefer-hardware; High at 8-bit 4:2:0 decodes to NV12 in hardware or I420 in software. Each of the five variants, 10-bit 4:2:0 and 4:2:2 and 4:4:4 at 8 and 10 bits, has a probe of its own with a 192x192 libx264 vector of an I, a P, and a B access unit and FFmpeg’s fingerprints, since Chrome’s software H.264 decoder is FFmpeg’s and FFmpeg’s decode is bit-exact. The engine’s probes passed on that Chrome with those fingerprints, which hold only in presentation order, and failed with a fingerprint changed or with decode-order expectations. The intra profiles, the removed High 4:4:4, and CAVLC 4:4:4 decode with the same decoder, so a stream takes the variant of its pixel format under any profile beyond High that codes that chroma format. The worker decodes such a track through Mediabunny’s sample path, as native H.264, rather than an owned path like HEVC’s and AV1’s, which exist to read metadata the sample path hides; the sample path already reads avcC and Annex B, orders B frames, and works around Chromium’s interlaced AVC bug, and H.264 carries no metadata the raw route reads. 10-bit PQ and HLG present through the raw HDR keys of the variant’s format, as the HEVC range extensions do. 8-bit 4:2:0 coded under a profile beyond High, such as x264’s lossless High 4:4:4 Predictive, stays unsupported: it is no variant, and the native route qualifies only the profiles up to High. 8-bit High keeps its VideoFrame route. - Single-layer profiles discard a signaled EL (10-06). P5, P8, and P20 have no EL composition, so their EL flag is ignored and in-band EL NAL units are dropped. A P4 or P7 frame without a paired EL presents MEL exactly, because a MEL carries no residual, and FEL as its base layer: the HDR10 base for P7, and the SDR base exactly for P4, with no tone mapping or dither.
- The
dolby_visioncrate is vendored and patched (10-06, 10-08). It is copied from dovi_tool rev38adec0intowasm/libdovi/vendor/dolby_vision/, and itsPATCHES.mdlists every deviation. The bridge inwasm/libdovi/src/lib.rsadds what the crate lacks: a reuse cache for compressed display metadata, Profile 4’s 2^30 YCC offset scale, and every mapping color space and chroma format. Since 10-15 it also takes the container’s profile for an RPU whose header reveals none, as FFmpeg takes the configuration record’s (dovi_parser_set_container_profile, from the worker’s RPU profile); a header that reveals its profile keeps it. And an RPU’s own header decides its layer, as FFmpeg’suse_nlqdoes: a single-layer RPU that reuses a dual-layer mapping stays single-layer, and a dual-layer one that reuses a mapping stored without NLQ is a MEL whose residual adds nothing, where both were rejected before, one by the host’s layer check and one by the bridge. Every color space and chroma format works because the composer maps the decoded components as they are and the RPU’s own matrices convert them, as for Profile 5’s IPT. The AV1 EMDF container is bounded before it sizes a buffer, and since 10-27 its payload size must lie between 6 and 512 bytes and its protection bits read 0x400, as FFmpeg requires. Upstream rejected syntax that FFmpeg’sdovi_rpudec.caccepts, so the patches follow FFmpeg:- header limits widened to FFmpeg’s (8 to 16-bit layers, coefficient precision up to 32 bits, no mapping color space or chroma check);
- a mapping method per piece rather than per component;
- unsupported syntax as a typed error;
- panics returned as errors;
- display-metadata extension blocks framed and bounded as
parse_ext_blocksdoes (10-27). A block whose coded length runs past the data or falls short of its level’s fields, an L2ms_weightbelow -1, and more than 7 static or 25 dynamic blocks in one RPU reject it, as in FFmpeg. An L1 in the CM v4.0 section is kept with its fields zeroed, as FFmpeg keeps it, so an RPU whose CM v2.9 section codes no L1 presents one of zeros. Unknown and other misplaced levels, and L8, L9, or L10 lengths without a defined layout, are skipped, where FFmpeg keeps them zeroed or partly read, because nothing reads them. Until 10-27 a short block was skipped and a misplaced L1 dropped, so that any valid L1 survived, and the block count was bounded only by the input size; the parser now rejects and keeps what FFmpeg does. - the end of an RPU read as FFmpeg reads it (10-27). The CRC32 and the 0x80 terminator follow the metadata and its alignment bits at once, so leftover bits before the CRC32 reject the RPU, alignment bits of any value are skipped, and bytes after the terminator are ignored. The CRC32 is not verified, because FFmpeg verifies it only when asked to and even then only logs a mismatch. A CM v4.0 section is read when more than 48 bits remain after the CM v2.9 section, FFmpeg’s test. Until 10-27 upstream’s reading held: the last five bytes were the CRC32 and terminator, bits between them and the metadata were kept, nonzero alignment bits and a CRC32 mismatch rejected the RPU, and so did bytes after the terminator.
- Mixed pieces and linear interpolation are packed per segment (10-08). A component may mix polynomial and MMR pieces, as FFmpeg reads them, so the snapshot (schema 2) stores the method per segment: an MMR segment has an order above 0 in its last slot. Polynomial linear interpolation, which FFmpeg rejects for lack of samples and ETSI GS CCM 001 V1.1.1 does not define, is read as annex A of US 10,701,399 B2 codes it. A piece carries the curve’s rise to its start pivot from the previous pivot’s value, the first piece its start value outright, and the last piece also the rise to its end pivot. The annex leaves a polynomial neighbor’s value undefined, so the bridge takes a polynomial piece’s value at its start pivot as the value a following linear piece rises from, and ends a linear piece before a polynomial continuously with it. Each linear piece is packed as the order-1 polynomial between its pivots’ values. A linear piece next to an MMR piece is rejected: an MMR piece maps all three components together, so it has no scalar value to rise from or end on. The revision prefix names the vendored dovi_tool commit; the schema version is what a snapshot’s readers check.
- Per frame, a snapshot’s header is read, not the whole snapshot (10-15).
The worker’s parser validates each whole snapshot once, curves and MMR data included, when it parses the RPU.
The worker boundary, the EL classification, and the presenter’s single- and dual-layer checks used to decode the whole 3,232 bytes again for every frame to read five header fields;
readDolbyVisionRPUSnapshotHeaderreads only those, still checking the length, the schema, the revision, and that the flags agree with the profile. - Each HEVC packet’s NAL units are walked once (10-15).
The owned HEVC path walked every packet three times: twice in the HDR10+ queue, for its base-layer picture check and its SEI parse, and once in the Dolby Vision split, which then rebuilt the BL into a new buffer even when it removed nothing.
The worker now walks each packet once and passes the NAL units to both, as the AV1 path passes its OBUs, and the frame-pool route’s HDR10+ queue walks its packet once.
The split returns the input itself when it removes nothing, and a view of the input’s leading bytes when it removes only trailing NAL units, as Dolby Vision streams carry their RPU and an interleaved EL after the BL.
Only a removal ahead of a BL NAL unit rebuilds the BL, with four-byte start codes.
An Annex B packet from MPEG-TS or M2TS therefore reaches the native decoder with its own three-byte start codes, and with the zero bytes Mediabunny leaves at its end when the stream puts zeros ahead of the next start code.
Chromium’s hardware HEVC decoder takes both forms (10-27): Main 10, HDR10, and Dolby Vision Profile 8.1 streams in MPEG-TS and M2TS, unchanged packets and leading-bytes views alike, decoded every frame on the native route with the timestamps FFprobe reports less its
start_time, since the worker counts a transport stream’s times from its first timestamp (see “MPEG-TS and M2TS play on a timeline that starts at their first timestamp”). A buffer the engine allocated for one decode, a rebuilt BL, an AV1 temporal unit without its RPU, or a sanitized or color-neutralized access unit, moves into itsEncodedVideoChunkthroughtransferinstead of being copied. A packet’s own bytes may share Mediabunny’s read buffer, so they and any view of them are copied. The native decoder’s own walks stay: its RASL check reads only the leading pictures after a key packet, its Chromium sanitizing only the first packet, and its color rewrite only access units whose framing and NAL unit headers show a base-layer SPS (see Native HDR neutralization below). - Mediabunny’s Dolby Vision sample entries are mapped in the engine (10-06).
Mediabunny 1.52.2 parses
dvh1,dvhe,dva1,dvav, anddav1but gives them no codec.ISOBaseMediaDolbyVisionSampleEntry.tswrites the wrapped codec into the track’s internal info, contained the wayMatroskaVFWVideoConfiguration.tsis, so Mediabunny itself stays unmodified. Mediabunny 1.61.5 still gives them no codec (10-15). The fork readsdvh1anddvheas HEVC,dva1anddvavas AVC (avc1andavc3), anddav1as AV1 (branchv2-isobmff-dolby-vision-sample-entries), soISOBaseMediaDolbyVisionSampleEntry.tsleft, and every track’s decoder configuration is the one the engine assigned (10-27). Since Mediabunny 1.66.0 an ISO BMFF HEVC codec string starts with its sample entry’shvc1orhev1, so the fork readsdvh1ashvc1anddvheashev1(11-13). - AV1 codec strings come from the bitstream (10-08).
Without an
av1Crecord, as in every Matroska track, Mediabunny 1.52.2 reads the first packet’s sequence header, but it readsdecoder_model_info_present_flagwithout timing info and the initial display delay flag once per operating point. Itscolor_configalso skips the color description and range, and takes the Professional profile below 12 bits as 4:2:0. Every field after the operating points comes out wrong: a 10-bit Profile 10 Matroska vector reads as 8-bit and monochrome.AV1DecoderConfiguration.tsparses the first packet’s sequence header as the specification defines it and replaces the cached decoder configuration’s codec string before the firstgetDecoderConfig()orcanDecode(), socanDecode()and the owned AV1 path use the stream’s own string. It is contained the same way, and leaves a track whose first packet has no sequence header untouched. Mediabunny 1.61.5 reads bit depth, monochrome, and chroma format correctly (10-15), but a header without a color description still gets the optional fields with BT.709 code points it never declared, so the replacement stays and leaves them out. That short form was itself the defect: the AV1 ISOBMFF binding takes monochrome, chroma, and range from the sequence header and, without a color description, says the color fields should be BT.709’s 01, as Mediabunny writes them, so the replacement dropped monochrome, 4:4:4 and 4:2:2 chroma, and full range, and a 4:4:4 stream’s string declared 4:2:0, which the native route’s chroma format then read (10-27). The fork also writes an explicit 2/2/2 color description as 02.02.02, where Mediabunny read it as none, and leaves the optional fields out when a reserved code point past 99 cannot fill two digits (branchv2-av1-codec-string), soAV1DecoderConfiguration.tsandAV1CodecParameterString.tsare gone and the worker takes Mediabunny’s string (10-27). Chrome supports and decodes the full strings of 4:4:4, 4:2:2, monochrome, and full-range streams on every route it supported their short forms, and 4:2:0 limited-range streams keep their strings (10-27). - Mediabunny’s dropped BlockAdditionals are recovered in the engine (10-09).
Mediabunny 1.52.2 parses every BlockMore but keeps only BlockAddID 1, the alpha channel, so VP9 HDR10+ never reaches a packet’s side data.
An upstream change would wait for a release, and a patch to the installed package is lost on every install, so
MatroskaBlockAdditions.tscontains the fix, asISOBaseMediaDolbyVisionSampleEntry.tsandAV1DecoderConfiguration.tscontain theirs. The worker’sInputwraps its Matroska and WebM formats in place, and each demuxer they create records every finished BlockMore against its block, before it reads any cluster, at the point where Mediabunny resets itscurrentBlockAdditionalfield. A packet finds its block through the track backing’s packet-to-cluster map, which Mediabunny keeps for its own navigation. Each internal is shape-checked: when one differs, demuxing proceeds untouched and reads no additions, with one console warning, andtest/demux/MatroskaBlockAdditions.test.tsfails on the committed vectors. HDR10+ is recognized by its ITU-T T.35 header, not by BlockAddID, so no BlockAdditionMapping is needed: FFmpeg writes BlockAddID 4 with a mapping in Matroska and without one in WebM, and reads it either way. A laced block keeps no additions, because Mediabunny replaces it with one new block per frame; Matroska muxers lace audio, not video. Mediabunny 1.61.5 still keeps only BlockAddID 1 (10-15). The fork’s Matroska demuxer keeps every BlockAdditional but alpha as packet side data,matroskaBlockAdditions, each frame of a laced block taking its block’s (branchv2-matroska-block-additions), so the format wrapper, its shape checks, and its warning left, andMatroskaBlockAdditions.tsonly reads the side data (10-16). - Header-stripped laced Matroska blocks are split before their content is decoded (10-09).
Older mkvmerge releases moved the leading bytes of every AC-3, DTS, and MP3 frame into the track’s ContentCompression (header stripping), and laced audio by default.
The Matroska specification scopes a block’s content encoding to its frames, excluding the lacing data, and FFmpeg reads it that way.
Mediabunny 1.52.2, and 1.61.3 and the fork’s 1.61.5 too, restores the stripped bytes ahead of a laced block’s lace header, so it misreads the frame count and no frame decodes.
CustomDecodeInputFormats.tsgives the worker a Matroska format whose demuxer splits such a block as stored and then restores each frame’s bytes, contained asMatroskaBlockAdditions.tsis, which wraps it in turn. When the internals differ, demuxing proceeds untouched with one console warning, andtest/demux/CustomDecodeInputFormats.test.tsfails. Its last case fails once Mediabunny itself reads such blocks correctly, and the replacement can then go. The fork splits a laced block as stored and restores each frame’s bytes itself (branchfix-matroska-laced-header-stripping), so the replacement format and its last case left;test/demux/CustomDecodeInputFormats.test.tsstill reads every lacing through the worker’s formats (10-16), and istest/demux/MediabunnyDemuxIntegration.test.tssince those are Mediabunny’s own (10-27). Mediabunny 1.62 splits such a block before decoding each frame’s content too, which the fork’sv2took with upstream’smain(10-27). - A custom decoder’s failure is reported once (10-09).
After a Mediabunny custom decoder rejects, Mediabunny 1.52.2 queues the decoder’s
close()behind the failed call without a handler, so the same error resurfaces as an unhandled rejection and the decoder is never closed. The worker marks every failure it catches (HandledDecodeFailures.ts) and prevents the unhandled-rejection report of a marked error only, so any other unhandled rejection is still reported. The unclosed decoder ends with its worker: the session replaces a worker whose run failed, and a worker that suppressed such a rejection asks for its replacement in its nextstopped. Mediabunny 1.61.5 still chainsclose()withvoidbehind the rejected call (10-15). The fork closes a custom decoder even after one of its calls failed, and reports a failingclose()as a decoder error (branchfix-custom-decoder-close-after-failure), soHandledDecodeFailures.tsand the worker’s replacement for an unclosed decoder left; a run’s failure still retires its worker (10-16). The same branch closes a custom encoder after a failedencode()orflush(), which the engine never runs (10-16). - The bundled HEVC decoder was FFmpeg’s (10-10), until the CPU decoders left (10-13).
hevc.js (
@hevcjs/core), the decoder before it, rejects the first slice of every stream that enables tiles (tiles_enabled_flag), and Ultra HD Blu-ray encodes commonly use tiles, as ATEME Titan’s do. Such sources failed on Firefox on Windows, which always took the bundled route, and fell back to a server transcode. hevc.js 1.4.8 fails the same way, and its roadmap says none of its test streams uses tiles. Theffmpeg-hevckit built FFmpeg’shevcdecoder for--arch=wasmwith-O3 -flto -msimd128, so FFmpeg’s simd128 HEVC code built and the rest auto-vectorized. It decoded the JCT-VC tiles conformance streamTILES_B_Cisco_1to the suite’s MD5, and a tiled 4K HDR10 Ultra HD Blu-ray remux and the 4K qualification stream to native FFmpeg’s MD5s, the remux at about 28 fps on one thread in Node. The exact probe qualified it with the same three vectors and pinned fingerprints, because conformant decoders output identical samples. It ran on one thread (--disable-pthreads), because threads need cross-origin isolation, which Jellyfin Web does not have. Its heap could grow to 4 GiB, where hevc.js kept Emscripten’s 2 GiB default. FFmpeg read each packet as the container stores it, with the HVCC record as extradata, so no Annex B conversion copied it. Each frame returned with its packet’s timestamp and duration, so no queue sorted packet timings to match frames. After a random-access point the decoder dropped the leading RASL pictures and reported them dropped, as the native path does, so the stream discarded their packets’ Dolby Vision and HDR10+ metadata instead of waiting for frames that never come. A Dolby Vision EL decoder reported every packet accepted, because its dropped leading pictures matched the BL’s, and a refused EL picture would have ended EL decoding. The HEVC decoder project’s package replaces it, on the GPU. - A bundled HEVC frame was copied once (10-09), until the CPU decoders left (10-13).
A drained frame’s planes were views of the decoder’s WASM memory, valid until its next call: bytes for Main and 16-bit words for Main 10.
The worker used to pack them into a new buffer,
VideoSamplecopied that buffer, and the raw copy orVideoSample.toVideoFrame()copied a third time. At 3840x2160 in Node 24, a 10-bit frame took about 5.9 ms and left about 50 MB of garbage, against about 0.9 ms for one row copy into a reused aligned buffer. The decoder then handed each frame to the owned path as it drained it, and the worker wrote the planes into the buffer it posted, whatever the decoder’s row stride. Drained frames waited in the owned stream’s queues beyond the 2 raw credits, so their buffers came from a run’s pool of spares, which allocates when none fits. The pool, which raw copies still use, keeps 2 spares of each byte length, as many as the page can hold, so a steady stream reuses every buffer. - Decoder instances are made once per worker (10-09).
The bundled HEVC, FFmpeg MPEG-2/VC-1, and OpenJPEG modules were instantiated for each decoder, and the libdovi parser for each run, which a worker that outlives its runs would repeat at every seek.
Each kit then had one instance per worker, in which every decoder or parser creates its own native context and releases only that context when it closes, so a Dolby Vision run’s BL and EL decoders shared one
ffmpeg-hevcheap. Since the CPU decoders left (10-13) the libdovi parser is the one kit left inWorkerWASMInstanceCache; decoder packages make their instances as Decoder packages describes. An instance whose code traps is discarded, so the next parser instantiates it again rather than trust its memory; an error its code returns keeps it. - Decoder surfaces must not starve (08-08,
ecb5a4ec09). Native frame credits return afterqueue.onSubmittedWorkDone(), not aftersubmit(), because Chromium holds the decoder mailbox until the GPU completes and the D3D surface pool is finite. - Rejected alternatives.
ffmpeg.wasmhas no GPU decode.libmpv-wasmis CPU FFmpeg plus WebGL. mpv’s D3D11VA P010 path is the reference design but cannot be reached from a page. Threaded WASM needs COOP and COEP isolation, which was not pursued. An FFmpeg HEVC WASM benchmark (the gate: at least 28.8 fps sustained, exact YUV420P10 hashes) was dropped when the native base route landed; FFmpeg’s decoder became the bundled one later, for tiled streams rather than speed, and left with the other CPU decoders (10-13). - If exact 10-bit is ever required, the Chromium patch options are:
- a P010 to RGBA16F GPU copy, the narrowest patch, which fixes the N32 TODO;
- a zero-copy P010 external texture;
- raw R16 and RG16 plane import, the only route to bit-exact P010 and exact P7 FEL.
- Native HDR neutralization.
The native external HDR route rewrites the color description to BT.709 so Chrome does not tone-map, and the shader recovers the YUV codes.
An
hvcCwithout an SPS defers validation to the first access unit with an SPS (08-05). Every access unit with a base-layer SPS is rewritten, not only key packets (10-29). An ISO BMFF sync-sample table may list only the IDR pictures, so each CRA, which repeats the VPS, SPS, and PPS, is a delta packet; Chrome then read the unrewritten PQ SPS of the first such CRA and decoded PQ frames, which the presenter’s color check refused. A check of the framing and NAL unit headers alone, without parsing a payload, finds the SPS, so every packet affords it; each SPS found must still prove the route’s transfer, so a mismatching SPS mid-stream fails the decode. - Full-range PQ and HLG HEVC is presented as full range on the native HDR route (10-29).
Until then the SPS check required limited range and refused an SPS with
video_full_range_flagset only once decoding had started, so such a stream failed asdecode-failedand fell back to a transcode. FFmpeg, mpv, and Chrome honor the flag, so the engine does too, even where the pixels look limited: Jellyfin stores no color range, so the bitstream’s flag is the only declaration there is. The HEVC rewrite now keeps the range flag and rewrites only the primaries, transfer, and matrix, and the decoder’s neutral colorSpace takes thehvcCSPS’s range, or the configuration’s own when the record has no SPS. Chrome then converts a full-range frame at full range and reports it as full-range BT.709, which the presenter’s frame check accepts on the external HDR route; the Dolby Vision external route still requires limited. The shader’s recovery, written for limited BT.709, then yields each full-range code’s limited-range equivalent,Y*876/1023+64for luma and(C-512)*896/1023+512for chroma, which the route’s limited-range metadata normalizes to exactly the full-range value, so neither the presentation metadata nor the authorization key changes. The authorization vector is limited range; the full-range conversion is Chrome’s, unverified by a key. - An HEVC SPS without
default_display_window_flagis repaired before Chromium reads it (10-29). Some old x265 builds wrote the VUI without the flag, so Chromium’s SPS parser reads the timing info that follows as a display window, fails its crop check, and the decoder reports a decoding error on the first packet. FFmpeg detects the pattern (hevc/ps.c): at the flag, at least 68 bits before the stop bit, and the next 21 bits a setvui_timing_info_present_flagthen 20 zero bits, which a set flag would read as an implausible left offset; it then reads on as if the flag were 0.repairHEVCSPSDefaultDisplayWindowapplies the same test and inserts the flag as 0, so every later field moves one bit, then writes a new stop bit and alignment and escapes the RBSP again; any other SPS is left as it is. The owned native HEVC decoder applies it on every route to each base-layer SPS of thehvcCand of every access unit that carries one, before the color rewrite on the native HDR route. The repaired SPS decodes to frames identical to FFmpeg’s decode of the original. - AV1 color is neutralized like HEVC’s (10-27).
Until then nothing rewrote an AV1 sequence header, so HDR AV1 decoded in software to raw I420P10, which cost CPU at 4K.
The worker now rewrites every sequence header OBU’s color description and range bit-exactly, every other field kept as it was, with the OBU size recoded only when the payload length changes, and also the codecs string’s color fields, an
av1Cdescription, and the decoder’s colorSpace. Chromium’s AV1 decoders take a frame’s color from the sequence header and fall back to the configuration, so both are rewritten; on the hardware decoder, the neutralized frame then reports limited BT.709 and stays opaque. Each rewritten header must itself declare limited BT.2020 with the non-constant-luminance matrix and the route’s transfer, and only the HLG code point proves HLG, because AV1 has no alternative transfer characteristics message. The AV1 keys have their own lossless vector and authorize on first use rather than at device creation, because only AV1 HDR items need them and the HEVC keys alone shape a host’s transcode ranges. The HEVC keys then started with the device; since 11-05 they start on first use too (see “External HDR authorization starts on first use”). - The external HDR authorization takes a frame’s coded size from its visible size to the vector’s (11-11).
VideoToolbox reports the 1080-row HEVC vector’s coded height as 1080, not the padded 1088, so on macOS every HEVC frame failed as
decoder-output-mismatch. The native SDR output probe already accepts that range. - Neutralized frames carry the sRGB transfer, not BT.709’s (11-11).
Chrome on macOS converts an external texture tagged with the BT.709 transfer to the sRGB curve on import, so on an M3 the authorization readback was up to 34.4/255 off and no native HEVC HDR key was authorized; Chrome on Windows passes the codes through for either tag.
With transfer 13,
iec61966-2-1, the readback is within 2.1/255; primaries, matrix, and range are unchanged, so the route keys keep their names. Chrome on macOS converts by the color the stream declares, not theVideoFrame’s, so the external Dolby Vision route rewrites each base-layer SPS in place: any color description to the neutral one, and a full range flag to limited, with or without a color description. The RPU describes the picture, and a Profile 5 SPS often declares unspecified color at full range. - Startup is bounded by progress, not by a fixed timer (10-07).
A 72 Mbps 4K TrueHD source under six concurrent players produced no audio within a fixed 20 s bound and fell back to a server transcode that also stalled; the stage that stalled was not recorded.
The controller fails after 20 s without progress, or at 60 s regardless, and the fallback message names the counters it reached.
Progress counted only decode counters until 10-29, so a slow but moving metadata read looked stalled: Matroska files that carry 100 to 146 MB of font attachments ahead of their first cluster took over 18 s to give their tracks at 8 MB/s, and failed at the quiet bound.
The worker now also reports the source bytes it reads before its readiness, each 256 KiB (
source-progress), from its source’s read events, and the controller counts them as progress; the 60 s ceiling still bounds a read that never ends. The host’s 25 s setup bound ends where the controller starts, so the two bounds cannot disagree. - An unknown duration does not block custom playback (10-07).
Requiring
RunTimeTicksfor eligibility sent a source the server never probed through the HTML backend, although the controller and session accept a null duration. The decoded streams define the end; only the native media audio backend needs a duration, so such a source takes decoded PCM. The worker reports the duration from the container’s metadata (the presented video track only, so a late audio track is never scanned) and the controller adopts it. Until a duration is known the player is not seekable, because Jellyfin Web seeks by percent of duration and would land at zero. - Native decode hints match their probes (10-07).
The SDR probes qualify with
no-preference. Requestingprefer-hardwarefor every nativeVideoFrameroute at runtime made VP8 sources fail after their probe passed, because Chromium on Windows has no hardware VP8 decoder and rejects that configuration. Only the routes that present opaque hardware output (native external HDR, the native Dolby Vision base, and external P5) prefer hardware, as their probes and authorizations do. - Unspecified color is absent, not unknown (10-07).
FFmpeg names an unspecified or reserved color field
unknownorreserved. Treating those names as color values nulled the metadata and declined the item asmetadata-unsupported. The engine treats such a field as absent, so the transfer’s defaults apply: an SDR stream with ColorRangeunknownis limited BT.709, and a Dolby Vision base with ColorTransferunknownpresents the transfer its CCID declares.test/presentation/PresentationInput.test.tsasserts both. Unspecified SD color defaults to BT.709; no resolution-based default is applied. - BT.601 and BT.2020 SDR play natively (10-07).
In field tests the engine declined H.264 and HEVC SDR tagged SMPTE 170M, and HEVC SDR tagged with the BT.2020 10-bit transfer, without logging anything.
SMPTE 170M and SMPTE 240M primaries read as
smpte170m, BT.470 BG asbt470bg, both matrices as BT.601, and the BT.2020 10 and 12-bit transfers, which use the BT.709 curve, as SDR. The VF-SDR route does no color math: it imports the frame withcolorSpace: 'srgb'through the identity shader, so Chrome converts it. The CPU reference and the shaders are exact for all four primaries sets regardless: luminance comes from the primaries, YUV coefficients from the matrix (BT.601 is Kr 0.299, Kb 0.114), and gamut and IPT conversions go through BT.709 tables derived from the H.273 chromaticities under D65. Since 10-27 each matrix and primaries pair has its own raw SDR key, and the raw shaders convert the gamut of SDR outside BT.709 primaries (see Video decode and Dolby Vision). - The SPS VUI never rejects color (10-07).
An HLG broadcast whose VUI signals the BT.2020 10-bit transfer (14) and whose alternative transfer characteristics SEI (payload type 147) names HLG (18) failed the native HLG route, because the SPS parser accepted only BT.709 and BT.2020 PQ or HLG.
The parser maps every code to a WebCodecs name or to null, and only the native HDR route check is strict (limited range, BT.2020 primaries and non-constant-luminance matrix).
Its transfer is the SEI value when the key access unit carries one and the VUI value otherwise; an HLG route also accepts VUI 14 or 15 without an SEI.
SEI errors are not fatal, as in FFmpeg, so a malformed SEI counts as absent and the VUI alone must prove the route.
The bundled HEVC decoder compared the container and SPS color only where both specify it (BT.470 BG equals SMPTE 170M as a matrix, and SMPTE 170M equals BT.709 as a transfer) and filled SPS gaps from the container, so a container’s HLG survived a VUI 14 SPS, until the CPU decoders left (10-13).
Chrome is assumed to report a null
VideoFrame.colorSpace.transferfor VUI 14 and 15; the raw HLG frame check also acceptsbt709on a BT.2020 frame in case it does not, and a null frame color member never contradicts the metadata. - HDR10+ metadata carries forward in decode order, as in FFmpeg (10-09).
FFmpeg keeps the last HDR10+ metadata until new metadata replaces it or the decoder flushes, also when a payload fails to parse, and hdr10plus_tool’s extract fills its gaps the same way.
x265’s
--dhdr10-optrelies on it: it writes the SEI only on IDR pictures and where the metadata differs from the picture encoded before, so 18 of the 30 access units of hdr10plus_tool’s sample carry none.HDR10PlusFrameMetadataQueue.enqueuegives an absent or malformed frame the last metadata of its decode run, and a frame’s status still describes its own payload. Conflicting or unsupported metadata ends the carry until the next valid payload, and each decode attempt and seek starts a new queue. Carrying in display order would be wrong: the B pictures that open a scene are shown before the scene’s first decoded picture, so they would take the previous scene’s metadata. - HDR10+ profile A plays (10-09).
Profile A has scene statistics without a Bezier curve and a targeted display of 0, and is the common shape.
Its frames tone-map from the scene peak and average alone, a mode that never reads the targeted display.
A curve whose targeted display is 0 is neither profile: hdr10plus_tool’s validation rejects it, FFmpeg exports it unchecked, and libplacebo clamps the target into the input range before following the curve.
With no target to adapt the curve from, such a frame is
unsupportedand tone-maps statically. - Jellyfin’s HDR10+ flag matters only on a PQ transfer (10-29).
Until then a stream whose HDR10+ flag was set while its transfer was not PQ got no presentation metadata, so an SDR stream with the flag was declined as
metadata-unsupported, and the HTML path did not count it as SDR either. HDR10+ dynamic metadata adjusts the tone mapping of a PQ signal, and the presenter applies it only on a PQ route, so on an SDR or HLG stream it would change nothing: such a stream now plays on its own transfer’s route, and the metadata its packets carry is parsed and ignored. The parse never fails a frame: a malformed payload only marks the frame’s status. - The spline’s output floor is the spline’s own (11-12). libplacebo’s spline maps black to a 1000:1 output floor, which its black point compensation takes off again. Until 11-12 the SDR output encode took the floor off whenever the operator setting was Spline, also from a frame whose HDR10+ curve had replaced the spline and mapped black to 0, so everything the curve mapped below a thousandth of the output peak turned black; the HDR output encode never took it off, so Spline’s black sat at a thousandth of the output peak, 1 nit at a 1000-nit peak. The spline now takes off its own floor, so every tone mapper hands either output encode light from black, and neither encode takes anything off.
- An HDR10+ curve applies only under the ST 2094-40 operator (11-12).
Until 11-12 a frame whose HDR10+ carried a Bezier curve followed it whatever operator the user had chosen, while libplacebo follows a curve only with its
st2094-40function and otherwise tone-maps with its default spline from the frame’s scene statistics. Adapted from a 400-nit targeted display to SDR’s 100 nits, a curve can crush a dark scene’s shadows and lift its midtones, the likely cause of a report on jellyfin-plugin-webgpu-player (issue 13) in which the same stream’s RPU route looked brighter. ST 2094-40 is now an operator of its own: it follows a frame’s curve, and without one builds libplacebo’s brightness-matching curve through the scene’s knee; every other operator takes a frame with a curve as it takes a profile A frame, from its scene peak and average. The curve’s Bernstein sum evaluated pow(0, 0) at both of its ends, which is NaN in Chrome on Windows, so until 11-12 every pixel at or above the scene peak presented black; a power of 0 is now 1 outright. - Dolby Vision RPU routes tone-map from their RPU’s luminance metadata (11-12).
Until 11-12 every RPU route tone-mapped from a fixed 4000-nit input peak and ignored both the RPU’s L1 and any HDR10+, while the native compatible base of a stream with HDR10+ tone-mapped from it, so such a title looked flat on one route and dynamic on the other.
libplacebo takes a frame’s L1 maximum and average as its scene statistics and the RPU’s mastering display maximum,
source_max_pq, as its static peak, with HDR10+ ranked between them; the presenter now reads both from each frame’s snapshot and ranks them the same way. An RPU without display management metadata takes FFmpeg’s default color metadata, whose 4000 nits the routes used before.
GPU decode
- GPU decoders replace the bundled CPU video decoders (10-12, 10-13). Route selection tries native WebCodecs first, then a GPU decoder package the host offers. On 10-12 a GPU decoder replaced a bundled decoder’s route whenever it took the stream; on 10-13 the CPU video decoders (FFmpeg’s HEVC, MPEG-2, and VC-1, and OpenJPEG) were dropped with no compatibility shim, so a codec without a native route plays only through a package.
- Decoders are packages (10-13).
A decoder is one
.wasmfile a host hands the engine at runtime: its module, and custom sections that carry its manifest and, for video, its WGSL and its self-test vectors. The engine has no decoder of its own and names none, so anyone can add a codec without changing it, and a package carries no JavaScript, so a host may load packages from anyone: the module imports only its bounded memory (1 GiB at most) and inert WASI calls. The decoder projects inwgpud/build packages without running on their own, and the engine’s tests load them through the engine’s loader, as a host does. Demuxing, the container rules, and the native routes’ vectors stay in the engine, because a package decodes what the engine’s demuxer hands it. - A package’s identity is the id its header declares (10-15).
Until 10-15 a package’s id was its file’s SHA-256, so a rebuilt package was another package: a host’s store kept both, and every engine cache keyed by it needed a count limit to bound what old versions left behind.
Now every header declares
id:, 1 to 64 lowercase letters, digits, and single hyphens, which its versions share, and the SHA-256 is the version,contentHash. A host’s store keeps one file per id, so adding a rebuilt package replaces the old one in its place, and the engine keys what it keeps for a package by id with the content hash inside the entry, so a new version replaces the old one’s entry and no cache needs a limit. The store’s schema moved to version 2 without a migration: an earlier database opens empty. - A video package’s CPU stage is its parser (10-14).
It was the front end until 10-14, when it took the name NVIDIA’s NVDEC gives the same stage: CPU code that parses each packet’s bitstream into the record the WGSL reconstructs from.
Its header directive is
//! parser:, its exports areparser_*, and a decoder project keeps it inparser/. The engine’s worker that plans and finishes pictures on the coordinator instance is the coordinator worker, beside the parse workers. The rename kept the interface at version 1; a package built before it fails to load aspackage-manifest, because itsfront-end:directive is unknown. - Parser interface 2 yields a packet’s pictures one at a time (10-14).
Version 1 made at most one picture of a packet, but an AV1 temporal unit holds hidden frames beside the one it shows, and each decodes into a reference of its own.
So
parser_plan_nextandparser_decode_nexttake the packet’s next picture until a call reports the last or none, and each picture is a session packet of its own: its record, events, output slot, and credit, with its packet’s timestamp. A blocked plan_next keeps its place, because a packet’s later frames depend on its earlier ones, while a blocked plan still keeps nothing and plans the packet again. A picture reports its visible size and its repeat counts, and configure reports the largest picture the codec private data declares, so AV1’s frame size changes fit one pool and its wavefront runs only the waves a picture needs. The engine dropped version 1 outright: a version 1 package fails to load aspackage-manifest(parser-version: 1, expected 2), and the HEVC and JPEG 2000 parsers moved to version 2, where every packet is one picture. - A session codes at the largest picture its parser declares, when the decoder fits it (10-14).
AV1 frames may change size within a sequence, up to its sequence header’s maximum, and a session sizes its pool once, so it codes at that maximum, rounded up to 64.
A declaration can far exceed the frames: one corpus stream declares 16384x8704 for 640x360 frames.
When the decoder’s
acceptsor the device does not take the maximum, the session keeps the container’s size, and eligibility checks only the container’s size, so a declaration never makes a stream ineligible. Until 10-15 a frame that then outgrew the pool failed the session asvisible-rectangle; since then it starts a session whose pool is the old one grown to it, as the next entry describes. - A picture its session cannot hold starts a session of its own (10-14), sized for its own sequence (10-15).
A session sizes its pool and compiles its pipelines for one output format once, but a later AV1 sequence header may raise the maximum frame size, as libaom’s sizeup and sizedown vectors and Argon’s joined sequences do, or change the bit depth or chroma subsampling, as Argon’s profile switches do.
So a parser reports each picture’s output format and its sequence’s largest picture, and
GPUVideoDecodeFeedcreates a session for a picture its current one cannot hold, which takes over once the current one decoded and presented its own pictures. Such a change comes only where the decoder needs no earlier picture, as at a key frame after a new sequence header, so the new session starts with a pool of its own while the old one’s last frames still present. The frame pool signal reads each frame’s sample size, bit depth, and chroma subsampling from its slot instead of compiling them in, so one presentation chain shows every session’s frames. It picks the range arithmetic by the slot’s bit depth among branches compiled for each depth, since computing it from the depth at run time differed from a compiled signal by an ULP on some codes on Dawn. Until 10-15 the new session’s pool also held the container’s size, and a picture that needed exactly a maximum the decoder or device had refused started no session and failed asvisible-rectangle. So a later sequence large on one axis, beside a container large on the other, asked for a box no level allows, and a refused declared maximum ended the stream at its first picture beyond the pool. Since 10-15 the new session takes the picture’s requirement alone, the larger of its sequence’s maximum and its own size, since it decodes only that sequence’s pictures, and falls back to the old pool grown to the picture when the decoder or the device refuses that. A picture its pool holds asks for no session the decoder refuses, so a stream whose sequences declare 16384x8704 for 640x360 frames decodes on in its first pool. Only a picture that no pool the decoder takes can hold fails, asacceptswith the fallback’s picture size and pool. The pictures queued while a session is created wait for it and are judged against the pool it got, since a device with small buffers can refuse the pool its request asks for, and a picture judged against that one would land beyond the smaller pool. Through the feed, the old sizing ended 32 of Argon’s 2,524 AV1 streams withinacceptsand one own encode, and these rules withacceptson the picture size end none; simulated alone, the sequence-alone sizing left 16 and the unroundedaccepts5. - The frame pool signal clamps its taps to the frame’s visible picture (10-27). A pool slot holds the coded size rounded up to 64, and its rows below or right of the picture are ones no decoder wrote or, as for 1080p HEVC’s conformance window, ones it crops. Clamping the bilinear taps to the slot’s planes made a picture’s last row read them: a 640x360 HEVC stream’s last row differed from the raw route by up to 245 of 255 on Dawn, through its 4:2:0 chroma, which interpolated chroma row 180 of 192. So every tap, luma and chroma, progressive or within a field, and the frame-pool Dolby Vision read with them, clamps to the visible rectangle, widened to whole samples of a subsampled plane, as the raw route’s texture of the picture and FFmpeg’s scaler after cropping repeat the edge. A slot’s frames differ in visible rectangle, as a decoder’s header replaces the container’s or an AV1 picture reports its own size, so the executor writes each presented frame’s rectangle into its slot’s layout uniform instead of the pool writing it once.
- A decoder’s
acceptsjudges pictures before the pool’s rounding (10-15).stream.widthandstream.heightare the pool’s size, rounded up to 64, which a decoder’s buffers and dispatches need, but a codec’s levels bound the pictures themselves. AV1’s level 5.x allows 8,912,896 samples a picture, and a legal 7396x1205 picture of 8,912,180 samples rounds to a 7424x1216 pool of 9,027,584, whichacceptson the pool refused. Raising the bound to the largest rounded pool of a legal picture, 8192x1152, would have offered the package level 6 sizes such as 4096x2304, soacceptsgained facts of its own instead.picture.widthandpicture.heightare the size before the rounding: the container’s visible size at eligibility, and the size a session’s pool is chosen for; size, state, and dispatch expressions cannot read them, so no fact means two things. The device’s limits still judge the rounded pool. Since 10-27 AV1’sacceptsbounds the pictures by level 6.x, 16384x8704 and 35,651,584 samples, on the same facts. - A device kept the four compiled decoders its sessions used last (10-14), until it kept one version of each package (10-15).
A decoder’s module and pipelines depend only on its package and output format, while a new session comes with each file, each seek that cannot reuse the session, and each stream change.
Until 10-14 every session also stayed reachable from its device’s lost promise after it was destroyed, which kept all its pipelines alive, so Dawn’s own deduplication of identical pipelines made a second session cheap by accident and nothing bounded what stayed.
On 10-14 the device’s lost promise came to hold only its live sessions, and
GPUDecoderPipelineCache.tskept compiled decoders by package id, then the file’s SHA-256, and format, the four used last, which covered a playback and the formats its stream changes reach. On 10-15 packages gained a stable id, and the cache keys each decoder by device, package id, and output format, with the content hash as its version: a package’s new version replaces every decoder of its old one, so what a device keeps is bounded by the packages and formats it decoded, and the count limit and its least recently used order left. On Dawn’s D3D12 backend in Node on the RTX 5080, a 1080p session took 0.44 s to create again without the cache for HEVC, 1.1 s for MPEG-2, 8.7 s for VC-1, and 0.42 s for JPEG 2000, and 4 to 12 ms with it. - Each entry point compiles a module of only the declarations it reaches (10-15).
A pipeline processes its whole module, so every entry point of a large decoder paid for all of it: AV1’s small entry points spent 60 to 130 ms each on its 560 KB module.
GPUDecoderEntryPointModules.tsreads the module scope with a tokenizer of its own, since comments nest and may hold braces, and keeps for each entry point what its names reach, blanking the rest while keeping every line break and the last line’s width, so compile messages map as before. Correctness never rests on the split: code it does not understand compiles whole, and an entry point’s module that reports an error makes the whole module compile, whose errors are reported or which serves every pipeline when it has none. On Dawn’s D3D12 backend in Node on the RTX 5080, with every compile salted so the driver’s cache missed and a fresh device per run, median of 5 at I420, session start fell from 3.21 to 2.62 s for AV1, 14.8 to 13.2 s for VC-1, 2.29 to 2.24 s for MPEG-2, 0.81 to 0.78 s for HEVC, and 0.62 to 0.58 s for JPEG 2000. Every split module of every package compiled without an error in every output format, 445 modules, and every package’s vectors decode bit-exact through it. The VC-1 times predate its parser (10-15), which took the parse stage out of its module. - A header’s
packet:may declare up to 512 MiB (10-14), and size it by the stream (10-27). The 64 MiB bound kept a ring of two packets within WebGPU’s default storage binding of 128 MiB on every device. A record such as a lossless 2160p 4:4:4 HEVC picture’s needs more, and the presenter requests the adapter’s largest limits, so a header may declare up to 512 MiB; offers skip a package whose ring the device’s buffer limit does not hold, and a session fails aspacket-size. AV1’s worst record grows with its picture, to past 512 MiB at 8K 4:4:4 12-bit, and one fixed bound would give a 1080p stream a ring of 1 GiB and keep the package from every device whose binding holds less, so since 10-27 the size may be an expression of the stream facts, as a buffer’s is, evaluated for each session. AV1’s is its record’s worst case for the pool, at least the 64 MiB it declared before, and its parser checks each record against the same bound for its frame’s size, which never passes the session’s. - Self-test vectors run only on an explicit call (10-13).
A package carries at most one vector per output format, in a format it writes, and
verifyGPUDecoderPackageruns one only when a host calls it. Nothing caches or persists a result, because a verdict belongs to a package and a device, which the host keys itself. Offers verify nothing, so playback never waits on a vector; the policy of 10-12, which offered a decoder only once its vector passed, left with the built-in decoders. - The frame-pool route kept one packet of decode work in flight (10-12), until two (10-16).
Decode shares the presenter’s queue, so
GPUVideoDecodeFeedsubmitted a packet only after the previous submission completed and its status readbacks applied, and a presented frame waited behind at most one packet’s decode work. The GPU then idled while the page learned of each completion, a few milliseconds a packet in Chrome and up to 100 ms in Firefox (see Firefox), so the feed keeps two packets in flight and submits the next as a status readback applies. The second packet keeps the GPU decoding across that wait, and a presentation submit queues behind at most two packets’ work; the session’s three readbacks would allow three. A presented slot returns to the decoder right after its presentation submit, because the queue orders later decode work behind it. - A packet’s decode work can submit in slices, two in flight, a switch the host sets and leaves off by default (10-16).
The device has one queue without priorities, so a presentation submit waited behind up to two whole packets, a large picture’s wavefront of hundreds of dispatches among them.
GPUVideoDecodeSessionlays a packet’s dispatches out at its start and submits them in slices of at most 96, of about equal length, each tracked to its completion; the last copies the packet’s status, whose epoch is the packet’s start, so a resync while it submits makes its status stale. The feed keeps two slices in flight, which bounds the decode work a presentation submit waits behind to two slices, and returns a packet’s credit once its work begins. A reset or a resync submits no more of the packet in progress: its readback is free again, and its frame drops, or repeats the last good frame in a resync; slices already submitted still run, since WebGPU cannot recall queued work. Slices stay large because each completion costs the page a few milliseconds to learn of, more in Firefox, and the GPU would idle between slices that run shorter. Slicing trades decode throughput for presentation latency: the GPU idles between slices that run shorter than the page takes to learn of a completion, as on a fast GPU in Firefox, and two slices of queued work ride out a shorter main-thread stall than two packets did. Unmeasured in the field, it is off unless the host turns it on throughcreateGPUVideoDecodeSessionFactory’sslicing; off, every packet submits whole with two in flight, as before slicing, apart from pacing on the GPU’s completion rather than on the status readback. - Every packet’s status reads back, and its frame presents only once it has (10-15). Until 10-15 a session copied the status out only when a readback buffer was free, so one readback could cover several packets, and frames presented without waiting for theirs. A recoverable error belongs to one packet’s frame, though, as do the field flags a decoder reports for the deinterlacer, so concealment and the deinterlacer designed the status together. It keeps its 48 bytes: the fatal error word and the header’s stream facts last until a reset, and words 9 to 11, the freed slots, the picture’s most severe recoverable word, and its fields, hold one packet’s reports and clear after each packet’s copy. The session submits a packet only when a readback buffer is free for it, so every packet’s block is captured, and a frame presents only once its packet’s readback applied, so a recovery always reaches its frame first. Decoding lost no throughput, since the feed already waited for each packet’s readbacks before the next submit. One block per packet serves both, and a later per-picture fact appends to it. Rejected: a fields word per output slot, the deinterlacer’s first design at 176 bytes, which misattributes once a slot is reused before its capture, as AV1’s discarded hidden frames are; status-wide words, which hold the union of every packet since the previous capture; a ring of per-packet records, machinery for batching that playback never does; and the pass reading the decoder’s status buffer, since the presenter must know on the CPU which frames are interlaced to time their second fields.
- A damaged packet ends no playback (10-15).
Until 10-15 every error a GPU decoder reported ended the attempt, so one damaged packet ended a playback that FFmpeg would have concealed and continued.
Each
error:line now declares how a playback recovers from its code:conceal, where the decoder concealed the damage and its frame presents;drop, where the frame never presents and decoding goes on; orresync, where the last good frame repeats until the next key packet decodes from a reset. Only the decoder knows what its state survives, so the recovery is its to declare: MPEG-2’s damage within a picture conceals as FFmpeg’s error resilience does, the bitstream and slice damage of HEVC, AV1, JPEG 2000, and VC-1 resyncs, and the uncompressed decoder drops a packet of the wrong size. A code without a recovery ends the attempt as before: a change the pool cannot follow, an unsupported feature, or a resource limit, which a stream could keep hitting, so a resync would hold the last good frame for the rest of it. A resync repeats the last good frame rather than present frames decoded from broken references, and the session repeats it at each lost frame’s time, so presentation’s queue keeps its timing and the controller’s starvation rules never see a gap. A parser’s failed call made no picture and leaves its instance unusable, so a failure whose code has any recovery restarts the parser at the next key packet, with no new export; a parser that can conceal outputs its record with the damage marked and lets its WGSL report aconcealcode. Recoveries count in the feed’s telemetry by code and error name, with the latest detail, and never as failures; a self-test vector that recovers fails, since a vector is undamaged. - The pool takes the container’s coded size rounded up to 64 (10-12). Containers declare the cropped picture, such as 1080 rows, while a decoder writes whole coding blocks, 1088 rows; the first header’s conformance window then replaces the visible rectangle.
- A decoder’s vector, not a presentation authorization, vouches for its frames (10-12, 10-13).
The frame-pool route presents static HDR whenever the host tone-maps it (
allowFramePoolHDR). Its Dolby Vision reconstruction (10-27) is gated by the RPU route’s own flags (allowDolbyVision,allowDolbyVisionProfile4,allowDolbyVisionProfile7) instead, which a host sets only while it tone-maps and once the raw route’s key of the BL format authorized. A paused draft (10-15) setallowFramePoolHDRfor every Dolby Vision stream, which would have let a host’s static HDR consent stand for an RPU authorization and widened the gate for streams without an RPU route;allowFramePoolHDRstays tied to static HDR alone. On 10-12 Auto offered a decoder only once its vector passed on the device, and the WGSL HEVC decoder declared no vector, so only a forced policy played it. Since 10-13 the host decides whether to verify a package before it offers it, and the HEVC decoder project’s package carries the nine range extension vectors. - The frame-pool route reconstructs Dolby Vision through the raw route’s key (10-27). Its signal is the raw Dolby Vision signal’s reconstruction text over the frame pool signal’s read, so the raw route’s base key of the BL format, whose authorization renders that reconstruction, authorizes it, and the decoder package’s vector vouches for the read as for every frame-pool frame. The route comes after the native and raw RPU routes and after a declared base’s native and raw routes, and before that base’s own GPU route, so native decode still wins and a package presents the RPU rather than the base. A frame pool holds no EL, as no decoder decodes one, so Profiles 4 and 7 reconstruct from the BL alone as on the raw route; the paused draft’s EL frame-pool binding was left out as code no route reaches. Each frame carries its packet’s parsed RPUs from the worker through the GPU decode session, and a repeat of the last good frame during a resync carries that frame’s RPUs, since a missing RPU ends the route. HEVC Main at 8 bits and AV1 Profile 10 reach it in the format the RPU route names, I420 and I420P10.
- The WGSL HEVC decoder parses on the CPU (10-13).
CABAC decodes each bin from the state the previous bin left, so a GPU parses a substream on one invocation, at about 200 ns a bin.
On a 66 Mbit/s 2160p stream with 16 tiles, parsing on the GPU took about 100 ms a picture even with each tile on its own invocation, while everything after the syntax took 3 to 4 ms.
The HEVC decoder project’s parser (
wgpud/hevc/parser/) parses such a picture in about 40 ms on one core, and the engine’s parse workers parse a picture’s slices and tiles, and several pictures, at once. It also derives the motion vectors, which read earlier pictures’ motion in decode order, so the GPU record carries final motion, coefficients, and edges, and the GPU only reconstructs and filters. A record holds one word per coefficient, 2 to 5 MB for such a picture; a lossless 2160p 4:4:4 picture can pass the 64 MiB packet bound, which compact coefficient blocks would lift. - Known differences between the WGSL HEVC decoder and FFmpeg (10-12).
Against FATE’s conformance checksums, BUMPING_A, NoOutPrior_A, NoOutPrior_B, and RAP_B differ: the decoder outputs pictures that FFmpeg’s output process drops, and every picture both output matches; the session has no way to decode a picture without presenting it.
EXT_A_ericsson_3 is not compared, since FFmpeg cannot decode it.
Against FFmpeg’s decode of x265 streams, subsampled chroma that combines cu_transquant_bypass with SAO differs, because FFmpeg’s
restore_tqb_pixelspasses the chroma width with the luma origin and so exempts only the CTB’s top-left chroma quadrant from SAO; 4:4:4 matches. - The HEVC intra wavefront is one entry point (10-15).
Both dispatches of a wave pair run
predictIntra: the even one has a workgroup column per CTB row and the odd one a column more, which returns at once, so each knows its wave counter and the wavefront compiles once, as AV1’s does. On Dawn’s D3D12 backend in Node on the RTX 5080, over four sets of 5 to 25 alternating runs with salted cold compiles, session start fell by 4 to 7%, about 30 to 60 ms of 770 ms. Decoding stayed within 2% either way, with no consistent direction, on a 1080p 10-bit stream with B-frames, a 2160p all-intra stream, and a 1080p 4:4:4 10-bit stream; a form that read the column count’s parity instead, without the early return, decoded up to 2% slower. Every package vector, those three streams against FFmpeg, and the 4:0:0 checks decode bit-exact with it. - The WGSL VC-1 decoder mirrors FFmpeg (10-13).
It matches FFmpeg’s frames bit for bit on FFmpeg’s VC-1 conformance streams and its WMV3 and VC-1 samples: Simple, Main, and Advanced profiles, progressive, field, and frame pictures, B and BI pictures, intensity compensation, overlap smoothing, the loop filter, the simple IDCT of streams without RES_FASTTX, and IntraX8 pictures.
Its references come from an FFmpeg with commit
ec8411e854: before it, FFmpeg conceals a valid last macroblock of a Simple or Main P picture that costs one bit, and every frame after it differs up to the next keyframe. The package of 10-15, whose parser runs on the CPU, matches the same streams bit for bit again. FFmpeg’s trace finds no picture with RANGEREDFRM in any of them, nor in FATE’s WMV3 DRM sample, so range reduction is implemented but unverified. Until 10-15 a mid-stream change of coded size failed withpictureSize, because the session sized its pool once and the package had no parser to declare a larger picture. Since 10-15 its parser declares the Advanced profile’sMAX_CODED_WIDTHandMAX_CODED_HEIGHT, which size the pool, and reports each picture’s size, so rtvc1’s pictures of 352x288, 352x224, and 352x144 decode in one session. At such a change FFmpeg’s decoder reinitializes, which drops the anchor waiting to present and every reference, and the parser drops them too. rtvc1 also holds pictures FFmpeg conceals and invalid AC codes it skips without concealing, after which its frames depend on stale AC predictors; the parser fails those pictures as damage, which resyncs, until VC-1 conceals as FFmpeg does. - The VC-1 parse is serial, and only the parse (10-13). Entropy decoding has no parallelism inside a slice, so one invocation decodes each parse unit, a slice or a field, and units run at once. The parse keeps only what its bits depend on: motion vector prediction, since hybrid prediction reads a bit, and coded block prediction. Intra blocks leave their DC differential and AC levels by scan index, and one workgroup predicts, scans, and dequantizes them afterwards in anti-diagonal order, so each macroblock follows its left, top, and top-left neighbors. That cut a 1080p I picture from 156 ms to 34 ms. IntraX8 cannot split this way: its orientation code is read only when the reconstructed edge samples span a wide range, so one workgroup decodes its blocks in order, one invocation parsing and all of them sharing each block’s prediction, transform, and filter. On 10-15 the parse, intra prediction, and IntraX8 moved to the CPU, as “The VC-1 decoder parses on the CPU” below describes.
- A lone invocation’s costs set the parse’s layout (10-13, RTX 5080, D3D12 through Dawn). A dependent instruction costs about 4 cycles, a storage read-modify-write about 30 ns, and a workgroup one about 8 ns. A private array indexed at run time lives in local memory, where a load after a store waits about 150 ns, and an integer division costs about 35 ns. So the parse stages its macroblock in workgroup memory and stores the record once, writes coefficients as it decodes them, addresses the block grid with a power-of-two stride, and reads each code’s run, level, and sign from one lookup and one window. 1080p progressive decodes in about 23 ms per frame on average, 14 to 40 ms for P pictures; interlaced 1080 at broadcast rates in 17 to 25 ms. The parse left the GPU on 10-15, so these costs now bound only the reconstruction, overlap, and loop filter passes that stayed.
- VC-1 pipelines are many and small (10-13).
Backends inline every call and fold no constant argument, so each picture kind has its own slice pipeline, a helper on the hot path has one call site, and the field passes share pipelines that read their pass from the picture state.
Tint copies a constant array at each place an index reads it, and a D3D12 pipeline holds at most 64 KiB of constants, so large tables live in the decoder state, which
loadTablesentry points of 8192 words fill at each reset. The 31 pipelines compile in about 10 s together on Dawn, once per browser shader cache. On 10-15 the slice pipelines,predictIntraBlocks, the header stage, and theloadTablesentry points left with the parse, and 15 entry points remain, the field passes still sharing theirs. - The VC-1 decoder parses on the CPU (10-15).
Real VC-1 and WMV3 streams carry no slices, so the GPU parse ran each picture’s whole macroblock layer on one invocation, and
predictIntraBlocksran each picture’s intra prediction in one workgroup. The package’s parser (wgpud/vc1/parser/) does the serial work instead: the headers, bitplanes, and lookups, the macroblock layer with motion vector prediction, and intra DC and AC prediction, scan, and dequantization, which read each macroblock’s left, top, and top-left neighbors. The WGSL keeps reconstruction, overlap smoothing, and the loop filter as they were, andexpandRecordwrites each record’s macroblocks into the buffers they read; the module fell from 789,633 bytes and 34 entry points to 107,099 bytes and 15. IntraX8 pictures decode entirely in the parser, and their record carries the samples: X8’s orientation code is read only when the reconstructed edge samples span a wide range, so its parse needs its own reconstruction, and a GPU kernel would have kept the bit reader, X8’s code lookups, and a serial workgroup in the WGSL. A B picture’s direct prediction reads the next anchor’s motion, which that anchor’s parse derives, so a B picture’s plan waits until every earlier anchor finished, and the plan carries the motion to the parse workers. The output events follow FFmpeg’s one anchor of delay, so the header declaresreorder: 1where presentation in timestamp order needed 7. - WMV3 is a codec of its own (10-13).
The engine’s codec names are FFmpeg’s, and a host builds its direct-play codec lists from them, so as an alias of
vc1WMV3 could never be advertised underwmv3, the name a media server reports, and every WMV3 file would transcode. It mirrors FFmpeg’s one decoder with two codec IDs: thevc1project’s package accepts[vc1, wmv3], and a package that decodes only the Advanced profile is never offered WMV3, whose codec private data and packets differ. WMV3’s codec private data is STRUCT_C, which holds no frame size; FFmpeg takes the size from the container, but a WGSL decoder sees only the pool’s size, padded to 64. The worker therefore puts a 40-byte BITMAPINFOHEADER of the track’s coded size and the WMV3 FourCC before it, and the package refuses a bare STRUCT_C asconfiguration. Eligibility accepts the Simple, Main, and Complex profiles, since the package matched FFmpeg on both Complex streams it was checked with. - Matroska VC-1 and WMV3 present in picture order (10-13).
A Video for Windows track stamps each block in decode order with the time of the picture a one-picture-delay decoder outputs at it, which FFmpeg reads as decode time, while the frame pool presents frames in timestamp order.
Every B-frame VC-1 or WMV3 stream in Matroska, VC-1 Blu-ray remuxes included, presented in decode order until
VC1PresentationOrder.tsrestamped the track by FFmpeg’s rule: an I or P picture takes the next I or P picture’s time, and a B picture keeps its own. A key packet lookup steps back to the previous GOP when needed, because mkvmerge stamps the first two packets 0. FFmpeg-made Matroska VC-1 without B pictures therefore presents one frame later, as FFmpeg-based players show it. - MPEG-4 Part 2 B-VOPs in AVI and Matroska VfW present in picture order (10-27).
Both containers stamp an MPEG-4 Part 2 stream’s packets in decode order, as VfW stamps VC-1, so the worker restamps the track by the same rule: an I, P, or S-VOP takes the time of the next coded VOP that is not a B-VOP, and a B-VOP keeps its own.
An N-VOP, a VOP that is not coded, is passed over like a B-VOP, since FFmpeg outputs nothing for it and its waiting anchor outputs at the next coded anchor.
A stream is restamped as FFmpeg’s
low_delayreads it: when its VOL’svol_control_parametersclearlow_delay, or, without them, when its object type is neither Simple nor Advanced Simple or its first group (at most 300 packets from the first key packet) holds a B-VOP, at which FFmpeg clearslow_delay; any other stream outputs each picture at once and keeps its stamps. The fork unpacks DivX packed bitstreams in AVI before the restamping reads them; a packed stream in Matroska keeps its two VOPs per packet, and its B-VOPs present one frame off. - The
mpeg4decoder project mirrors FFmpeg’s decoders (10-27). One project decodes what FFmpeg’smpeg4,h263,msmpeg4v1,msmpeg4v2, andmsmpeg4v3decoders decode, because they share FFmpeg’s macroblock reconstruction, motion compensation, and IDCTs, and differ only in their headers and macroblock layers. Its parser ports FFmpeg’smpeg4videodec.c,ituh263dec.c,msmpeg4dec.c, and the slice loop ofh263dec.c, with FFmpeg’s encoder detection and the bug workarounds it sets, and dequantizes on the CPU asff_mpv_reconstruct_mbdoes. Its record carries per macroblock the motion operations FFmpeg’smpegvideo_motion.cand global motion compensation would run, with their source positions and fractions, and the IDCT blocks; the WGSL runs those operations, the simple or Xvid IDCT, and the H.263 loop filter as two passes over all horizontal and then all vertical edges, which equals FFmpeg’s order per macroblock. One picture is one parse unit, so pictures parse at once on the parse workers; a B-VOP’s plan waits until every earlier anchor finished, since direct mode reads the next anchor’s motion, which the plan carries. The parser matched a traced FFmpeg macroblock for macroblock on 48 streams: FFmpeg’s and Xvid’s encoders over every Advanced Simple tool they write (B-VOPs, quarter-pel, four motion vectors, global motion compensation, interlacing, data partitioning, MPEG quantization), H.263 and H.263+ annexes, MS-MPEG4 v2 and v3, and FFmpeg’s samples, including MS-MPEG4 v1, a size-changing H.263 stream, and a DivX packed stream. The WGSL matched FFmpeg’s own C functions on about 47 million randomized samples, and the decoded frames matched FFmpeg’s end to end on Dawn. One-point global motion compensation, reversible VLC, and H.263 B and PB pictures are implemented but unverified, since no available encoder writes them. Quarter-pel streams of libavcodec builds before 4653, which FFmpeg decodes with its old quarter-pel filter, fail asunsupportedFeature. - The
mpeg4package’s reference is FFmpeg’s bit-exact, single-threaded decode (10-27). FFmpeg’s x86 decode averages half-pel samples without rounding approximately, unless asked for bit-exact output, and with MPEG quantization its bit-exact intra dequantization adds MPEG-4’s mismatch control. Its frame-threaded decode of interlaced MPEG-4 B-VOPs reads motion its reference frame has not written yet in field direct mode, so its output depends on the thread count. The vectors therefore come fromffmpeg -threads 1 -flags +bitexact, whose output is FFmpeg’s C code. - The
mpeg4package’s codec private data is a BITMAPINFOHEADER for every codec (10-27). H.263 and MS-MPEG4 carry no frame size in codec private data, and FFmpeg’s MPEG-4 workarounds read the stream’s FourCC,XVID,DIVX, orUMP4among others, so the worker puts a 40-byte BITMAPINFOHEADER of the track’s coded size and FourCC before the extradata, as it does for WMV3. The parser picks the codec from the FourCC as FFmpeg’sriff.ctable classes it, so the FourCC of a Matroska VfW or AVI track reaches it as it was written, and a Matroska, MP4, or MOV track without one getsmp4v,s263, orMP43. - VC-1 in MP4 and MOV reads its
dvc1box through Mediabunny’s internals (10-13). Mediabunny 1.52.2 lists avc-1track and its samples but skips thedvc1box, soISOBaseMediaVC1Configuration.tsreads it from themoovbytes Mediabunny keeps (moovSlice,sampleTableByteOffset), and the worker builds its packet track with theInputVideoTrackconstructor. Each internal is checked, and a change fails the track with a named check; only the Advanced profile’sdvc1is read, as FFmpeg reads it. Mediabunny 1.61.5 still skipsdvc1(10-15). The fork reads avc-1sample entry as codecvc1, with thedvc1box’s sequence header and entry point as its description for the Advanced profile only, as FFmpeg reads it (branchv2-isobmff-vc1-dvc1), soISOBaseMediaVC1Configuration.tstakes the description from the track’s decoder configuration, without internals, and fails asdvc1-missingwithout one (10-27). The worker takes the transport stream rule only for an MPEG-TS or M2TS track, by its input format, since avc1track no longer means a transport stream; since 10-29 it takes it for an MPEG program stream track too. - AV1’s codec private data reads through Mediabunny’s internals too (10-14).
Mediabunny 1.52.2 reads an
av1Cbox only for its codec string and keeps a MatroskaV_AV1CodecPrivate without handing it on, so an AV1 package’s parser saw no sequence header before the first temporal unit and could declare no maximum frame size. On the frame-pool routeAV1CodecConfigurationRecord.tsreads the CodecPrivate from the internal track, or theav1Cbox of anav01ordav1sample entry through themoovreader thedvc1path now shares (ISOBaseMediaSampleEntryChildBox.ts), and copies the record unchecked, as FFmpeg’s demuxers do. A container without one hands nothing, since temporal units carry their sequence headers, and a change in the internals fails the track with a named check. Mediabunny 1.61.5 still keeps the CodecPrivate to itself (10-15). The fork hands the record on as the decoder configuration’s description, a Matroska or WebM CodecPrivate or an ISO base mediaav1Cbox, and reads a Matroska track’s codec string from its CodecPrivate when the first block has no sequence header (branchv2-av1-codec-configuration-record), soAV1CodecConfigurationRecord.tsis gone (10-27). Mediabunny 1.66.0 itself reads a Matroska AV1 track’s codec string from the sequence header in its CodecPrivate, and from the first block only without one, so the fork’s reading of the record’s fixed fields is the last resort (11-13). The frame-pool route posts that description as codec private data, and every other route configures the browser’s decoder without it, as before, since the WebCodecs AV1 registration uses no description (10-27). - VC-1 in MPEG-TS and M2TS is unreachable (10-13).
Mediabunny 1.52.2 drops stream type 0xEA, and its video setup and packet splitter handle only AVC and HEVC, in private methods the engine cannot wrap.
Once Mediabunny surfaces the stream, the engine needs only the codec ID, extradata from the first key packet, and
vc1in the transport stream rule. MPEG-2 in a transport stream is unreachable for the same reason. The fork surfaces all three since 10-15, so they play; see the entry on MPEG-1, MPEG-2, and VC-1 in transport streams. Since 10-28 it reads 0xEA as VC-1 with or without theVC-1registration descriptor, as FFmpeg does. - MPEG-1, MPEG-2, and VC-1 play from MPEG-TS and M2TS through the fork (10-15).
The fork reads stream types 0x01, 0x02, and 0xEA, and splits packets as FFmpeg’s
mpeg1_find_frame_endandvc1_parsedo, so each packet is FFmpeg’s, byte for byte. Until 10-28 it read 0xEA only under the elementary stream’sVC-1registration descriptor or in a Blu-ray program; it now reads 0xEA as VC-1 whatever the descriptors say, as the ISO stream type table in FFmpeg’slibavformat/mpegts.cdoes (10-28). Neither the packet splitter, the extradata, nor the track’s size depends on the descriptor, sovc1-advanced-unregistered.ts,vc1-advanced.tswith its PMT stripped of the descriptor, demuxes to the same track and packets byte for byte. A transport stream track has no codec private data, soMPEGTransportStreamVideoConfiguration.tstakes the extradata of its first packet that holds a sequence header, by FFmpeg’sextract_extradatarule: MPEG takes the sequence header and its extensions, VC-1 the sequence header and entry points. The fork sizes a VC-1 track by the last entry point before its first frame, as FFmpeg’s decoder does, since a stream may declare a larger maximum in its sequence header. Fourteen MPEG-2 and VC-1 streams, remuxed by FFmpeg into MPEG-TS and M2TS, and the engine’s own TS vectors, decode on Dawn bit-exact against the same streams in Matroska. The one difference found is a stream whose elementary source put six bytes ahead of its first start code: Mediabunny drops bytes before a packet’s first start code, as it does for H.264 and HEVC, and FFmpeg’s decoder decodes that first packet differently without them. BDAV M2TS carries neither MPEG-1 video nor MP3; FFmpeg’s M2TS mode writes them as private data, which Mediabunny rightly skips. - DTS plays from MPEG-TS and M2TS (10-16).
Mediabunny 1.61.5 reads DTS by its stream types and descriptors, a Blu-ray program’s DTS-HD types among them, and splits the same frames Matroska carries, so the TS rule takes DTS and the
dtspackage decodes it as from any container.dts-core.m2ts, the DTS test vector remuxed by FFmpeg, demuxes to the Matroska vector’s packets byte for byte. FFmpeg writes DTS in plain MPEG-TS as stream type 0x82 without the registration that names it, which Mediabunny reads as DTS but FFprobe, and so Jellyfin, as MP3, so only M2TS has a vector. - A package’s output format follows the stream’s pixel format (10-13).
It was 4:2:0 at the stream’s bit depth, so a 4:2:2 or 4:4:4 MPEG-2 stream reached the decoder as I420 and failed as
chromaFormat. The pixel format now names the chroma subsampling, an explicit BitDepth must agree with its samples, and one no frame pool holds, such as RGB, leaves the stream to its native routes. 9-bit samples take the 10-bit format, shifted up one bit as FFmpeg’s converter shifts them. 11-bit JPEG 2000 has no route: FFmpeg, and so the package, leaves its samples unshifted in 12-bit planes, which would present one bit dark. - A 4:0:0 stream decodes into 4:2:0 only on a decoder that declares monochrome (10-14).
A 4:0:0 pixel format,
graytogray12in either byte order, takes the 4:2:0 output format of its depth, whose chroma planes the decoder fills with the neutral value, which shows FFmpeg’s gray decode unchanged. Mapping it alone offered 8-bit grey JPEG 2000 to the JPEG 2000 package, whose parser fails it ascolourSpace: 2, so eligibility offers a 4:0:0 stream only to a decoder whose header declaresmonochrome: yes, and a header that says nothing takes none. A directive keeps that silence a refusal; a stream fact foracceptswould have made every existing condition take 4:0:0 unless it named the fact. The HEVC package declares it: six x265 4:0:0 streams at 8, 10, and 12 bits, with conformance cropping, B-frames, scaling lists, transform skip, and weighted prediction, decode bit-exact on Dawn against FFmpeg’s luma with neutral chroma. The JPEG 2000 package does not, since it would need a parser that keeps greyscale pictures and vectors with neutral chroma. - The prefilter keeps the package-only variants of AV1 and 4:0:0 HEVC potential (10-14).
hasPotentialCustomPlaybackVideoRouteknows no host’s packages, so it ruled out every AV1 stream outside Main and every 4:0:0 HEVC stream, which then never reached a package’s fallback. An AV1 stream outside Main 4:2:0 at 8 or 10 bits, such as High 4:4:4, Professional 4:2:2, or 12 bits, and a 4:0:0 HEVC stream now stay potential when a frame pool holds their format, and eligibility decides with the host’s offers. Since 10-27 every SDR color has a raw key, so AV1 Main at 10 bits in any SDR color stays potential, and the AV1 raw format probes join the packages in deciding the other AV1 variants; the other codecs with native routes keep their prefilter, since no decoder project decodes their other variants. Since 10-28 an H.264 stream of a raw format variant stays potential too, as a range extension does, and its probe decides it. - Motion JPEG 2000 opens through the engine’s own input format (10-13).
ISO/IEC 15444-3 puts the JP2 signature box ahead of
ftyp, and Mediabunny 1.52.2 recognizes an ISO base media file only by anftypfirst, so no.mj2file opened.CustomDecodeInputFormats.tsadds a format ahead of Mediabunny’s own that takes the signature box and anftypof brandmjp2, and hands the file to Mediabunny’s MP4 demuxer, which skips the signature box. It reads the file’s first bytes through Mediabunny’s internal reader, contained the way the Matroska fix is: when the reader differs, it warns once and opens nothing. Mediabunny 1.61.5 still requires anftypfirst (10-15). The fork’s MP4 format takes a JP2 signature box followed by anftypof brandmjp2ormj2s, and still leaves a JP2 still image unopened (branchv2-isobmff-mj2-signature), soCustomDecodeInputFormats.tsleft and the worker opens files with Mediabunny’s own formats (10-27). - The WGSL JPEG 2000 decoder follows FFmpeg’s bitexact 9/7 path (10-13).
FFmpeg’s default irreversible 9/7 transform runs in floating point, whose rounding follows the CPU’s SIMD, which WGSL’s
f32cannot reproduce. Its integer path, the one-flags bitexactselects, lifts in fixed point with defined rounding, so the decoder implements that, and the 9/7 vectors pin-flags bitexact. Playback therefore equals FFmpeg’s bitexact decode and differs from its default decode only in rounding. Tier-1 runs on the GPU and tier-2 on the CPU, as measured: a native single-threaded CPU tier-1 and wavelet took 229 ms for a 249 Mbit/s 1080p picture, where the GPU takes about 6 ms and tier-2 1 to 7 ms. RGB, grey, and palette pictures fail ascolourSpace, because the frame pool holds only YUV; they need planar RGB formats and a luma-only format. XYZ pictures failed the same way until 10-29, when the package took FFmpeg’s conversion of them (see the next entries). - JPEG 2000 plays from Matroska Video for Windows tracks (10-29).
FFmpeg’s Matroska muxer writes JPEG 2000 as a
V_MS/VFW/FOURCCtrack ofmjp2, which Mediabunny leaves codec-less with that codec ID, and whose blocks are bare codestreams, each a key packet stamped in presentation order. The route takes such a track when its BITMAPINFOHEADER names a FourCCriff.cmaps to JPEG 2000, compared upper-cased as for MPEG-4 Part 2, and passes its blocks unchanged without codec private data, as FFmpeg’s decoder reads no extradata; the Mediabunny fork needed no change. A cover image is a Matroska attachment, which Mediabunny opens as no track and Jellyfin lists as an embedded image, so it moves neither the video ordinal nor the selection. - Digital Cinema JPEG 2000 converts its X’Y’Z’ in the package, as FFmpeg’s converter does (10-29).
FFmpeg decodes the Digital Cinema profiles to
xyz12le, which no frame pool holds, and shows it, as a transcode does, through libswscale’sxyz12Torgb48: a 2.6 gamma into 16-bit linear light, a fixed-point XYZ to BT.709 matrix without white adaptation, a clip, and a 1/2.2 gamma. The package’s parser lays an XYZ picture out as the 12-bit X’, Y’, and Z’ codes that converter reads, and its output stage converts them the same way, its gamma tables computed withpowand its matrix in integers, then writes the R’G’B’ as limited-range BT.709 Y’CbCr in I444P12, which presents as an untagged SDR stream does. Presentation through ST 428-1’s primaries and transfer instead would need an XYZ matrix, primaries set, and transfer the presenter’s color types lack, and would not show what FFmpeg shows, since ST 428 scales by 52.37/48 and its white differs from D65. Emulated in single precision, the conversion matches FFmpeg’srgb48lecodes for every sample of the Matroska vector and for all but 100 of 5.3 million samples of a 2048x858 Digital Cinema 2K frame, each of those by one code; after the 12-bit Y’CbCr round trip, the presented R’G’B’ is within 2.3 of 4095 of FFmpeg’s, a seventh of an 8-bit step. An X’Y’Z’ stream needs BT.709 SDR color metadata at limited range, which an untagged one takes, since other tags would misstate the Y’CbCr, and 12-bit samples, the profiles’ depth. Unlike 4:0:0, no header directive declares it: X’Y’Z’ is JPEG 2000’s alone, and a JPEG 2000 package that predates the conversion fails the first picture ascolourSpace: 3, which ends the attempt, so the host falls back. - MPEG-1 is a codec of its own (10-13).
FFmpeg names it
mpeg1video, so as an alias ofmpeg2videoa host could not advertise it, and a package that decodes MPEG-2 alone would be offered it. Thempeg2project’s package accepts both, since its WGSL follows FFmpeg’s MPEG-1 paths too. A package carries one vector per output format, so its MPEG-1 decode has no vector of its own. - The WGSL MPEG-2 decoder follows FFmpeg’s simple IDCT (10-13).
FFmpeg’s default IDCT on x86-64 is its SSE2 simple IDCT, which equals the C one except where intermediates overflow 16 bits, which valid streams never reach.
The vectors pin
-idct simple, and the generator checks that each decodes the same with-cpuflags 0, so the known answers hold on every platform. - The WGSL MPEG-2 decoder conceals as FFmpeg’s error resilience does (10-15).
A damaged slice ends at its damage and decoding resumes at the next slice, and the macroblocks a damaged, missing, or overrun slice leaves are concealed.
composeMacroblocksgives each macroblock the status FFmpeg’sff_er_add_slicecalls leave it in slice order, which needs every slice’s end, so it runs after the slices decode in parallel, an invocation per macroblock, so a packet of many slices spreads over the device. When two slices decoded one macroblock their records raced, soredecodeSlicesruns every slice again, each writing only the macroblocks it decoded last.markSegmentsrunsff_er_frame_end’s marking passes, which reset at each slice start, one segment per invocation, anddecideConcealmentdecidesis_intra_more_likelyon the reconstructed picture. FFmpeg’s MPEG-2 decoder keeps no motion vectors for its error resilience, so a damaged macroblock taken for an inter one predicts from the same place, both ways in a B picture. One taken for an intra one getsguess_dc’s DCs,filter181over luma’s, andput_dc’s flat blocks; thenh_block_filterandv_block_filtersmooth each damaged edge with an intra side. A picture whose slices all ended, covering it once, runs none of it: the concealment dispatches are indirect and get no workgroups. Header damage follows FFmpeg too: a damaged sequence header is ignored, a zero in a quantizer matrix stops that matrix, an invalid picture type takes the picture coding extension’s guess or skips the picture, and a picture header after a frame picture is ignored, its slices decoding into that frame. Until 10-27 a change of size or chroma format ended the attempt; since then the package’s parser moves a larger sequence or another chroma format to a session of its own, and a sequence its pool holds reinitializes the decoder in its session (see the entry on the MPEG-2 package’s parser). A picture before any sequence size, a packet whose start codes or pictures overflow the decoder’s tables, or a field that does not pair resyncs. Against FFmpeg’s C code paths on one thread, 162 damaged streams decoded with no failure, and every frame picture of the 4:2:0 streams matched FFmpeg bit for bit apart from the differences the next entry lists; a 4:2:2 broadcast sample whose damaged P picture FFmpeg conceals matched over all 50 frames. - Known differences between the WGSL MPEG-2 decoder and FFmpeg (10-13, 10-15, 10-27). A packet that holds no picture FFmpeg outputs, such as a skipped open-GOP B picture, repeats the latest reference, or gray before one, because the decoder outputs a frame for every packet, its parser deciding no output events. Since 10-15 the first field of a pair split across packets presents with its other field concealed, which the next packet’s second field replaces; FFmpeg outputs the pair once, with the second packet. Until 10-15 damaged slices, slice gaps or overlaps, and motion vectors outside the reference ended the attempt with the error and the failing value, where FFmpeg conceals and continues. Since then the decoder conceals as the previous entry describes, and differs from FFmpeg only where FFmpeg’s own output is undefined or misplaced. FFmpeg’s error resilience skips field pictures, which keep what its frame buffer held; the decoder conceals them as it does frame pictures, on their own lines, from the references’ fields of their parity. It addresses the chroma of 4:2:2 and 4:4:4 as if it were 4:2:0, concealing and smoothing the top-left quarter of other macroblocks’ chroma, while the decoder conceals each macroblock’s own chroma blocks. A slice too short or below the picture makes FFmpeg abandon the rest of the packet, which it neither conceals nor outputs; the decoder skips that slice and conceals what it would have decoded. Its motion guess stops at the reference’s visible height, so a damaged row of macroblocks below it keeps what the buffer held; the decoder conceals it, and later pictures predict from it. A motion vector outside the reference makes FFmpeg skip the prediction and add the residual to what the buffer held; the decoder predicts from the reference’s clamped edge and counts the macroblock as concealed. Damaged coefficients beyond the standard’s range overflow FFmpeg’s default SSE2 IDCT differently from its C one, which the decoder follows, as the IDCT entry above describes. Until 10-15 interlaced pictures presented woven: the decoder read the field flags, but no deinterlacer ran and no flag reached presentation, so eligibility refused interlaced streams. Since 10-15 the decoder reports each picture’s fields and the deinterlacing pass presents them (see Deinterlacing). A sequence’s last anchor before a size change presents; FFmpeg drops it, since it reinitializes at the new sequence before it outputs that anchor.
- The MPEG-2 package’s parser reads only sequence headers (10-27).
The decoder opened at the container’s coded size and failed any later sequence of another size as
pictureSize, so a broadcast transport stream that switches resolution fell back. A session learns a picture’s size only from its parser, before the picture decodes, since a status readback arrives after later packets queued, so the package needed a parser that declares each sequence’s size, as the VC-1 package’s does. The parser (wgpud/mpeg2/parser/) reads the sequence headers and extensions before each packet’s first picture start code, as the WGSL’s header stage reads them, and hands the packet on unchanged as the picture’s record. A full CPU parse, as VC-1’s, would have moved the WGSL’s header stage and slice table to the CPU for no gain, since MPEG-2 slices already decode in parallel on the GPU; a record that is the packet keeps the decode, its concealment, and its picture fields bit for bit as they were. It decides no output events, so frames present in timestamp order as before, and it reports each sequence’s chroma format, so a change of chroma format moves to a session of its own too. A smaller sequence stays in its session, whose pool holds it, so the WGSL now drops its references and starts the new geometry where it failed before, as FFmpeg’smpeg_decode_postinitreinitializes. On Dawn through the playback worker and the feed, MPEG-2 transport streams and Matroska files joined from FFmpeg’s 720x576 and 1920x1080 interlaced segments, up, down, up and back, at 4:2:0 and from 4:2:0 to 4:2:2, a change within one pool (720 and 736 columns), and MPEG-1 from 352x288 to 640x480 and back, presented every frame of each segment’s own FFmpeg decode bit for bit, where the package without a parser failed each aspictureSize. Every earlier MPEG-2 check stream without a size change presented the same frames through the parser as without it. - The AV1 package’s parser is libaom’s decoder running its parse-only pass (10-14).
vendor/aompins libaom v3.15.2, and theav1project builds its decoder alone to WebAssembly with four small patches: every tile parses with the parse-only flag, hooks hand each transform block, block, frame, and missing reference to a C bridge, and the decoder exports its intra edge counts, its CfL buffer extent, and its deblocking levels. A parser of our own would repeat libaom’s entropy decoding, motion vector prediction, and reference and segmentation bookkeeping, which nothing on the GPU could check; libaom’s are the reference’s own. libaom’s frame buffers point at an address no module memory maps, so the parser holds no samples, and a stray sample access traps. - An AV1 record carries libaom’s decisions, not its syntax (10-14). Each frame’s record holds the blocks with their modes, motion, warps, transform partitions, and palettes; each superblock’s intra jobs per plane in decode order, with libaom’s edge counts; transform jobs with libaom’s dequantized coefficients, dense or sparse, whichever is smaller; and the restoration units, CDEF indices, deblocking levels, references with their scale factors, global motion, and film grain. The WGSL then needs no syntax, no probability models, and no decode order beyond the wavefront, and every value it reads is the one libaom’s own reconstruction would have used.
- Every AV1 frame is one picture, hidden frames included (10-14).
A temporal unit’s hidden frames decode into references of their own, so the parser makes one picture per frame through parser interface 2, and the unit’s last shown frame presents, as libaom and FFmpeg’s libdav1d output one frame per temporal unit at operating point 0.
References stay in the package’s own frame store, ten frame buffers the parser assigns, so the header declares
references: 0, and only shown frames reach the frame pool. - AV1 reconstructs in 16-bit frame buffers at every bit depth (10-14), in a store split over two buffers (10-27). Two samples per word keep every write owned by one invocation, since blocks and transforms start at multiples of 4 and no two filter footprints share a word; 8-bit streams pay twice the memory of 8-bit samples. Level 6 makes the ten frame buffers of an 8192x4352 picture 1.07 GB at 4:2:0 and 2.14 GB at 4:4:4, past many devices’ largest binding, and the RTX 5080’s 2 GiB under Dawn holds 4:4:4 at 8192x4352 but not every legal pool rounded up to 64. Bytes for 8-bit samples would halve the store, but four samples a word put two deblocking edges four samples apart into one word, which two invocations would write, so every family’s sample layout and the filters’ write ownership would change. So since 10-27 the store spans two buffers of five frame buffers each, and one comparison against the first buffer’s length picks the buffer of each word; a device whose binding holds half the store decodes it, and the fit check names the buffer a device cannot hold.
- AV1’s intra blocks reconstruct in a superblock wavefront (10-14). Inter blocks predict per 16x16 tile, and the transform passes add their residuals first; then a superblock waits for its left, top, and top-right neighbors in its tile (slope 2; IntraBC’s delay rule needs slope 5 with 64x64 superblocks and 3 with 128x128), chroma runs one wave behind luma for CfL, and tiles run side by side. The parser reports each picture’s wave pairs, so a frame without intra, IntraBC, or inter-intra blocks runs no wave. Both dispatches of a pair run one entry point, which picks its wave counter by its workgroup count, so the wavefront compiles once; a second copy cost 0.3 to 0.4 s of pipeline creation.
- After a seek, AV1 temporal units before the first key frame make no picture (10-14). libaom cannot decode them without the references they predict from, so the parser waits for a key frame from its start or reset, as libaom’s own interface does. An inter frame after a resyncing intra-only frame that misses references makes no picture either, while a key frame’s own failure is the bitstream’s.
- The WGSL AV1 decoder matches libdav1d bit for bit (10-14, 10-15), up to level 6.x (10-27).
Every frame matches FFmpeg’s libdav1d on the package’s nine vectors, libaom’s 242 test vectors, 53 encodes of our own from libaom, SVT-AV1, and rav1e in every output format, and Argon’s 2,524 conformance streams within level 5.x, 26,772 frames; stage dumps compared every decode picture’s reconstruction and each filter stage with libaom’s own.
Until 10-27 the 239 Argon streams with a frame beyond level 5.x were outside
accepts. Since then the 131 of them within level 6.x decode through the feed with every frame matching, 1,293 frames across 28 stream changes, and so does an 8K 10-bit SVT-AV1 encode; eight of them carry records past the old 64 MiB bound, up to 92 MiB. Their first run found two faults the level 5.x streams could not reach: the film grain block offsets held 16,384 32x32 blocks, fewer than an 8192x4352 picture’s 34,816, and libaom refused a 16384x2176 4:4:4 frame buffer with a scaled reference’s borders past an eighth of its 32-bit allocation bound. The other 108 have frames beyond level 6.x, up to 16640x8960 or 40,468,480 samples, whichacceptsrefuses and the parser fails aspictureSizeor, where libaom’s frame size limit refuses them first,bitstreamCorrupt. A stream that changes its output format midway, as Argon’s profile switches do, or whose later sequence header raises the maximum frame size past theav1Ca session was sized from, as libaom’s sizeup and Argon’s joined sequences do, continues on a session made for the change, as stream changes do since 10-14. The runs of 10-14 decoded each run of one format on a session of its own, sized from the largest picture the parser reports; those of 10-15 go throughGPUVideoDecodeFeeditself with the built package, an IVF without a size declaring its first frame. There every frame the feed delivered matched, but the feed’s pool sizing first ended 32 Argon streams, 22 asacceptsand 10 asvisible-rectangle, and one own encode whose sequences declare 16384x8704 for 640x360 frames:acceptsjudged pools rounded up to 64, which a legal level 5.x picture such as 7396x1205 crosses, and a refused pool ended the stream even where its pictures fit a smaller one. With the sizing rules of 10-15 for stream changes andaccepts, every stream of the four sets decodes through the feed, Argon’s across 1,072 stream changes in 746 streams. - AV1’s parser sets its pace (10-14). On the RTX 5080 through Dawn, a 2160p50 10-bit SVT-AV1 stream takes 1.0 to 1.1 ms of GPU time a picture, 0.8 to 0.9 ms with 4x2 tiles, and 1080p50 8-bit streams 0.3 to 0.5 ms, while the parser takes 6.4 to 7.3 ms a picture at 2160p and 3.2 to 4.6 ms at 1080p. libaom parses a frame’s tiles in order on one instance, so a picture plans whole on the coordinator worker with no parse units, which bounds 2160p at about 100 frames a second, at about 1.5 pictures a frame.
- AV1’s pipelines take about 2.6 s to create cold (10-14, 10-15). The package has 17 entry points; on Dawn’s D3D12 backend, which compiles with DXC, a session creates them at once in about 3.2 s cold from the 560 KB module on the RTX 5080, with about two compiling at a time, and in 2.6 s since each compiles a module of only what it reaches. The NVIDIA driver keeps compiled shaders on disk, so a compile it saw before is warm: the 2.0 to 2.2 s measured on 10-14 were partly warm, and a later session of a package version and format the device compiled compiles nothing. On 10-14 the heaviest alone were the inverse transforms, 0.93 s, the tables, 0.55 s, and the wavefront, 0.47 s; since then the wavefront’s two entry points became one, the 64-point transforms moved into a kernel of their own, and the inter and transform families each read their tables from one packed array at one site, which took their loaders from 0.26 and 0.19 s to about 0.1 s.
- A GPU decoder compiles before its playback needs it: in the background after the probe warm-up, from the negotiation’s prediction, and from the eligibility (11-05). Until then a session compiled its decoder only once the worker had opened the file and described the track, and its first packet waited for every pipeline, so a compile the browser had not cached came whole before the first frame. With Dawn’s shader cache cleared, the VC-1 package’s 15 pipelines took 1.15 to 1.9 s on the RTX 5080, against about 5 ms cached: the interlaced frame loop filters 0.8 to 1.9 s each, from 14 KB modules that inline the segment filter about 11 times, and the 47 KB reconstruct 0.6 to 1.3 s. Every packet dispatches every entry point, and the interlaced filters return at once on a progressive picture, so the engine cannot leave a pipeline out of the first packet’s wait without the package declaring when a dispatch runs. From a click to the first frame of a progressive 1080p VC-1 film, cold, a host took 2.33 s compiling at the session, 2.04 s from the eligibility, and 1.72 s from the start of its negotiation, against 0.52 to 0.58 s warm; an interlaced one took 2.45 s at the session and 1.91 s from the negotiation. The negotiation’s start is only about 0.1 s before the eligibility against a server on the same network, so the background warm-up is what takes the compile off the path: a page that warmed its packages played the next VC-1 film’s first frame 0.30 to 0.54 s after the click, compiling nothing, and a later page’s warm-up compiled them in 5 ms from the browser’s shader cache. The warm-up compiles only what a stored package decodes that no browser does, one package format at a time and each at a background turn, so it starts no compile beside a playback, though a playback started during one finds it running; H.264 and HEVC playbacks started as before.
- AV1’s 64-point transforms run in a pass of their own (10-15).
The 64-point DCT’s unrolled flow graph made the one inverse transform kernel the longest compile of the package, 1.16 s.
So the parser lists the transform jobs with a 64-point side after the others and records where they start,
inverseTransformruns the jobs before that index, andinverseTransformLargeruns the rest on the same grid. The two kernels compile in 0.69 and 0.88 s side by side, and on the GPU the pair matches the single kernel, the pass with no jobs of its own costing 6 us. The large kernel runs DCT_DCT only, since libaom codes no other transform type across a 64-point side; a record that carries one, or a job on the wrong side of the index, fails asrecordInvalidwith value 10, and the job writes nothing.
Deinterlacing
- Interlaced video deinterlaces in presentation, with yadif (10-15).
Until 10-15 eligibility refused every stream whose metadata did not call it progressive, and the GPU decoders presented interlaced pictures woven.
yadif is FFmpeg’s default deinterlacer and fully defined, so FFmpeg’s output is a known answer: it predicts each missing sample from its own field along the best of five edge directions, then clamps that around the mean of the neighbouring frames by how much the area moves, so still areas keep their full vertical detail and moving ones the spatial prediction.
The pass,
Presentation deinterlace, implements it from the algorithm with FFmpeg’s options:mode=send_fieldorsend_frame,parity=auto,deint=interlaced, and the spatial interlacing check of mode 0. A CPU reference,test/helpers/yadifReference.ts, matches FFmpeg’s yadif bit for bit in integer arithmetic on FFmpeg-woven 576i and 480i, 1080i at 10 bits, 4:2:2, and a 9x10 4:4:4 frame, at both rates, and the WGSL pass matches its f32 arithmetic exactly on Dawn. - It deinterlaces the signal’s RGB, per channel in f32, at the frame’s visible size (10-15).
The signal is where every route’s frames first meet in one form, before tone mapping and scaling, so one pass serves native
VideoFrames, raw planes, and frame pool slots, at 8 to 12 bits and every chroma format. Deinterlacing the YUV planes, as FFmpeg does, would need a pass for each plane layout, and an external texture exposes no planes at all. The signal renders into a ring of threergba16floatframes, and yadif’s integer score bias of one code becomes 2^-bitDepth, one code at the signal’s depth. The output’s luma agrees with FFmpeg’s YUV-domain yadif at 42 to 55 dB PSNR on MPEG-2 576i, VC-1 1080i, and H.264 1080i; the difference is yadif’s clamps and edge choices, taken per channel. - A deinterlacing plan reads 4:2:0 chroma within each field on the raw and frame-pool routes (10-15).
An interlaced 4:2:0 frame’s chroma rows alternate between its fields, so the progressive read blends the other field’s colors into every row, which bleeds at moving edges once yadif keeps one field.
The signal reads a row’s own field’s chroma rows at MPEG-2’s interlaced siting, a quarter of a field row below the top field’s luma and three quarters below the bottom field’s, whenever the frame it reads is interlaced (
fieldState’sinterlacedSignalbit), so a progressive frame of the same stream keeps the progressive read. Only a deinterlacing plan’s signal carries the branch, so every other plan stays as it was. An external texture cannot: the browser upsampled its chroma progressively before the page sees it. - Double rate is the default (10-15).
An interlaced frame’s fields are two moments, 20 ms apart at 50 Hz, so a frame per field keeps the motion of broadcast video, where single rate halves it.
Single rate (
send_frame) and off, woven frames as decoded, stay a host’s choice throughsetDeinterlaceMode. - Deinterlacing takes one frame of lookahead, and the clock stays (10-15). yadif reads the frame after the one it shows, so the frame loop takes frames one frame duration ahead of the clock and the presenter shows the frame before the newest. The clock, and with it audio, never moves: decoding runs one frame further ahead within the same credits. A frame’s second field presents at its time plus half the interval to the next frame, rounded down to a whole microsecond, as FFmpeg’s yadif stamps it. After a reset the first frame shows at once without a next frame, and the last frame shows with itself as its next, as FFmpeg’s yadif ends. A field drawn between new frames is a draw from history: no source, no upload, one encode.
- Each frame carries its own fields, from where they are known first (10-15).
The picture fields word is the same in WGSL and parsers: bit 0 reported, so a reported progressive picture differs from none, bit 1 interlaced, bit 2 top field first, bit 3 repeat first field (
PICTURE_FIELDS_WORD). A frame’s fields are its GPU decoder’sreportPictureFieldsreport, else its parser’s optionalparser_picture_fieldsword, else its container’s scan: Matroska’s FlagInterlaced and FieldOrder, or an MP4 sample entry’sfielbox. The fork’s Matroska demuxer reads FlagInterlaced and FieldOrder and reports them as the track’s field order,getFieldOrder(branchv2-matroska-field-order), so the engine’s format wrapper, which recorded them while Mediabunny parsed the track entry, left (10-27). A GPU decoder’s report reaches its frame through the decoder status’s per-packet block, as “Every packet’s status reads back” above describes. The field order codes follow FFmpeg’s mapping: 9 stores the top field first and shows the bottom first, and 14 the reverse. A file whose MuxingApp names libavformat 57.36.100 to 57.51.100 holds 9 and 14 swapped, as that muxer wrote them, so they are read swapped back, as FFmpeg’s demuxer does. The swap repairs what one muxer wrote rather than choosing how to present, so it moved into the fork’s demuxer with the reading, andgetFieldOrderreports the order the file means (10-27). A frame without fields takes the stream’s scan from the host’sFieldOrder, in ffprobe’s names, else itsIsInterlaced; an interlaced frame of unknown order presents top field first, as yadif’sparity=autodoes, and a frame of unknown scan presents as it is. - Eligibility refuses interlaced video only where no route presents it (10-15).
HEVC codes interlaced video as field pictures and JPEG 2000 as a codestream per field; no decoder weaves them into the frames the pass takes, so
IsInterlacedtrue refuses them. A Dolby Vision route never deinterlaces, since the profiles are progressive, so it keeps requiringIsInterlacedfalse; a declared base layer’s ordinary route deinterlaces. Every other codec’s interlaced or unknown scan is eligible.
Black bars
- Black bar crop is built into presentation, not a pass (11-03).
A pass writes pixels inside the output rectangle the plan gives it: it cannot move that rectangle, change how the picture fits the surface, or report what it found, so a pass could only zoom within the frame’s own letterboxed rectangle, never let the picture fill a surface of another shape.
The crop has to reach the layout, so the presenter decides it and
calculateTexturePresentationGeometryfits the picture the bars leave as the source. - The GPU measures and the CPU decides (11-03).
Presentation black barsreads 64 points along every row and column of the signal, about 384,000 points of a 4K frame’s 8.3 million; the same dispatch over a 4K texture took 9 microseconds on an NVIDIA Blackwell GPU in Chrome, measured with timestamp queries, and its readback is a word per line, 24 KiB at 4K.BlackBarDetectortook 34 microseconds per 4K frame in JavaScript. Reading the planes on the CPU would cost a copy of every frame, and an external texture has no planes to copy. Deciding on the CPU keeps the rules testable and their state plain, at a frame’s latency: a change shows from the frame after the one that showed it. - The crop behaves as a scan of the whole film would (11-03). A scan beforehand crops one rectangle, the union of every frame’s picture, from the first frame on. Detection that sees no frame ahead gives bars up at once when picture reaches into them, as the union does, and takes bars only on evidence a dark scene cannot fake: a sharp picture edge where the bars end, symmetric bars, and agreement across two shots. A frame that looks like a letterbox or a pillarbox, sharp symmetric edges on one axis and picture across the other, crops at once, so a film crops from its first bright frame, as a scanned film would from its first. Unlike one rectangle, an axis crops again once 30 s of playback kept the picture out of the bars, so an IMAX scene, the opening credits, or a subtitle in a bar does not leave the rest of a film uncropped, and the change lands at the start of a shot; a film that changes its aspect ratio from shot to shot keeps its larger picture.
- A line holds picture by its signed mean luma, as FFmpeg’s cropdetect reads a line, or by 2 bright points (11-04). The 11-03 rule, 2 of 64 points whose largest of R, G, and B passed 0.1, read the ringing a coder leaves beside a bright edge, and the chroma bled across it, as picture. In the sandbox, on 10 s letterboxed clips of moving content with 140-line bars, it cropped 139 lines for H.264 at CRF 20, HEVC at CRF 28, and 10-bit H.264 on the raw-planes route, 137 for H.264 at CRF 32, and 139 for AV1 at CRF 45, which grew to 136 at 4 s, since every frame that rings harder adds to the union. Ringing swings both ways around black, so a line’s signed mean luma cancels it: on the decoded luma planes of those encodes, lines past the one beside the edge stayed under 0.03, and luma leaves chroma out. A line holds picture above 0.06, about 0.1 nits in SDR and in PQ, which is black to the eye. A mean misses sparse picture, a subtitle in a bar or a small bright object, so 2 points of luma above 0.3 also hold picture; no artifact in the bars of those encodes reached 0.3, and chroma bled onto black and clamped reaches 0.21 at most. The new rule cropped exactly, 140 lines, 131 for bars of 131 and 132, and 240 columns of a pillarbox, from the first analyzed frame to the end of each clip: H.264 at CRF 20 and 32, HEVC at CRF 28, also in PQ, and AV1 at CRF 45 on the video-frame route, 10-bit H.264 at CRF 24 on the raw-planes route, and HEVC and AV1 on the frame-pool route.
- One line of bled chroma beside an edge is not picture (11-04).
Chrome’s conversion of a
VideoFramelights the line beside the picture’s edge with clamped chroma: its mean luma reached 0.04 to 0.11 in those clips, against 0.2 to 0.6 for the edge line, with no bright point, while the lines past it stayed under 0.02. The raw-planes and frame-pool signals keep R, G, and B unclamped, which cancels the chroma in luma. So the line at each end of a frame’s picture that holds no bright point and less than half the level of the line inside it is left out. - Averaging the statistics over frames would not have helped (11-04). The edge error was a bias, not noise: a coder rings the same way beside the same edge in every frame, so an average keeps the ringing, and the union of frames, which the crop needs so that it never cuts picture, grows with every frame that rings harder. The fix is what each frame measures, after which the first bright frame crops exactly and later frames add nothing.
- A cut moves the rows’ and the columns’ mean levels by more than 8 of 255 (11-04). The 11-03 rule, the rows’ mean levels moving by more than 20, missed cuts between moving test patterns of like brightness: in a windowboxed clip it missed both, so the windowbox, which needs a second shot, never cropped. The rows and the columns together moved by 11 to 52 at the cuts of those clips, and by 3.3 at most within a shot, and the windowbox now crops at its first cut.
- A crop rounds to a standard aspect ratio only within a line of bar or 0.5% of picture (11-04). The Detect Aspect Ratio plugin measures trickplay thumbnails, so it snaps any measurement within 0.075 to its table of 24 film and television ratios; a measurement of full frames is exact to the line, so only near ratios round, to absorb a stray line, such as a dim edge line on the first bright frame or a bar split 131 and 132. Its table is kept, each ratio that names a fraction at the fraction’s value and Academy at its defined 1.375, since the rounding sets the crop: 1.33 for 4:3 would cut nearly 2 columns from each side of a 1440-column picture. Coded pictures round their ratio to a few lines, such as 1920x804 for 2.39:1 or 1920x816 for 2.35:1’s 817.02, so rounding to the nearest ratio either way would bring a sliver of bar back. Rounding cuts at most 0.5% of the picture, 2 lines at each end of 800, and shows at most half a line of each bar, which leaves a 1920x816 picture as measured; on an axis without bars it moves nothing, since that axis’s picture is whole.
Firefox
These were settled on Firefox 157 on Windows.
- WebCodecs has no HEVC on Windows (10-07).
IsSupportedVideoCodecin Firefox’sdom/media/webcodecs/WebCodecsUtils.cppaccepts HEVC only on macOS and Linux, and only with bothdom.media.webcodecs.h265.enabled(on by default only in Nightly) andmedia.hevc.enabled. That second pref, on by default, lets<video>and MSE play HEVC through the Windows HEVC extension. HEVC therefore decoded in the bundled decoder until the CPU decoders left (10-13), and now plays only through a GPU decoder package; the native HEVC, external HDR, and native Dolby Vision routes never qualify on Windows. The hardware decoder behind<video>cannot feed the custom pipeline, whose frames must reach WebGPU unconverted, while a<video>external texture arrives as SDR sRGB. The bundled decoder ran on one core, about 20 fps at 4K on a fast CPU, so 4K played below real time. This is left as is until Firefox enables WebCodecs HEVC on Windows. - A
VideoFramecannot be constructed in a high-bit-depth format (10-07): the constructor rejectsI420P10. The bundled HEVC decoder’s frames therefore never passed through aVideoFrameon the raw route: as the decoder drained a frame, the worker wrote its planes from WASM memory into the aligned raw buffer it then transferred (10-09). On the VideoFrame route, which 8-bit SDR took in Firefox, the worker wrote each drained frame into compact I420 planes and constructed theVideoFrameitself withtransfer, with the metadataVideoSample.toVideoFrame()gave it; a browser that ignoredtransfercopied the planes, and the worker wrote the next frame into the same buffer. A GPU decoder package’s frames never leave its frame pool, so the limit no longer matters to them (10-13). The external P5 authorization builds anI420P10frame and fails, but it needs WebCodecs HEVC regardless. - Copy options name a
formatonly to convert (10-10). The spec accepts only RGB formats there, and Firefox enforces it, rejecting even a frame’s ownI420P10inallocationSizeandcopyTo. The raw copy and its capability probe therefore leaveformatout when the frame already has the requested format. - 10-bit AV1 decodes to 8-bit
BGRX, and the 4K VP9 Profile 2 vector fails to decode (10-07), so raw HDR AV1 and VP9 do not qualify. - WebGPU completes queue work only when the GPU process polls the device (10-16).
Firefox resolves
onSubmittedWorkDone()andmapAsync()inside wgpu-core’sDevice::maintain, which runs at the end of eachqueue.submitand on the GPU process’s 100 ms timer (POLL_TIME_MSindom/webgpu/ipc/WebGPUParent.cpp; bug 1870699). The frame-pool route waited on each packet’s completion before submitting the next, and native frame credits wait ononSubmittedWorkDone(), so decoding advanced only as presentation submitted, and every 100 ms once the frames ran out. An empty submit runs the same maintenance without work, sowaitForGPUCompletionpolls the device with one every 2 ms while a completion is pending; Mozilla measured completion at 100 ms idle against 3.85 ms in Chrome, and at 21 ms with an empty submit every 20 ms. Chrome completes queue work within milliseconds of the GPU finishing, so the polls add only a few empty submits there. - The Vorbis decoder emits an empty
AudioDatafor the priming packet before the decoded one (10-07). Mediabunny skips it at runtime, and the audio probe skips empty outputs too.
Playback robustness
- Decode-clock guard (08-07,
cde9dba045). One stale or seek-preroll frame more than 2 s behind the clock is discarded and re-credited, with no immediate renegotiation. Sustained starvation still falls back after the bounded timeout. - Hidden-page video follows Chromium (10-01).
A hidden page stalled video at exhausted credits while audio ran on, and on return the lag guard replayed the backlog (frozen, then fast-forward) or tripped
playback-stalled. The engine mirrors Chromium’s background video track optimization: drain while hidden, release the decoder after 10 s, and on return restart video alone from the preceding keyframe. Audio is never touched. Onlynativedecode is suspended; the software backends kept draining, because a keyframe resync cost up to a GOP of CPU decode, and thegpubackend keeps draining too (10-13). A full seek was rejected because it restarts audio too. - Presenter geometry (08-07). Layout is invalidated on seek, resize, style and class mutations, and CSS motion events. Nothing reads layout per frame during an animation.
- Timing trace and presentation counters (10-09).
Video that crawled and caught up in waves showed no dropped frames, because a frame shown late is not dropped and a clock re-anchor is not counted.
The telemetry now counts frames shown later than their own duration while playing, the worst lag, and clock re-anchors of 16 ms or more with the largest jump, and hosts report stale discards beside skipped frames.
TimingTrace.tsrecords render ticks, clock syncs, frame arrivals, decodes, and outputs, frame credit and read waits, fetches, GPU completion, audio clock mappings, and long tasks, but only while a host runs a trace. Without one, a hook costs a null check and, at most, its small field object. Worker events carry epoch times,performance.timeOriginplusperformance.now(), and reach the page on their owntiming-tracemessage, which is merged even from a replaced generation because it explains the moments before the replacement. - Owned decode paces its packets (10-09). A frame credit was held, not spent, while packets were read, so with no decoded frame waiting the pump read packets until the first output came back. A hardware HEVC decoder answered after a few milliseconds, by which time a whole group of pictures had reached it: 12 4K packets in 6 ms, every 12 frames. On an Intel Arc laptop each burst delayed the GPU work of the frame being presented by 40 to 80 ms, a visible hitch twice a second, while every frame was still chosen on time. Each packet now waits, up to 10 ms, for the decoder to return a picture before the next packet is read, so decode follows presentation one packet per frame. A decoder that needs more packets to reorder gets the next one when the 10 ms pass.
- A preroll decodes in batches the GPU keeps up with (10-27).
On an Intel Arc laptop, resuming 4K HDR10+ HEVC mid-GOP, the first two presented frames’ GPU work took 266 and 129 ms, Chrome held back two animation frames, and one frame dropped.
Measured on an RTX 5080 in Chrome 156 with a 4K HDR10+ HEVC stream of 10 s GOPs, a seek mid-GOP decoded 123 preroll frames in about 240 ms, and its first presented frame’s GPU work took 31 to 44 ms, against 11 to 17 ms for a start at 0, which has no preroll but builds and imports everything for the first time, and 2 to 5 ms in steady playback.
A second seek in the same session, whose pipelines were built, cost the same, and a fixed 80 ms pause after the preroll brought the first frame to 2 to 5 ms, so the cost was the preroll’s decode work, not the presentation pipeline.
A Chrome trace showed why: the GPU process submits each decode in about 2 ms and the decoder returns the frame then, while the GPU decodes a frame more slowly, so the preroll left tens of milliseconds of decode queued, and the first frame’s import waited behind it.
Pacing the preroll by time would have slowed it, and a
copyToof a few texels of a hardware frame in the worker did not resolve in the trial, so the worker reads one texel of a batch’s last frame with a WebGPU device of its own and waits for that work, which the frame’s decode precedes. Batches of 4 frames with two batches ahead kept the first frame’s GPU work at 1 to 7 ms; in a trial that waited on every batch at once, batches of 16 left 11 to 18 ms. The preroll took about 40 to 60 ms longer by the decoder’s outputs, which is the decode work it no longer leaves queued, and the first frame presents about when it did, now without a long draw. A frame exactly at the start used to follow the frame before it, which the first tick then skipped for the start’s frame and counted as dropped; the frame at the start now leads alone. The Arc’s numbers are unmeasured since. - The plan a track declares is built before its first frame, and a device keeps its compiled passes (10-27).
The presenter learned a stream’s chroma format, and its frame size where the host gave none, from the first frame, so a plan that reads them, the raw and frame-pool routes and the external ones at a declared location, an interlaced stream’s deinterlacing plan, or a preset pass sized by the frame, compiled while the first frames presented, and the first frame drew with the plan before it.
The worker reads both from the track at its ready response, so the controller hands them to the presenter between
configuredand ready, and ready waits until the executor for them is installed; on the native HDR10 HEVC route of an RTX 5080 the plan’s one pipeline already compiled inside the play request, and the first frame’s chroma left the plan as it was. Compiled passes stay on the device, most recently used first, up to 64, the size of a preset of large passes; a pipeline holds driver memory, so a bound keeps variants that come and go, such as HDR output toggles and deinterlacing modes, from growing it, and an executor keeps the passes it holds when they are dropped. The next session on the kept device, as the next item of a queue, then builds its executor without compiling, where each session used to compile its passes again. The coded size is a prediction: a frame whose visible size differs replans at the first frame, as before. - One decode worker per session (10-09).
Every generation, which every seek, audio track switch, and paused repaint starts, used to create its own worker and paid worker startup, script evaluation, and WASM decoder instantiation each time.
The session now creates its worker at the first start, runs every generation in it, and terminates it only on
destroy. Since 10-17destroyleaves a worker fit for another generation to the page’s next session from the same factory, once its runs postedstopped, and terminates it if none takes it within 30 s. Each item of a queue built its own controller and session, so every episode switch started the decode worker, its parser coordinator, its six parse workers, and its audio decode worker again and fetched their scripts and the output stage’s WASM. The handoff waits for the runs to end because a new controller numbers its generations from 1 again, so a run still unwinding could post under the next session’s generation; a session that starts first takes a new worker. Runs overlap since 10-16: the session posts the nextstartright behind the previous run’sstop, and the worker starts it at once while the stopped run unwinds beside it. Before, a start waited for the previous run’sstopped, and a run that did not post it within 1 s had its worker replaced, so a seek paid the wait, then a cold worker and a lost URL source; contention in the field held an unwinding run past that bound now and then. The wait came from the bundled HEVC decoders, whose stopping run waited for every one in its worker, the next run’s included (10-13). Without them, a stop closes the run’s decoders and audio attempt and cancels its reads at once, and the next run’s parser pipeline replaces the stopped run’s on the shared coordinator, so nothing the next run needs waits for the stopped run’sstopped. A retired run has 10 s to poststopped; past that the session warns and replaces the worker at the next start, and a start that would leave more than 4 retired runs unwinding in one worker takes a new one. A worker is replaced rather than reused after a generation fails, a message fails validation, or the worker crashes; a retired run’s failure leaves the running generation in place and replaces the worker at the next start. A lost audio decode worker no longer asks for the decode worker’s replacement, since the next run may start before the lost one’sstopped; the decode worker spawns another for that run instead, andstoppedlost itsreplaceWorkerfield. A session’s start on another video decoder backend gets a new worker too, since the session’s earlier runs may still unwind in the old one and use what their backend set up there, such as a package parser’s workers; the page’s next session takes an idle worker on either backend (11-05, below). Each run still opens its own input, so the read cache does not outlive a seek; since 10-16 a URL source does (below). After a run ends on its own, its generation stays current while the worker idles, so a native-media element that plays out after decode still ends the session. Before, the run’sstoppedretired the worker and with it the generation, and such a session did not end. - Presentation stays on the page; presentation in the worker was removed (10-09).
Its first stage (
be2cd76) kept each decoded frame in the decode worker, which drew it with a GPU device of its own into a canvas transferred withtransferControlToOffscreen, so noVideoFrameor 25 MB 4K 10-bit raw buffer crossed to the page. In field use it felt less responsive than the page presenter, and on an Intel Arc laptop (Meteor Lake iGPU beside an RTX 4060) 4K HDR10+ playback dropped frames after a few minutes, which the engine before it (a3bba75) did not. The cause was not isolated. The suspects: every draw waited on the decode worker’s event loop, which also demuxes, decodes, and copies; each session took a second device on the same adapter and authorized its route again there; and a frame’s credit returned only after the renderer’s GPU work and the page’s release. The persistent worker, the bundled HEVC single copy, and the audio decode worker, which landed with it, stayed; the single copy left with the CPU decoders (10-13). Presentation off the main thread should return only with a measured cause, and on a thread of its own rather than the decode worker’s. - The host names the paths whose cross-origin 206 hides
Content-Range(10-14).Content-Rangeis not CORS-safelisted, and Jellyfin’s stream endpoint does not expose it, so the range check accepted a 206 with a boundedContent-Lengthfrom a hard-coded Jellyfin path, which bound the engine to one host. A URL source now carrieshiddenContentRangePathPattern, a RegExp without the global or sticky flag that crosses to the decode worker by structured clone, and without it every 206 must show itsContent-Range. The Jellyfin plugin sets the same/Videos/{id}/streampattern on the stream URL it plays, so its playback is unchanged; the sandbox sets none. - A URL source outlives its run and reads ahead by seconds of media (10-16).
Every seek started a run whose new source read the container’s header and index again over new connections.
Mediabunny’s prefetch reached at most 8 MiB past a read, under a second of a 70 Mbit/s remux, and it aborted each response at its target, so the next read paid a new request and a TCP slow start.
The fork’s
UrlSourcekeeps a response open for 4 s at its target, where a sequential reader extends it, keeps a set read-ahead past the latest read, pins what it cached outside its cache budget, and cancels waiting reads (branchv2-url-source-read-ahead). The worker keeps the last run’s source for the next run over the same URL and pattern, cancels its waiting reads when a run stops, and releases it when a run fails, when a run opens other media, or when no run takes it within 5 s. Since runs overlap, only the run that took the source last decides its release, so a stopped run that ends after the next one took the source leaves it be. After the first preparation over a source, the worker pins its cache, the container’s header among it, and sizes its read-ahead to 5 s of media and its cache to 15 s, at the media’s size over the server’s duration, else the container’s, within 8 to 64 MiB and 32 to 192 MiB. Since 11-05 the Matroska index arrives later, at the first lookup past the start, and the fork pins it as it reads it once the cache is pinned (below). The server’s duration reaches the worker in the start request, whose absence asks for the container’s in the ready response. Each run still opens its own input, because a canceled read rejects promises a kept demuxer could have memoized. - Startup reads only what the first frame needs (11-05).
A Matroska start at 0 read the Cues at the end of the file before the first frame: a second request after the header’s, whose response stood behind the header’s, which stayed open past its target and kept taking data on the same connection.
Through a redirecting front end, each of those requests also paid the redirect.
The fork’s Matroska demuxer now reads Cues that sit behind a seek head at the first lookup for a time past the start, from which a lookup at the start begins at the first cluster anyway, and pins them in the source’s cache when its host pinned it, so every later run’s seek reads them from memory (branch
v2-matroska-deferred-cues). The fork’sUrlSourcesends range requests after a redirect straight to its target, starting over at its URL when a request there fails (v2-url-source-redirected-requests), and a read that waits on a new request ends the responses that only prefetch, which stay idle until a read near them resumes them (v2-url-source-waiting-reads). A resume or a seek still reads the Cues before its cluster, now without a response at the start of the file competing. - An MP4 or MOV movie box is read without the sample tables of the tracks a start does not need (11-05).
The fork read the whole movie box before the first frame, and a remux’s lossless audio tracks can make it tens of MiB: one file’s 57 MB box held 46 MB of TrueHD sample tables.
Its ISOBMFF demuxer now reads a track box over 1 MiB, other than the first video and the first audio track’s, without its sample tables, reads them in one request at that track’s first lookup, and pins them when its host pinned the source’s cache (branch
v2-isobmff-deferred-sample-tables). A source that only reads forward, a stream or a server that ignores ranges, cannot read those bytes again, so it gets every table with the movie box. On a 50 Mbit/s link with 50 ms round trips, that file’s start went from 13.7 s to 3.0 s and a seek run from 6.8 s to 0.7 s; a movie box after the media data gains little, since read-ahead fetches its small skipped regions anyway. The engine read sample entry boxes Mediabunny skips, such asfiel, from the movie box the demuxer kept; it now reads them from the copy of each track’sstsdbox the fork keeps instead, since the movie box itself is no longer kept. The fork’sUrlSourcealso gives a read queued behind busy responses the first of them to reach its target, rather than leaving it to wait out that response’s 4 s hold. - A host may start a session’s head read before it starts the session, through the retained source (11-05).
The first range read went out only once the session’s first run opened its input, after everything the host does before a start, so the head and the decode worker’s startup sat on the critical path.
prefetchCustomDecodeSourcereads the head in the worker the next session takes and leaves the source toURLSourceRetention, so the run continues on the same response and cache rather than the page handing bytes over through a new messaging layer. The run takes the source by the same key as a seek’s, the URL and the pattern, so a prefetch of another URL is released by the run that does not take it and never feeds it a byte. A worker a prefetch creates has run no generation, and a worker that already ran goes to a session on either backend too (below). The read is the one any run starts with, at byte 0 for Mediabunny’s smallest load, so it costs no more than the head read it replaces, and a source no run takes is released within the retention’s 5 s. - An idle decode worker serves a session on either video decoder backend, and a destroyed session’s worker takes the prefetch at once (11-05).
The idle worker went only to a session on its last run’s backend, a rule left from Mediabunny’s decoder registrations, which no longer exist, so a GPU decoder movie after a WebCodecs one, or the reverse, started a new worker and read the head again after the host’s negotiation.
A session now takes the idle worker on either backend, and the worker’s first run on another backend ends what the last one set up: the parser coordinator and its parse workers, or the decode fence’s device.
The worker keeps its audio decode worker, its retained source and the prefetch, and a session’s own generations still change backends in a new worker, since its earlier runs may still unwind in the old one.
A prefetch for the next item waited for the playing session’s worker to park after its run’s
stopped, which raced the host’s negotiation and often lost. A destroyed session’s worker whose runs all gotstopnow takes the prefetch at once, since those runs’ reads were canceled and a stopped run’s release leaves a source acquired after it; a prefetch that waited for the session is posted when the session is destroyed. The worker parks with the prefetch, and a session that starts before it parks still reads the head itself, which a prefetch in a new worker would avoid at the cost of the warm worker. Live, from one movie’s end to a movie on the other backend over a redirecting front end, the second play read the head twice before, once at the click and again about 100 ms after the negotiation; after, it read the head once, at the click, and its first frame came 110 ms sooner from the GPU decoder to WebCodecs and about the same the other way, where shader work dominates. - MPEG-TS, M2TS, and MPEG program streams play on a timeline that starts at their first timestamp (10-29).
A transport stream stamps its packets with a program clock that need not start at zero: a Blu-ray title starts at 600 s, and broadcast recordings a fraction of a second or more in.
A program stream carries the clock of the source it was cut from the same way; a DVD-Video title starts a fraction of a second in, and a VOB cut from later in a title at its own time.
Mediabunny returns those timestamps as they are, and the host’s positions and durations count from FFprobe’s
start_time, so a run started at 0 found its first key packet 600 s ahead: video waited ten minutes with its credits held, the worklet rendered the gap as silence the clock ran on, and nothing stalled or fell back. The worker now computes each run’s origin once its tracks are prepared, the first timestamp of the tracks it plays, truncated to whole microseconds as Mediabunny truncates packet times, and works in container time inside and playback time at its boundary. The tracks a run plays, rather than all of a file’s, set the origin, so a stream the run never reads, which may start late or hold no packets, costs no read; a run without its audio track may start the timeline a few milliseconds later than FFprobe’sstart_time.hasProgramClockTimestampsnames the containers that take an origin, MPEG-TS and M2TS and MPEG program streams; every other container’s tracks start where the host’s positions start, and keep their times. - A container without a video track fails as soon as its metadata is read (10-29).
An unfinalized Matroska file, whose Segment size mkvmerge left at 0, gave Mediabunny no tracks after a read of every cluster, and the worker then reported a missing track ordinal.
The worker now fails a container whose metadata names no video track as
source-unsupportedwith its own message, so renegotiation names the cause; how long the metadata read takes is the demuxer’s.
Playback rate
- Custom playback takes every rate from 1/16x to 16x (11-06). That is Chromium’s media element range, so the HTML fallback takes every rate custom playback takes, and a host needs no second range. No rate falls back to the HTML player, with audio or without.
- Audio plays from 0.25x to 4x, time-stretched with its pitch kept, and a generation outside that range plays without audio (11-06).
Gecko mutes its media element outside 0.25x to 4x, as MDN’s
playbackRatepage states, while Chromium time-stretches every rate it takes and mutes none; the engine plays audio only where both browsers do. Outside it the controller restarts the generation without its audio track, rather than decoding muted audio, which at up to 16x would run decode, demux reads, and network at the rate for nothing. The requested track is kept aside, and crossing back restarts with it; a change within either side applies live. - The time stretch is WSOLA in the AudioWorklet, not in the audio decode worker (11-06).
The ring then stays media-rate PCM, so the producer, resampler, limiter, credits, and progress keep their timing of one sample per sample period, and a rate change applies at the next render quantum with no resync and no gap.
It follows Chromium’s
AudioRendererAlgorithm: a 20 ms window, a half-window hop, 30 ms of candidate blocks, Hann windows, and a transition window that blends the natural continuation into the best block. The search correlates the mono sum of the channels, first decimated by 4, then at full rate within 4 frames of the best coarse candidate, and the overlap-add runs on every channel. Every buffer is allocated at construction, so rendering allocates nothing; in Node 24, 8 channels at 2x, an iteration with its search took a median of 0.066 ms and a p99 of 0.11 ms, against a 2.67 ms quantum and about one iteration per 3.75 quanta. - Above 1x the worker decimates video by a presentation interval, the rate times the display’s refresh interval (11-06).
The display shows at most one frame per refresh, so frames between are lost anyway; dropping them only in the page’s
takeFramestill paid their decode, raw copy, and post, which at high rates outran the decoder and the page. The frame-pool route is exempt, because a package’s parser must see every packet; its page still drops frames it never presents. In the sandbox, in Chrome 156 on an NVIDIA Blackwell GPU and a 144 Hz display, a 1080p24 H.264 file at 8x posted 144 of its 192 frames a second, of which the page presented 97, the grid’s phase not being the refresh’s, and a 1080p24 x265 file at 16x posted 144 of its 384, nearly all presented; both ran at the full rate, without audio. - Only owned HEVC skips decoding pictures that can never present (11-06).
An HEVC picture whose every base-layer slice is a sub-layer non-reference type at the highest temporal sub-layer is referenced by no later picture, which its NAL unit headers alone tell, so skipping it leaves every other picture intact; seeks gain from it too, since such pictures before the start are skipped as well.
H.264 is not skipped because it decodes inside Mediabunny’s sample cursor, which decodes every packet, and AV1 and VP9 because a non-reference picture shows only in its frame header’s
refresh_frame_flags, which needs a frame header parse per packet; they decimate but decode every packet. On that 1080p24 x265 file, whose b-pyramid makes about 55% of its pictures unreferenced, 16x skipped 591 of 1580 packets read without decoding them and decimated 391 decoded frames, and 8x skipped 207 of 1409 and decimated 146; a 60 Hz display, whose interval is 2.4 times as long, keeps fewer frames and skips more. - Above 1x video that falls behind jumps to the next keyframe instead of renegotiating on
playback-stalled(11-06). A decoder slower than the rate never catches up, so the old wait ended in a stall and a renegotiation that played no faster. The jump goes 250 ms of wall time ahead of the clock, scaled to media time by the rate, once a frame is 500 ms of wall time behind, at most the 2 s lag bound; at or below 1x the old guard stays. The keyframe a jump lands on presents even when it arrives late: with the page’s CPU throttled 20x at 4x with audio, every keyframe first came too late, was dropped, and jumped again, so the wait never ended and stalled intoplayback-stallednear the end of the stream. Presenting it, the same trial jumped six times in 12 s, showed a picture after each, played audio without an underflow, and ended the stream normally.
Audio
- Output routing (08-07,
cde9dba045).WebGPUAudioOutputManager.tsowns the sinks. “Default” issetSinkId(''). A suspended AudioContext that should be playing is resumed. Decode, worklet, and PCM queues never restart for a sink change. A chosen device is stored by its opaque ID, falls back to the default, and is restored when it reconnects. - Output device recovery (10-06).
Re-applying an unchanged sink ID does nothing: the Web Audio and Audio Output Devices specs, and Chromium, resolve a same-ID
setSinkIdwithout touching the output. An AudioContext created while Windows has no output device gets Chromium’s placeholderAUDIO_FAKEparameters, and the audio service rebuilds its stream with them on every device change, so it never reaches a device that appears later. Only a new destination escapes: a realsetSinkIdchange or a new context. Media elements request low-latency streams and follow the new default on their own. Chromium 134 and later senddevicechangeonly to pages with microphone permission. Without permission,enumerateDevices()lists one blankaudiooutputentry while any output exists and none when there is none. Engines withoutAudioContext.setSinkIdhide outputs, so an empty list proves nothing there; Firefox instead gives such a context zero output channels. Hence the pool never reuses a context created without an output device, and the router polls enumeration and rebuilds the sink through{ type: 'none' }. Retries are unlimited but run only while the page is visible, which bounds their cost to when someone is watching; audio that plays in a hidden tab recovers when the tab is shown again. - Live output layout switch (10-07).
Only the output stage depends on the decoded layout: the audio decoder always produces the source layout, and the 2, 6, or 8 channel count configures the downmix, resampler, and limiter, the worklet, and
destination.channelCount. A layout change therefore restarts only the audio attempt in the worker (resync-audio, epoch-tagged likeresync-video) and swaps the worklet on the same context and sink. It never goes through the seek path, which would restart video. The new audio starts 250 ms ahead of the clock, so the clock keeps running and video does not stall; sound pauses for that lead. The page learns of a new device throughsinkchangeafter a realsetSinkIdchange (the recovery rebuild or a chosen device). Chromium does not report a default device that moves on its own, which is why the settings offer a manual re-detect. - Downmix (08-06,
ca5ac91a5c,8239010d93). The default is AC-4 (front 1.0, center 0.707, LFE omitted; 5.1 surrounds 0.707, 7.1 sides and backs 0.5 each) with a linked lookahead limiter: a 100 ms analysis horizon, an adaptive 3 to 10 ms attack with quintic smoothstep, a 100 ms exponential release, and a -1 dBFS sample peak (revised 11-02; before, standard Lo/Ro, which matches AC-4 on 5.1 and takes 7.1 surrounds at 0.707). The limiter drains at the end of stream and resets per seek and generation. The alternatives are standard Lo/Ro, peak-normalized Lo/Ro, RFC 7845, Dave750, and night mode. Downmix applies only to a stereo destination; a 5.1 or 7.1 destination takes the source by channel name (next entry), whenAudioContext.destination.maxChannelCountallows. - Layouts map by channel name (10-07). A decoded layout that differs from the output layout is mapped, not rejected, because the decoded layout can differ from the declared one the output was sized for (E-AC-3 7.1 declared as 5.1, for example). Mono goes to the center speaker, or to both channels of a stereo output, and stereo to the front pair. 5.1 to 7.1 fills its sides or backs and leaves the other pair silent, and 7.1 to 5.1 folds each surround as sqrt(1/2) times side plus back. A 6.1, 4.1, or 3.0(back) back center splits across the surround or back pair at sqrt(1/2). 2.1, 3.0, and 3.0(back) mix to stereo through three-channel matrices taken from each algorithm’s 5.1 weights, where a back center enters through both surrounds at sqrt(1/2). Peak-normalized Lo/Ro and RFC 7845 renormalize those weights to sum to one, because the six-channel normalization left a full-scale 3.0 bed 3 dB and 0.88 dB low; RFC 7845 3.0 then equals opusfile’s matrix. Every fold-down runs the limiter: a stereo downmix, an output speaker that sums channels, and a block a package rendered from objects, which it sums without a limiter of its own (revised 10-28; before, a source with more channels than the output, which missed 5.1.2 on 7.1).
- The output follows the source layout (10-07).
Three channels, 5.0, and 5.1 use a 5.1 output when the device has six channels; 6.1 and 7.1 use a 7.1 output with eight, or fold into 5.1 with six; anything else mixes to stereo.
Before 10-07, a three-channel source, and 7.1 on a six-channel device, mixed to stereo.
A live switch applies the rule to the layout the decoder reported (
audio-source-format), not to the container’s declaration, which can under-declare E-AC-3 7.1. Until a track decodes PCM, the host’s request, which follows Jellyfin’s count, stands. - Mono and three-channel sources play (10-07).
The mixer duplicates mono, but the route tables rejected it, so the server transcoded every mono or three-channel track.
Every decoded PCM route admits mono.
Decoders without a speaker mask (AAC, FLAC, Opus, Vorbis, and PCM) deliver three channels as 3.0, so their three-channel routes need Jellyfin’s 3.0 layout, except a 2.1 FLAC (revised 10-29, next entry).
Jellyfin keeps only the part of FFmpeg’s layout name before
((ProbeResultNormalizer.ParseChannelLayout), so 3.0(back) arrives as 3.0 and 7.1(wide) as 7.1. AC-3 2/1 and 3/0 are therefore indistinguishable, and the AC-3 decoder of 10-07 reported no mask, so three-channel AC-3 is neither advertised nor admitted and transcodes. An AC-3 package reports its mask (10-13), but no evidence covers either bed yet. AC-3 mono plays. A profile condition cannot express ChannelLayout, so any other three-channel layout, a 2.1 AAC or Matroska PCM track for example, is negotiated, fails eligibility when playback starts, and plays through the HTML player or renegotiates to a transcode. E-AC-3 and TrueHD admit mono only. - A 2.1 FLAC plays as FL, FR, and LFE (10-29).
A FLAC stream’s channels follow its
WAVEFORMATEXTENSIBLE_CHANNEL_MASKVorbis comment, which a Matroska track carries in its CodecPrivate and FFmpeg’s Matroska demuxer, and so Jellyfin, reports as the layout; FFmpeg’s encoder writes0xbfor a 2.1 input. FFmpeg’s 2.1 encodes with one tone per channel decode with the LFE tone in the third plane for FLAC, and the decoder ignores the comment, so a decoder without a mask delivers those planes as 3.0 and puts the LFE in the center. So eligibility admits three-channel FLAC whose Jellyfin layout is 2.1, withaudioSourceChannelMask0xb, which the host passes inaudioMetadataLayoutand the worker lays the channels out by when the container declares no mask. The other codecs stay as they were: libopus refuses a 2.1 layout and Opus mapping family 1 and Vorbis fix three channels as L C R, which FFmpeg reports as 3.0; FFmpeg’s AAC encoder writes 2.1 through a program config element, which no browser evidence covers; and FFmpeg reports Matroska PCM’s three channels without a layout. - 5.0 beds play (10-29).
Five channels had no layout, so a true 5.0 AC-3 or DTS track transcoded, and a Matroska AC-3 track whose header declared its first frame’s five channels, a 3/2 bed that gains its LFE a second in, failed at preparation as
source-unsupported. Five channels without a speaker mask now lay out as FFmpeg’s 5.0(side), FL FR FC SL SR (0x607), as six lay out as its 5.1(side), and a mask also qualifies 5.0(back) (0x37). A 5.0 bed maps by name into a 5.1 output with a silent LFE, and mixes to stereo as that 5.1 through the selected algorithm, as a height layout mixes through its floor. FFmpeg’s AC-3 encode of five syncframes of 3/2 and then five of 3/2 with LFE, one tone per channel, decodes through theeac3package to0x607and then0x60Fblocks with every tone in its WAVE place (src/capability/vectors/test/ac3/), and the output stage rebinds from five channels to six on one timeline. AC-3 therefore admits five channels; 3/1 with an LFE, FFmpeg’s 4.1, plays as the 4.1 layout added for DTS (10-29), while 2/2 with an LFE reports a mask with no layout and fails its attempt asdecode-failed. - Jellyfin’s channel count screens a track whose container’s count has no layout (10-29).
Mediabunny copies a Matroska track’s header
Channels, which a muxer can write from the first frames (an AC-3 3/2 stretch before 5.1) or wrongly (DTS 5.1 declared as four channels), while Jellyfin’s count comes from FFmpeg’s probe of the frames. Without a container mask, a count with no layout refused the track at preparation assource-unsupported, and playback renegotiated to a transcode. The host now passes the selected track’s metadata layout (audioMetadataLayout: Jellyfin’s count, and a speaker mask where the metadata names a layout no count implies) with each start and track switch, andresolveDeclaredAudioChannelLayoutscreens with it: a container mask decides, then a metadata mask of the container’s count, then the container’s count, and only when that has no layout the metadata’s count. The decoded layout stays authoritative at runtime, as for E-AC-3 7.1 declared as six or seven channels. The native media route plays a track whose declared count it does not qualify at the metadata’s count when that is stereo or 5.1, since the browser’s decoder reads each syncframe’s bed; a true five-channel AC-3 track has no native route at eligibility and takes the package. - E-AC-3 7.1 needs the layout, not only the channel count: the decoder must report the decoded channel layout (
9ca70e11ad). Eligibility also required a declared7.1layout until E-AC-3 became a package codec (10-13); a package reports every block’s mask, which now decides alone. - The DTS envelope, until 10-13, and its routes since (revised 10-29).
DTS-HD HRA was valid at 48 and 96 kHz only.
Above 96 kHz only 5.1 MA (or MA with a DTS:X bed) was admitted.
Stereo DTS-HD MA was admitted up to 96 kHz.
Mono was admitted for every direct-play profile, and three channels for MA and MA with DTS:X when Jellyfin reports a 2.1 or 3.0 layout, because libdcadec reports their speaker masks (0x4, 0xB, 0x7, and 0x103 for 3.0(back), which Jellyfin also reports as 3.0).
Since DTS decodes through a package (10-13), eligibility checks only the channel count, and the package’s speaker mask decides the layout at runtime.
The route tables still drew the device profile’s DTS conditions from those vectors, so stereo DTS other than MA, five channels, and DTS-ES’s seven, which the tables excluded as a profile, transcoded.
Since 10-29 the evidence policy is: a route is a capability vector where an encoder this project can run makes one, and composed from the masks libdcadec reports where only DTS’s own tools could encode it.
FFmpeg’s
dcaencoder writes DTS core: its stereo and 5.0(side) encodes, one tone per channel, decode through thedtspackage to0x3and0x607with every tone in its WAVE place, and the dcadec XCh sample decodes to0x70F, the WAVE 6.1 the engine lays out, its back center apart from the side pair (wgpud/dts/test/DTSPackage.test.ts). DTS-HD MA, HRA, and DTS-ES encoders are DTS’s own, so their stereo, 5-channel, and 7-channel routes are composed: libdcadec reports a lossless or XCh bed’s mask as it reports a core’s, and FFmpeg’s DCA decoder, a port of libdcadec, decodes the field’s DTS 2.1 core, MA 4.1, MA 5.0, DTS-ES 6.1, and MA 6.1 tracks to2.1,4.1(0x10F),5.0(side), and6.1, each a layout the output stage places. The engine gained 4.1 for that: its back center splits across the surround pair, as 6.1’s does; 4.0 (0x107), a back center without an LFE, has no layout, so four-channel DTS transcodes. Every DTS profile is a direct-play profile; DTS-ES, which always extends a 5.1 core, takes no stereo or five-channel route. - Decoded format is authoritative (10-07).
The declared format only screens a track at preparation.
The output stage binds to the first decoded rate and layout and rebinds on a later change: the old resampler’s tail goes through the still-running limiter, and a new resampler continues its output timeline and input expectation.
The worklet format and the output timeline never change, and the route tables are checked against the decoded values.
The worker reports each bound format with
audio-source-format, which the session’s telemetry and ready event prefer over the declaration. Declared formats were wrong in the field:- Mediabunny reported the Matroska SamplingFrequency, which for HE-AAC is the 24 kHz core, until 1.66.0 read OutputSamplingFrequency (11-13), and it detects no implicit SBR;
- in MP4 the AudioSpecificConfig overwrites the sample entry, so HE-AAC declares 24 kHz and, with Parametric Stereo, mono;
- the dec3 parse can declare a 7.1 E-AC-3 track as 6 or 7 channels, though Mediabunny 1.66.0 no longer counts 7.1 as 7 (11-13).
- Timestamps are reconciled within 2 s (10-07).
Two field failures came from containers, not from audio.
One was a TrueHD access unit that decoded to no PCM (833 us) plus 1 ms of Matroska rounding.
The other was a Mediabunny lace artifact: a laced block that is last in its cluster keeps duration 0, so every frame of the lace gets the block’s timestamp although the PCM is correct.
The tolerance is max(codec floor, one container tick) plus an access-unit allowance plus one source sample: the floor is 3 ms for DTS and 1 ms otherwise, and the allowance is 834 us for TrueHD and MLP.
Within tolerance an input is absorbed, and a timestamp at or before the previous one is absorbed as non-advancing.
A gap up to 2 s is filled with silence, and an overlap up to 2 s is trimmed, or dropped when the remainder is within tolerance.
A larger deviation fails the audio attempt as
decode-failed, so playback renegotiates. The first design rebased the expectation instead, which shifted all later audio against video. The audio decode worker logs every correction over 100 ms and every failure with the input timestamp, the expected timestamp, and the correction. Output time stays the anchor plus output frames at 48 kHz, so the bridge, limiter, worklet, and clock see one contiguous stream. Output chunks are capped at 12000 frames, the 2 s ring divided among the 8 credits, so even a filled burst fits the worklet ring. - The FFmpeg wrappers stamped later frames of one packet themselves (10-07). FFmpeg resets the packet timestamp after a partial consume, so the E-AC-3 and TrueHD wrappers stamped a frame without one at the packet time plus the frames the packet already produced. Since they became packages (10-13), the engine does it: a block that reports no timestamp of its own continues from its packet’s earlier blocks.
- A late first audio sample is padded, not followed (10-07).
In two MKV files the video started at 0 s and the first audio block at 6.006 s or about 25.9 s.
Setting the worklet’s media time to the first chunk it rendered made the clock jump to the audio start and drop the earlier video (631 frames for 26.3 s at 23.976 fps).
The worklet renders silence from the flush position to the first chunk’s timestamp.
The native-media route parks
<audio>at its first fragment and delaysplay()by the remaining gap, because Chromium snaps a seek only to buffered data that starts within 1 s, and otherwiseplay()stays pending until startup times out. Hidden tabs fire that timer late, so a timer more than 20 ms late first moves the parked element and its clock baseline forward by the overshoot, within the buffered range, and audio does not trail the clock. Only anAbortErrorcaused by the backend’s own pause, seek, or teardown stays silent; any otherplay()rejection is reported. Mediabunny proves that a track starts after a lookup time only after two scans, so an audio start at or before zero, or after no earlier packet, takes the first packet directly. - A starting audio output holds the clock for its latency (10-15).
The clock started running as soon as the
AudioContextdid, but the first sample reached the speakersbaseLatencyplusoutputLatencylater, so the first correlated report pulled the clock back 72 to 81 ms against a reported 64 to 72 ms on an Intel Arc laptop, and the first frames held for about two frame durations. Uncorrelated reports never move the clock, so the jump came from the clock’s own start, not from them. Wherever an audio output starts, at playback start, resume, an output switch, and an underflow recovery without a correlated report,MediaClock.resumeholds the media time for the output’s estimated latency, at most 500 ms, so video moves when its first sample is heard and the first correlated report corrects only the estimate’s error. Without an estimate the clock holds nothing and that report re-anchors it, as before. - Audio that ends before video is not starvation (10-07).
Treating the final underflow as starvation started an audio wait that fell back with
playback-stalledafter 10 s. The worker postsaudio-endedfor the epoch. The final underflow releases the tail without the end-of-stream steps: video waits stay in force, and an uncorrelated output releases at once instead of pausing the clock for its latency grace, because video carries playback past the tail. Once the tail is out, video starvation holds the clock as it does without audio. The ended track also stands in for the PCM a start, seek, or resync waits for, so a seek past the end of the audio starts. A native-media track ends its MSE stream, so<audio>plays out instead of stalling at its last fragment with the clock frozen. Once the element has ended, the clock runs on andplay()is not called again, because it would restart the element from its earliest position. A hidden page keeps decoding video once the audio ended, and a released decoder restarts at the clock, because the worker ends the run only after the video track itself ended, not when it was suspended or interrupted. - DTS and TrueHD play from ISO BMFF too (10-07).
Mediabunny maps no MP4 DTS or TrueHD sample entry but records it as the internal codec ID, so the worker treats
DTS(QuickTime’s core entry, with a trailing space),dtsc,dtsh, anddtsllikeA_DTSandmlpalikeA_TRUEHD.dtse(LBR) anddtsx(DTS-UHD) are unsupported because libdcadec, which thedtsdecoder project builds, cannot decode them. Two sample-rate fields need recovery. TrueHD writes the rate as a 32-bit integer, which reads back as 16.16. A DTS rate above 65535 Hz does not fit the 16-bit integer part, so muxers write zero, and the worker declares the 48 kHz core until the decoder reports the real rate. Mediabunny 1.61.5 mapsdtsc,dtsh,dtsl, anddtseitself (10-15), so the worker maps onlyDTSandmlpa, and refuses adtsetrack asThe selected audio codec has no decode route: dtse, whichisDTSExpressTracktells from the core entries. Mediabunny 1.65 mapsmlpaand Matroska’sA_TRUEHDtotruehditself, and reads anmlpatrack’s rate and channels from itsdmlpbox (11-04), so the worker maps onlyDTSandA_MLP, and the TrueHD rate recovery left. - The
dtspackage builds libdcadec with SIMD and link-time optimization (10-15). It built at-O3without either, while the FFmpeg audio packages used both, so nothing of libdcadec was vectorized.-msimd128and-fltogrow the module from 268,676 to 332,684 bytes, each about half of that, and it decodes both DTS test vectors, Master Audio 7.1 and core 5.1, to the same samples as before. - The FFmpeg audio packages build for speed, as the
dtspackage does (10-16).eac3andtruehdbuilt at-Oz, which keeps FFmpeg from inlining and unrolling its transforms and bit readers; at-O3they grow from 520,412 to 539,361 and from 315,165 to 326,453 bytes, 3.6% each. A client downloads a package once and keeps it until the server offers another, so the size costs one download, while decoding runs for every second of audio. Both decode Mediabunny’s E-AC-3 and AC-3 test files and the TrueHD test vector to the same samples as before. - Normalization follows the metadata. Jellyfin fills track and album gain for audio libraries, not movies, so video sessions normally use unity gain.
- The resampler and the limiter render in WebAssembly, byte for byte as their JavaScript reference (10-09).
On Node 24 the JavaScript resampler took 15 ms per second of 44.1 kHz stereo and 48 to 58 ms per second of 5.1 and 7.1, and the limiter 24 to 70 ms per second under sustained limiting.
The time went to a bounds-checked call per filter tap, to reallocating and copying the retained history on every push, and to re-deriving the attack envelope over the whole 100 ms horizon for every output chunk.
The
audio-output-stagekit (C, simd128) keeps each history in a ring, sums each output frame’s taps in float64 in tap order with SIMD lanes across channel pairs and neighboring frames, and applies each peak’s attack once, when the peak arrives. That attack order is exact: the reference’s constraint for a frame is the minimum over every peak whose attack reaches it, and its horizon always covers the longest attack. Afterwards 44.1 kHz stereo took 1.6 ms per second, 5.1 and 7.1 4.3 to 4.8 ms, and sustained limiting 6 ms.StreamingAudioResamplerandStreamingAudioLookaheadLimiterstay as the reference and the fallback, andtest/audio/processing/AudioOutputStageModule.integration.test.tsrequires the same chunks, timestamps, sample bytes, errors, and telemetry from both. It covers randomized timelines from 8 kHz to 768 kHz with fills, trims, drops, and rejections, one to eight channels, pushes from one frame to the 65536-frame protocol maximum, rate changes with continuation, finalize tails, and the limiter quiet, loud, and beside its ceiling. Exactness needs the filter table and theMath.powandMath.expconstants computed in JavaScript,Math.log10imported, no fused multiply-add, the reference’s order of float64 operations, float32 rounding on store, andMath.roundreproduced for the non-negative values it sees. NaN payload bits are implementation-defined in ECMAScript and WebAssembly alike, so the test requires NaN in the same samples, though on V8 x64 its bits matched too. A 48 kHz source only passes through, so it keeps the JavaScript path, which a staging copy into the module would only slow. The downmix and its live settings ramps stay in JavaScript: they cost little, and moving every algorithm would add risk without gain. Timeline reconciliation, timestamps, and output chunking stay in TypeScript too, so both paths share them. The audio decode worker loads the module once, on its first attempt. If it does not load or instantiate, that worker warns once and every output stage renders through the reference, and a kernel the module cannot allocate falls back the same way, so playback never fails for it. Each attempt frees its kernels when it ends, also when a seek or a resync stops it early, because the worker outlives its runs. - Decoded PCM goes from the worker straight to the worklet (10-09).
The page used to receive every chunk as an
'audio'message and post it on to the worklet, so decoded audio waited on the page’s main thread. The page now creates the channel, hands the producer’s end over withattach-audio-outputorresync-audio, and learns each chunk’s place and length fromaudio-progress, which readiness, telemetry, and the end-of-stream drain read.WorkletPCMProducerowns the credit window and checks each chunk as the page’s bridge did, so a gap, an overlap, or an overflow still fails asaudio-output-failed. The worklet returns each played chunk’s buffers, andPCMChannelPoollends them to the output stage for later chunks. - The worklet computes signal statistics only when a host shows them (10-15).
Its peak, RMS, clipping, and non-finite counts took a finite check, an absolute value, a maximum, and a square for every output sample on the audio rendering thread, and only the sandbox’s diagnostics read them.
createBrowserCustomAudioOutputFactorytakes{ analyzeSignal: true }to turn them on; every output sets its choice on each worklet it leases, since the pool hands one node to the next session, and telemetry carriessignalonly while they are on. - An audio decode worker decodes and renders decoded PCM (10-09).
The worker spawns
CustomAudioDecode.worker.jsat its first decoded PCM start and keeps it for its life; terminating the worker terminates it too. It runs audio decoder packages (the bundled E-AC-3, DTS, and TrueHD decoders until 10-13), the downmix, the output stage, and the producer, so video work in the worker never delays audio, and audio decoding never delays video. Demux stays in the worker, which reads the source once for both tracks. The worker sends compressed packets copied back to back, or Mediabunny’s decoded samples cut to the start, in batches that close at 40 ms of media time or 64 inputs, so TrueHD’s 1200 access units a second do not each cost a message. 4 batches are in flight, and each returns its credit once rendered under the producer’s window, so demux runs at most a few batches ahead. Progress, the decoded format, an attempt’s end, and failures return through the worker, which keeps their order withaudio-endedandstoppedon its one channel to the page. A run’sstoppedwaits until the audio decode worker released the run’s attempts. Failures keep their kinds, and a lost audio decode worker fails the run asaudio-output-failed, which falls back to HTML in the same session; the next decoded PCM run spawns another. Mediabunny’s own decoding stays in the worker: WebCodecs decodes off the worker’s thread anyway, and@mediabunny/ac3exposed only its Mediabunny registration until AC-3 became a package codec (10-13). - Audio decoders are packages (10-13).
The E-AC-3 and AC-3, TrueHD and MLP, and DTS decoders left the engine and became decoder projects (
eac3,truehd, anddtsinwgpud/), which build packages a host supplies;@mediabunny/ac3left too. The order is the MSE bridge, then the engine’s own decoders (WebCodecs and Mediabunny’s PCM), then the first package whose manifest accepts the codec, so AC-3 without MSE needs a package. Eligibility checks only the codec’s input channel counts and rate, because a package reports every block’s speaker mask, which the engine checks before it mixes, and the decoded format is authoritative anyway. The profile, rate, and layout rules that gated the bundled decoders on their qualification vectors therefore no longer apply: a stream the package cannot decode, such as DTS-UHD, fails its attempt asdecode-failedand renegotiates. A package may instead drop every packet without an error, as thetruehdpackage drops each packet FFmpeg’s decoder refuses as invalid data, so the attempt waited for its first PCM until the 20 s startup bound (10-29).PackageSoftwareAudioDecodernow fails the attempt asdecode-failedonce the packets the package dropped since its last output span 2 s of media; a packet before a sync point counts as no drop, since a stream may take that long to reach one, and a package that drops a few packets and then decodes plays on. Every start and seek runs on a new instance of the package’s module, so a decoder never sees a jump in its packets. - Audio packages take codec private data, and WMA plays from Matroska, AVI, and ASF (10-28).
FFmpeg’s WMA decoders need the stream’s WAVEFORMATEX before they open, as
ff_get_wav_headerhands its rate, channels, average bytes per second, block alignment, and extradata to them, while the AC-3, E-AC-3, DTS, MLP, and TrueHD decoders find everything in their packets. So the decode worker reads a track’s codec private data with the track and sends it with the package selection in each attempt’s open request, andPackageSoftwareAudioDecodercallsaudio_decoder_configureonce before the attempt’s first packet; the codecs that need none still get no call. Thewmapackage’s configure parses the WAVEFORMATEX, refuses a short header, a format tag other than its codec’s, or a failed open with named errors, and opens the decoder; a send before it fails asunconfigured. The contract is the whole WAVEFORMATEX, header and cbSize bytes, rather than the extradata alone, so the package sees the block alignment and bit rate FFmpeg’s WMA decoders read, and a WAVE_FORMAT_EXTENSIBLE header names its codec by its SubFormat asriffdec.creads it. Mediabunny maps noA_MS/ACMtrack and no WMA format tag, so the worker reads the WAVEFORMATEX itself: a Matroska track’s CodecPrivate, or, from the fork’s parsedstrffields, an AVI stream’s, written back with a SubFormat on the media subtype base. The fork’s AVI demuxer dropped an audio stream whose format tag it maps no codec to; it now keeps it codec-less with its tag as its codec ID, as it keeps a video stream of an unknown FourCC, which also keeps the engine’s audio ordinals in step with Jellyfin’s streams in such a file. Its packet times already match FFmpeg’savidec.cfor WMA: FFmpeg’s AVI muxer writes the frame size as the scale, the rate as the rate, and the block alignment as the sample size, and both demuxers time each block as one sample, which ffprobe’s packet times of an FFmpeg WMA v2 AVI confirm. FFmpeg’s WMA decoders hold frames back until their input ends, 8192 frames for 24-bit WMA Lossless, so the interface gained an optionalaudio_decoder_drainexport, which the audio decode worker calls once at the end of a track before the output stage’s tails; a module without it ends at its last packet’s blocks, so the other packages are unchanged. The fork ignored Matroska’s CodecDelay for every codec until 11-04, and the engine left it so: FFmpeg’s WMA v1 and v2 decoders skip their own 4096-frame delay, while FFmpeg’s Matroska demuxer replaces that skip with the track’s CodecDelay, 2048 frames in FFmpeg’s own WMA Matroska encodes, so such a file played each frame 2048 frames later than FFmpeg plays it. Since the fork took Mediabunny 1.65 (11-04), its Matroska demuxer subtracts a track’s CodecDelay from the track’s timestamps, as FFmpeg’s demuxer does, so such a file plays each frame at FFmpeg’s time, and only its first 2048 frames, which the package still skips as its decoder’s own delay, are missing; an AVI or a Matroska file without a CodecDelay plays as FFmpeg does. ASF, where WMA usually lives, had no demuxer in Mediabunny or the fork when this entry was written; the fork’s ASF demuxer now reads it, and the worker reads an ASF stream’s WAVEFORMATEX as it reads an AVI stream’s (see “ASF plays through an ASF demuxer in the fork”). - AAC, Opus, FLAC, and Vorbis play 7.1, each on its own 7.1 probe (10-28).
Chrome 156 on Windows decodes 8-channel AAC-LC, Opus with mapping family 1, FLAC, and Vorbis from Matroska through Mediabunny’s decoder configuration into 8-channel
AudioDatawhose planes are in WAVE 7.1 order, FL, FR, FC, LFE, BL, BR, SL, SR; AAC and Vorbis outputf32-planar, Opusf32, and FLACs32. A silence vector cannot show plane order, so each codec’s 7.1 vector carries one sine per source channel (400, 600, 800, 60, 1000, 1200, 1400, and 1600 Hz at amplitude 0.25), and the probe measures every decoded plane over a 2048-frame Hann window centered in the decoded stream with a Goertzel filter at each tone. A plane passes when its own tone keeps at least 0.125, or 0.025 for the LFE, which a lossy encoder may attenuate, and is at least 10 times (20 dB) stronger than every other channel’s tone; so Vorbis or Opus channel order, 7.1(wide), six channels, and silence all fail, which tests and a real-decoder run with permuted expectations confirm. In Chrome 156 every own tone was 30 or more times stronger than any other, the weakest being AAC’s, whose encode at 512 kbit/s keeps that margin where 384 kbit/s left a back-to-side leak of only 4 times; the decoded LFE kept its full amplitude. The 7.1 verdicts are a probe of their own,native-seven-point-one-audio, kept beside the 5.1 ones innativeSevenPointOneAudio, so a stored entry written before it holds no verdict under its name and a page probes only 7.1 again. In the sandbox on Chrome 156 with a stereo destination, 8-channel Matroska files of each codec with the same tones played the decoded PCM route at 8 channels in and 2 out, at -11.1 dBFS RMS, the level the default Lo/Ro downmix gives these tones through the 7.1 layout. FFmpeg’s encoder writes 7.1 AAC as AudioSpecificConfig channelConfiguration 7, which FFmpeg’s decoder, and Chrome’s with it, reads as 7.1 with back and side pairs (libavcodec/aac/aacdec.c), not the specification’s 7.1(wide), and Jellyfin reports either as7.1; the probe proves the order Chrome gives FFmpeg’s encode. Mediabunny’s PCM route stays at 5.1 and 6.1 sources stay unsupported, since no vector covers them. - Height output layouts, a speaker layout choice, and binaural headphones (10-28).
Atmos objects render in the packages, so the engine qualifies FFmpeg’s 5.1.2 (
0x503F), 5.1.4 (0x2D03F), 7.1.2 (0x563F), and 7.1.4 (0x2D63F) as decoded and output layouts of 8, 10, and 12 channels in WAVE order; 5.1.2 and 7.1 share 8 channels, so the pipeline carries a layout name, not a count. A height the output lacks folds into the floor pair beneath it, and every channel a speaker sums takes 1/sqrt(n) of n, sqrt(1/2) for a pair as the existing folds, which keeps the power of content common to them; a height layout mixes to stereo through its folded floor and the selected algorithm. The host’s setting,autoor an explicit layout, resolves on the destination’s channel count; a source a package renders as objects plays the whole layout and every other source keeps the floor rule, so beds never spread onto heights. Binaural is the host’s opt-in, applies only on a stereo output, and switches live without rebuilding the output. An Atmos-profiled E-AC-3 or TrueHD stream prefers a rendering package over the browser’s MSE decoder only when the target needs objects, so 5.1 and stereo outputs keep the native route; the add-on fetches the server’s packages for such a track before it settles for the native route (playbackAudioNeedsObjectRendering). FFmpeg’s stock E-AC-3 decode of a dependent stream that adds heights reports 5.1(side) with the top front pair,0x560F, which is qualified as a decoded layout and folds like 5.1.2. While a package renders, a live switch judges the source by its declared channels, since the blocks carry the output layout or binaural stereo; a binaural change made while a switch builds its output reaches the resync it posts. - TrueHD Dolby Atmos objects decode with our own decoder of the 16-channel presentation (10-28).
FFmpeg’s TrueHD decoder stops at the 8-channel presentation:
max_decoded_substreamcaps at substream 2,MAX_CHANNELSis 8, and its restart header refuses the0x31ECsync word of substream 3, which carries the 16-channel presentation. Depending on the truehd crate of truehdd, or on any other Atmos library, was ruled out, and stock FFmpeg stays unpatched, sowgpud/truehd/truehd_presentation.cports FFmpeg’smlpdec.c,mlpdsp.c,mlp.c, andmlp_parse.cinto one LGPL-2.1-or-later file and generalizes them to 16 channels and 4 substreams. What FFmpeg lacks, the0x31ECmatrix syntax with its interpolated coefficient deltas, the substreams of each presentation, the extra channel meaning, and the Evolution frame that carries object audio metadata, is documented only by the truehd crate (Apache-2.0), which was read to learn the layouts and written anew; the file’s header anddecoders.mdcite it, and the Evolution payloads follow the EMDF payload syntax of ETSI TS 102 366 Annex H. FFmpeg keeps its decode wherever it decodes: the package outputs FFmpeg’s presentation unless the target needs objects, binaural rendering or a speaker that presentation lacks, so MLP, TrueHD without Atmos, and Atmos at a covered target play exactly as before, which the package tests check against the TrueHD qualification fingerprints at every target. truehdd at commit77ddc52, built natively, is the black-box oracle: the port decodes the 16-channel presentations offba_atmos_obj.mlp(5600 frames of 16 channels),fba_atmos_cbi.mlp(5440 of 16), andfba_atmos_dimtrim.mlp(16000 of 12) bit for bit as truehdd does, and every other presentation of the crate’s eight TrueHD streams and the engine’s three TrueHD vectors too. Against FFmpeg 862338fe31, presentations 0 and 2 of all eleven streams and presentation 1 wherever FFmpeg’sdownmixoption selects it are bit-exact up to FFmpeg’s channel order; two streams whose presentation 1 is a stereo downmix it cannot select were compared with truehdd only. Every restart header’s lossless check passed. FFmpeg keeps its prediction filter state across a restart header where the truehd crate clears it; the two agree on all these streams, and the port keeps FFmpeg’s. The object metadata parser inwgpud/atmos/reads the 15 payloads of the two object streams, and the 39 metadata events truehdd writes for them match its values: positions within 3.2e-7 of a room unit, gains, ramps of 1536 samples, and active flags, with a payload’s timing counted from its access unit’s first sample. The 16 channels are the payload’s objects in order, beds first, as the crate’s object presentation lays them out; a presentation without metadata, such asfba_atmos_cbi.mlp, is the bed its major sync’s 16-channel channel assignment names. A packet renders only when all its access units decoded and rendered, or plays FFmpeg’s decode, so a switch to a target that needs objects renders from the first packet after a major sync and object metadata, every 128 access units and 1536 samples in these streams, and a switch away returns to FFmpeg’s decode at once. In Chrome 156 on Windows, the rebuilt package underPackageSoftwareAudioDecoder, fed one access unit per Matroska block of the looped object and bed streams, rendered binaural stereo at mask 0x3 withobjectCount15 and 11 for the object streams and 0 for the bed, drained its 128-frame tail, and played through a 2-channelAudioContext; at 7.1.4 it rendered mask 0x2D63F, and the bed stream with binaural off played FFmpeg’s 7.1 at mask 0x63F. Decoding and rendering took 67 to 154 ms for 4.5 to 5 seconds of audio. - A single-substream TrueHD stream FFmpeg refuses decodes with our presentation decoder (10-29).
A library run found a TrueHD 5.1 track that neither the package nor the server could play: its major sync names one substream, which carries all six channels and the 6-channel presentation, with no separate 2-channel substream.
FFmpeg’s
read_major_syncnames the channels of a single substream only when the stream is stereo or mono, so every restart header failed its channel assignment, the package dropped every packet, the attempt failed asdecode-failed, and the server’s transcode, through the same FFmpeg decoder, produced no audio either. Stock FFmpeg stays unpatched, as above, sowgpud/truehd/truehd_substream.cdecodes such a stream withtruehd_presentation.c, whose presentation 0 is the stream’s one substream, from each major sync that describes one until a major sync describes a stream FFmpeg decodes. The decoded channels follow the TrueHD order of the major sync’s 8- or 6-channel arrangement with that many channels, and the block reorders them into that arrangement’s WAVE order and mask, as FFmpeg’s output layout orders a stream it decodes. On 12 s of the library track, the port decoded all 14,624 access units with every lossless check passing, and its six channels matched the same title’s E-AC-3 5.1 track, channel for channel, at correlations of 0.94 for the LFE and 0.998 or more for the rest. FFmpeg’s TrueHD encoder writes multichannel as two substreams, so the vectorsingle-substream-5.1.thdcomes from it with three lines changed (itsPROVENANCE.txt); the package decodes it to the encoded samples exactly. - E-AC-3 Dolby Atmos objects decode with our own joint object coding decoder (10-28).
FFmpeg’s E-AC-3 decoder reads only the Atmos flag in
addbsiand outputs the 5.1 or 7.1 core; the objects need the EMDF payloads and the JOC upmix of ETSI TS 103 420 V1.2.1, which no FFmpeg decoder has. Depending on OpenJOC, or on any other Atmos library, was ruled out, and stock FFmpeg stays unpatched, sowgpud/eac3/adds its own EMDF reader, JOC decoder, and filter bank (MIT), written from TS 103 420 clauses 6 to 8, Annex A, and its companion archivets_103420v010201p0.zip(Huffman trees and filter window), and TS 102 366 V1.4.1 Annex H (EMDF). OpenJOC (Apache-2.0) was read to learn where Dolby’s encoder writes the container, an audio block’s skip field rather than the frame-end auxiliary data, and how JOC objects pair with OAMD objects: the scene’s objects in payload order, the LFE taken from the core. Reaching a skip field exactly takes the audio block syntax up to it, mantissas included for later blocks, which would duplicate most of FFmpeg’s decoder, so the bridge finds the EMDF syncword with a validskiple,skipl, and complete container instead; on Dolby’s stream it found the container at the same bit as OpenJOC’s block parse in all 64 access units. Two texts of TS 103 420 contradict themselves, and the code follows what reconstructs: synthesis uses the matrix equation’s exponent (2j - 255), since pseudocode 14’s (2j - 129) does not invert the analysis, while the equation’s reconstructs white noise at 78 dB after 577 samples; steep interpolation maps subbands to parameter bands, as pseudocode 6’s smooth branch does. Sparse coding follows pseudocode 2 literally, unselected channels at the offset value, as OpenJOC does. OpenJOC at commite3e330a, built natively, is the black-box oracle: on the same FFmpeg downmix and payloads, the objects match its decoder within 6e-8 of full scale, 129 dB or more per object, over all 64 access units and 15 objects of androidx/media’ssample_eac3joc.ec3, and within 4e-10 on its synthetic fixtures with 5- and 7-channel downmixes, sparse and dense coding, and one or two data points. OpenJOC’s own OAMD parser refuses the Dolby stream, so its positions could not serve as an oracle there; the shared parser reads a 5.1 bed and 10 dynamic objects. The filter bank delays objects by 577 samples; the bridge holds each block until later frames fill that delay and the binaural latency, rather than shifting timestamps by a fraction of a microsecond, so blocks keep their packets’ timestamps and the optional drain outputs the last one. In Chrome 156 on Windows, the sandbox played the Dolby stream looped under H.264 in MP4 at the 2-channel destination: binaural off reported 6 channels to stereo and 0 objects, the stock decode; binaural on reported 2 channels and 10 objects, without underruns or non-finite samples. Under Node the package decodes and renders 0.512 s of the stream at 7.1.4 in 12 ms. - Atmos objects render with our own OAMD parser and renderer, shared by both packages (10-28).
Depending on OpenJOC’s renderer, Cavern, the truehd crate, or any other Atmos library was ruled out, so
wgpud/atmos/(MIT) parses object audio metadata from ETSI TS 103 420 V1.2.1 clause 5 and renders from Recommendations ITU-R BS.2051 and BS.2127; theeac3andtruehdmodules compile the same sources throughATMOS_MODULE_SOURCES, so both formats place and pan objects alike. TS 103 420 contradicts itself once: clause 5.5.11 testsobj_render_info[0]for the position, while Table 31 gives the position[3], the last of the four bits. The parser follows the table: every other flag array of the payload carries the flag of its first-parsed field last, which Dolby’s DD+ JOC stream and TrueHD streams confirm forcontent_description[]and the bed assignments, and the AC-4 form of the syntax (TS 103 190-2 clause 6.2.8.7) readsb_obj_render_position_presentlast; truehdd’soamdcrate (Apache-2.0), read to settle this and the place of a dynamic-object program’s LFE (first, as a bed object), agrees. OpenJOC’s synthetic fixtures write that flag first and fail to parse; they come from OpenJOC’s own encoder, not Dolby’s. On Dolby’ssample_eac3joc.ec3from androidx/media the parser reads a 5.1 bed and 10 objects, six of them on the right wall and four of those raised; the stream’s JOC upmix rebuilds each of those six from the core’s R and Rs alone (cross-correlation with L, C, and Ls under 0.001), so the layout is the content’s, and the left heights stay silent there by design. The panner is room-centric and separable, the allocentric approach of BS.2127 for Cartesian positions: OAMD positions are coordinates in the room’s cuboid, not directions, so panning between layers, rows, and speakers by the walls keeps an object where the mix placed it on every layout, puts an object at a speaker on that speaker alone, preserves power by construction with the sine and cosine law, and needs no triangulation per layout, which VBAP would. Not rendered: trim, divergence, and ISF objects (a scene with them is refused); a screen-anchored position plays as the room position it codes. Ramps interpolate an object’s properties, with gain vectors every 32 samples and linear gains in between, so an update’s values arrive at exactly its start plus its ramp duration whatever the calls’ lengths, and 16 continuously moving objects cost little: under Node, 10 s of 16 objects plus an LFE at 7.1.4 render in 36 ms, or 231 ms when every object also has a size, and binaurally in 68 ms. The binaural set is MIT’s KEMAR measurements (Gardner and Martin, 1994), diffuse-field equalized: their terms allow any use with citation, so the table can ship inside the LGPL-2.1-or-later packages without adding license terms of its own. The virtual heights take KEMAR’s 40 degree elevation, the only one of its elevations from 30 to 50 degrees that measures azimuths 45 and 135 exactly, as BS.2051’s 4+7+0 upper layer places them. Uniformly partitioned convolution in 128-sample blocks delays binaural output by 128 samples, which the renderer reports and the packages drain; zero latency would take a direct 128-tap head for 22 responses, about 135 million multiply-adds a second. The LFE enters both ears at 0.5, the LFE level of the engine’s Dave750 matrix, since the default Lo/Ro omits the LFE and headphones would otherwise lose it. In Chrome 156 on Windows, the test module rendered a noise object circling the room in 2 s: the level difference between the ears turned from right (-11 to -14 dB binaural, -19.6 dB stereo) to left (+12 to +14 dB, +19.6 dB) as the object passed the sides, in 13 ms binaurally and 7 ms in stereo, and played through a 2-channelAudioContext.
Passes
- mpv user shaders convert through naga (10-16).
tools/convert_mpv_shader.pyparses the//!headers itself and hands each pass’s GLSL to naga-cli 30.0.1 inside a wrapper of stubs for the mpv shader API, then binds the stubs to the pass API, rather than translating GLSL with a parser of its own. The version is pinned, since the converter repairs that version’s GLSL frontend: it reverses adjacent array dimensions, reads a for-loop condition that opens with a constructor as a declaration, lowersbarrier()with asubgroupBarrier()WebGPU lacks by default, rejects a function that can end without a return,dot()of scalars, and a swizzle of a scalar, and cannot fold a ternary in a globalconst. Every converted pass is validated with naga before the file is written. 71 user shaders were checked in development: Anime4K’s, bjin’s ravu and nnedi3 variants, FSRCNNX, KrigBilateral, SSimSuperRes, SSimDownscaler, adaptive-sharpen, FSR, CAS, NIS, and SGSR, then the Chroma from Luma prediction shaders. 70 of the 71 converted, as did both Chroma from Luma shaders, and all of their passes planned and compiled under naga in the planner’s modules on the raw, frame-pool, external, and deinterlacing routes; NVScaler’s shared memory exceeds the limits below. ravu-zoom converted onceTEXTUREblocks became texture blocks of the pass file. - The engine keeps WebGPU’s default compute limits, and a shader that exceeds them is modified to fit (10-16).
Requesting an adapter’s larger limits would make a pass run on some devices and not others, so the
nisproject converts NVScaler at a 32x16 block instead of 32x24, which takes 16208 bytes of workgroup storage instead of 21776 and keeps every thread’s work otherwise as the port wrote it. The upstream files stay unmodified, and the generator changes the two lines in memory. - Plane passes run on the signal’s planes (10-16).
mpv’s
LUMA,CHROMA, andNATIVEhooks see the decoded planes before conversion, which only the signal step reads, so the spine starts with plane stages of those names, and the converter maps the hooks onto them. Deriving planes from the RGB image, as the converter first did, gaveCHROMAthe image’s size, so a chroma upscaler, whoseWHENasks for subsampled chroma, never ran, and it could not read native chroma anyway.CHROMA_SCALEDstill reads the RGB image, since the engine never holds chroma apart at the image’s size, through the prelude’s transform of the source’s own matrix and range (10-16). - The signal converts what plane passes leave (10-16). The fixed signal step is decode-equivalent, so the merge that converts the planes is built from the signal’s own functions: its plane reads, its chroma siting, and its range and matrix. It reads each plane no pass replaced straight from the source, so a chroma upscaler leaves luma exactly as decoded, and a plan without plane passes is the plan it always was. The alternative, converting the passes’ chroma into the RGB image in the converter, would need the source’s matrix and range in every converted pass.
- Offsets follow libplacebo’s texture rects, and no read is shifted (10-16).
libplacebo binds every texture whole, so
NAME_posis the output’s coordinate whatever the texture’s rect, and onlyOFFSET ALIGNmoves the hooked texture’s position onto its rect; the rects decide where each result lands, which the plane merge and the final scaler apply. Folding each input’s offset into its accessors would shift reads that a pass computes itself: KrigBilateral’s upscaling pass readsLOWRES_Yat chroma texel centers it derives from the alignedCHROMA_pos, which a folded read would move between texels. So the engine gives each input its offset as<P>_offset(), as mpv’sNAME_off, and places outputs as libplacebo does. - Routes without decoded planes derive them from RGB through the conversion that made it (10-16).
An external route imports a frame the browser already converted, so its planes come back through the stream’s own matrix and range, at its bit depth, from its color metadata, which the merge inverts exactly; the identity route therefore carries the stream’s metadata, and has no planes while it does not know it.
On the external HDR route the engine itself labels the stream BT.709 for the browser, so its planes are the codes its signal recovers from that labeling, which match the signal’s own codes exactly; a BT.709 assumption on every route gave a BT.601 or BT.2020 source the wrong luma and chroma.
Preferring a plane route for a preset that needs planes would make a preset change the route.
The Dolby Vision routes offer no planes, since reconstruction maps each pixel’s base-layer codes, so they leave plane passes out as
planes-unavailable. - An external route’s chroma takes the stream’s subsampling from its decoder configuration (10-16). The decode worker reads it from the track’s avcC (its closing chroma format, else its first SPS), hvcC, or av1C, the SPS in its first key packet where it has no record or its record no SPS, or the AV1, VP9, or VP8 codecs string, and every frame carries it, so the presenter plans with it without a host fact. The avcC’s closing bytes count as the High profile extension only when all four are present and the reserved bits of its chroma format and both bit depths are set (10-29); some Matroska muxers end the record with four other bytes, whose low bits could read as 4:4:4, so such a record takes its SPS’s chroma format. Derived chroma is read at each chroma sample’s site, as the stream’s location places it, so a chroma upscaler runs on the external routes too.
- Chroma is read where the stream declares it lies (10-16), revising the engine’s fixed siting, which read every planar format’s subsampled chroma left and NV12’s at its texel centers.
4:2:0 chroma takes the location of H.273’s six types that an H.264 or HEVC SPS’s VUI declares, or an av1C’s or AV1 codecs string’s chroma_sample_position, and the left siting H.264 and HEVC default to where none is declared; UHD HDR encodes declare top-left, which the fixed siting read half a luma row off.
The SPS is the first of the avcC or hvcC, else the first base-layer SPS among the first key packet’s NAL units (10-27): MPEG-TS, M2TS, and Annex B AVI tracks have no record, and an avc3 or hev1 track’s record may carry no SPS, so their location once kept the default.
The worker reads that packet only for such a track, before its ready response, Annex B where the track has no record and length-prefixed where it has one.
MPEG-1 is center-sited 4:2:0, as FFmpeg’s
mpeg12dec.csets it for every MPEG-1 stream, which has no field that declares a location (10-27). 4:2:2 chroma stays on its left luma column, which H.264 and HEVC fix whatever the VUI says, and 4:4:4 chroma on its luma samples. VP9’s codecs string is not read for a location: muxers write its colocated 4:2:0 value for chroma of unknown siting, as FFmpeg does unless its source is known left, and Mediabunny names WebM’s that way, so the value does not declare one. The HEVC location first came from the bounded SPS parser, so a stream outside its progressive Main and Main 10 bounds kept the default; since 10-27 the parser also reads any profile’s SPS through its VUI without those bounds, as FFmpeg’shevc/ps.creads range extensions, Main 12, and field sequences, so those streams declare theirs too. A deinterlacing plan’s field read keeps MPEG-2’s vertical field siting, which interlaced sources take, and the location’s horizontal shift. The worker’s ready response names the stream’s chroma and the session attaches it to every frame, the frame pool feed to its frames, so every output mode carries it; the presenter plans again when it changes, preset or not, and a plan that does not change keeps its executor. The frame pool route’s chain now builds its signal from the GPU decoder’s output format, as the raw route’s does, so its signal takes the location too. Read back that way, the browser’s chroma is exact across, since a bilinear upsampler passes through the samples it interpolates, and down it is the 4:2:0 rows’ [1, 6, 1] / 8 blur under bilinear upsampling: exact for chroma that varies linearly, about 0.0014 RMS of the plane for smooth chroma, a third of an 8-bit code, and 0.09 RMS for chroma that changes every row; the browser’s own upsampler and its rounding to 8-bit RGB are unknowns on top. - A deinterlacing plan derives its planes from the deinterlaced image (10-16). Plane passes once left a deinterlacing plan out, since the deinterlacer works on the signal’s RGB, after the planes, so luma shaders that ran on interlaced sources through RGB-derived planes stopped running there. The planes now come from the deinterlaced image through the signal’s transform, as mpv’s shaders see the planes of a deinterlacing filter’s output, and no pass after the deinterlacer reads the source, so a held field still draws from history alone.
- The planes hold the frame’s samples (10-16), revising their first placement on the source’s display grid.
Plane sizes derive from the frame’s visible sample size, a plan fact (
PlanFacts.sampleWidthandsampleHeight, theluma.widthandluma.heightfacts) that the presenter follows per frame and plans again on, so an anamorphic stream’s planes keep its samples, as mpv’s do, and the scaler takes the merged image to the display’s shape. - Chroma’s rect is luma’s over the subsampling divisor (10-16). libplacebo keeps a subsampled plane’s rect at the source rect over the divisor, so an odd luma width leaves the chroma texture’s last texel half outside the rect and chroma’s scale exactly half luma’s. The engine took the texture’s extent as the rect, which moved chroma by up to half a chroma texel across the frame, so each plane image now carries an extent: the chroma read covers whole texels, the merge reads chroma through extent over size, and a pass’s output extent scales as libplacebo scales a rect.
- mpv’s linear light is 1.0 at reference white, which the engine gives passes (10-16).
The engine’s linear light is in nits, so a
LINEARorSIGMOIDpass divides by the prelude’sreferenceWhiteNits(), the configured SDR white for an SDR source and BT.2408’s 203 nits for HDR, and multiplies its result back; it was an SDR pass over a fixed 100 nits and an HDR pass over 203, whichhdr.sourcechose. The prelude’slinearize()anddelinearize()compile from the built-in transfer decode’s own code, renamed apart from it, and its exact inverse, so they are the source’s transfer rather than the gamma 2.4 the converter once stood in. They and the plane transform are defined only in modules whose bodies call them, so no other module grows. - Pass files carry texture blocks (10-16), replacing the converter’s constant arrays of at most 4096 texels.
A host stores one file per pass, so the texture travels inside it, in base64, and the passes that bind it read it through accessors with its filter and edge mode.
The engine uploads a block when a plan first binds it, keyed by a content identity of its size, format, and data, which the parse computes synchronously since a plan needs the identity; uploads live until neither the installed plan nor the preset holds them, with no count or LRU limit.
The identity is cyrb53, a 53-bit non-cryptographic hash, rather than a SHA-256:
crypto.subtledigests asynchronously, a hand-written SHA-256 was code to keep for a cache key, and two of a preset’s few blocks collide with a chance of about one in 2^53. A one-dimensional block is a texture one texel high, since WGSL samples a 1D texture only in fragment code, and a block beyond the device’s texture size, or a linear filter on a format it cannot filter, leaves its passes out with a code of its own. - A pass’s
whenreads its own parameters (10-16). The plan request carries the current values, and the presenter plans again when a parameter somewhenreads changes; the executor of a plan that comes out the same keeps running, and every other parameter stays a uniform write. The converter once fixed parameter defaults intowhen, or ran a parameterWHENevery frame as a guard that presented the image unchanged; awhenthat reads the parameter replaces both. constparameters compile into the plan (10-16). A WGSL override constant was the alternative, but the executor builds every pipeline of a plan anyway, and aconstmay also size an array or fold into other constants, so a change builds a new plan whose modules hold the new value. The converter declares an mpvCONSTANTas a GLSL global, which naga reads by name, unless the code needs a constant expression of it; such a constant naga may fold, which a second translation at another value detects, and a folded one keeps its default.- A
DEFINEparameter splits its pass (10-16). ADEFINEsteers the preprocessor, so no single translation serves every value; the converter translates one pass per value of theDEFINEparameters a pass’s code reads, each underwhen: <name> == <value>, up to 16 passes, past which the parameters keep their defaults. rgb.sourcetells RGB sources apart (10-16). mpv firesRGBon RGB sources andLUMAandCHROMAon YUV ones, so the converter keeps both copies of a pass that hooks both, each under the fact, rather than droppingRGB; every route presents YUV, so the fact is 0 on each.- Converted compute passes keep WebGPU’s default limits (10-16), as the pass header does: 256 invocations per workgroup and 16 KiB of workgroup storage.
- Smooth motion is a user pass that draws at the display rate (10-27),
wgpup/smooth-motion/, rather than a built-in step. It blends the two frames a refresh spans by the share each covers, as libplacebo’soversampledoes. It blends atOUTPUTin thedisplaydomain, linearizing the sRGB encoding, so each frame is tone mapped with its own metadata, and identity SDR routes, whose color track is empty, keep it. Frames are taken one refresh ahead, so the frame that starts within a refresh is in history when it draws. The display rate is a least-squares fit of rAF times over refresh counts, published with 0.1% hysteresis so jitter does not replan a condition that reads it. Whether frames blend is judged on every draw, from the refreshes a frame lasts at the clock’s rate, the media time between the recent frames over the media time a refresh covers, rather than by awhencondition at plan time, which judged once from the declared frame rate and the display rate, missed a playback rate, and kept the pass out on a whole multiple; a playback rate, a new frame rate, or another display now takes effect within frames (10-27). The media time between frames is a least-squares line through the frames’ timestamps over frame counts rather than a mean of their durations (10-27): Matroska’s millisecond timestamps put a mean of 16 durations of 24 fps video up to 0.2% off, which turned blending on and off at 144 Hz, where the line holds 6 refreshes per frame to 0.02%.tolerancedefaults to 0.2%, so a 1000/1001 mismatch, which repeats a frame only every 16.7 s at 60 Hz, counts as no judder. A deinterlacing plan leaves it out, since its history holds fields rather than the frame after the shown one. Redraws start at the first display-rate pass, so a heavy pass before it, such as Anime4K, runs once per frame.
Repository
- The engine is its own repository (10-02): WebGPU Player, MIT, with each vendored library under its own license.
Hosts check it out as a git submodule and import it as
webgpu-player/*. Its only host is the Jellyfin plugin’s add-on. - The decoder and pass projects are repositories of their own, and the engine installs their pinned releases (11-02).
The decoder projects are webgpu-player-decoders and the pass projects webgpu-player-passes, checked out as submodules at their old paths,
wgpud/andwgpup/, so the engine’s tooling, tests, and paths stay as they were. The engine no longer builds decoder packages or copies pass files:tools/pinned-releases.jsonpins one release of each, file by file with its SHA-256, andscripts/pinned-releases.mjsinstalls those files intobin/wgpud/andbin/wgpup/, so a checkout needs no decoder toolchain. The decoders release workflow builds in an engine checkout with the engine’s own steps, since the projects build only with the engine’s tools: the Makefiles read the engine’s layout and its pinned codec sources invendor/, and the package build loads every package with the engine’s loader; the passes release workflow only collects the pass files. A release’s files are pinned by SHA-256 because they come from outside the checkout, unlike the engine’s own assets, which typed names replaced hash pins for (10-02).libaom-PATENTSwas dropped from the decoders repository’slicenses/, and never entered its history. Each repository has a short README that points to this book, and a LICENSE, a copy of the engine’s. - Mediabunny comes from the user’s fork, vendored as a submodule and aliased, not installed (10-15).
The fork, alchemyyy/mediabunny, is Mediabunny 1.61.5 with six upstream pull requests merged, each in a merge commit named after it with its authors’ commits kept: #155 (AVI), #156 (MPEG-4 Part 2), #298 (MP4 chapters), #409 (AIFF), #443 (ASS subtitle muxing), and #495 (
getUnsupportedFeatures). On top of them the fork times AVI audio chunks as FFmpeg’sget_durationdoes, maps WAVE_FORMAT_EXTENSIBLE AVI audio and reports its channel mask, describes FLAC and Vorbis in AVI (v2-avi-wave-format-extensible,v2-avi-flac-vorbis), reads AVI OpenDML indexes and unpacks DivX packed B-frames (v2-avi-opendml-index,v2-avi-unpack-packed-bframes), and demuxes MPEG-1, MPEG-2, and VC-1 from MPEG-TS and M2TS. Fixes meant for upstream are cut from upstream’smain, one branch per bug, and merged into the fork’smainin a merge commit named after the branch:fix-matroska-laced-header-strippingandfix-custom-decoder-close-after-failure(10-16).vendor/mediabunnypins the fork’s commit as a shallow submodule, the way the add-on pins its hls.js fork. Afile:dependency would have installed the fork’s development dependencies into the host’s lockfile, so nothing installs it: the tsconfigpathsentry mapsmediabunnyto the folder for tsc, esbuild, and Vitest, the sandbox’s Vite config and the host’s webpack and Vitest configs alias it, and both ESLint configs ignore the import.scripts/mediabunny.mjsbuilds the fork’sdist/with its own pinned toolchain (npm ci --ignore-scripts --include=devin the folder, then itstscand import-extension pass), stamps the commit indist/webgpu-player-build.json, and skips the build while the stamp matches; the engine’s typecheck, test, lint, and build scripts run it first.--include=devkeeps that toolchain, the fork’s development dependencies, when a host’s production build setsNODE_ENV=production, as the add-on’snpm run builddoes; npm had left it out, so npx ran the host’stscagainst the host’s@types/node, whoseesnext.disposablelib requires[Symbol.asyncDispose]of the fork’s async generators (11-03). The version move from 1.52.2 removed the engine’s G.711 decoder registration, which Mediabunny 1.61.5 decodes itself, and the ISO BMFF DTS sample entry mappings, which it maps itself. The fork follows Mediabunny v2, upstream’sv2branch, since 10-16: itsv2branch merges upstream’smain(1.61.6) in one commit, then carries the six pull requests, the AVI timing, and the MPEG-TS video, ported where v2 changed their code (AVI, AIFF). It merged upstream’smainagain at 1.62.1 (10-27), withmain’s fixes to the sinks v2 removed moved into its packet reader and PCM decoder; AVC and HEVC in MPEG-TS take their access unit’s PTS since, rather than their first slice’s, while MPEG-1, MPEG-2, and VC-1 keep the PTS of the PES packet their picture starts in. It merged upstream’smainagain at 1.65.2 (11-04), whose audio encoders start their packets before 0 by their measured priming delay, and whose demuxers read such timestamps: Matroska’s CodecDelay as a shift back, an MP3’s LAME priming, and a Vorbis track’s priming packet. Ofmain’s changes to the sinks v2 removed, the audio pre-roll lands in the sample cursor:AudioSampleCursorstarts decoding at the key packet a codec’s pre-roll (InputAudioTrack.getPreRollDuration()) ahead of a seek target, and answers an audio seek with the sample that holds the target time, since pre-roll and the Opus pre-skip move decoded sample boundaries off packet timestamps. It merged upstream’smainagain at 1.66.0 (11-13), a release of fixes across its demuxers, muxers, sources, and decoders. Ofmain‘s changes to the sinks v2 removed, the decoder fixes land in v2’s decoder wrappers, among them one input timestamp per shown frame, which keeps VP8 and VP9 frame times right when hidden frames sit in packets of their own. 1.66.0 makesInputTrack.ida track’s position ingetTracks()rather than its container’s own ID, so the fork addsInputTrack.containerTrackId: the ISO BMFFtrack_ID, the Matroska TrackNumber, the MPEG-TS PID, the program stream ID as FFmpeg numbers it, the Ogg serial number, the AVI stream index plus one, or the ASF stream number. The worker reads a separate Dolby Vision EL track’s configuration, and finds the track, by that ID;test/pipeline/CustomDecodeWorkerHEVCRoute.test.tsplays a dual-track Matroska whose TrackNumbers are not its tracks’ positions. Branches meant for upstream’sv2are cut from it with av2-prefix:v2-fix-matroska-laced-header-stripping,v2-fix-custom-decoder-close-after-failure,v2-url-source-read-ahead, andv2-matroska-block-additions(10-16),v2-matroska-field-order,v2-isobmff-mj2-signature,v2-isobmff-dolby-vision-sample-entries,v2-av1-codec-configuration-record,v2-av1-codec-string, andv2-drop-avc-leading-pictures(10-27), andv2-cursor-start-before-first-key,v2-avc-recovery-point-keys,v2-hevc-inter-rps-count,v2-avcc-extension-reserved-bits,v2-avc-codec-string-from-sps,v2-matroska-deferred-attachments, andv2-matroska-zero-segment-size(10-29). Fixes to fork-only code, such as the AVI demuxer or the fork’s VC-1 codec, are cut from the fork’sv2with the same prefix:v2-avi-presentation-times,v2-avi-hevc-zero-tag,v2-avi-opendml-index,v2-avi-unpack-packed-bframes,v2-avi-wave-format-extensible,v2-avi-flac-vorbis,v2-avi-keep-unmapped-video-tracks, andv2-isobmff-vc1-dvc1(10-27),v2-mpeg-ts-unregistered-vc1,v2-avi-keep-unmapped-audio-tracks, andv2-asf-demuxer(10-28), andv2-avi-audio-framingandv2-mpeg-ps-demuxer(10-29), the last of which also opens the MPEG-TS demuxer’s reading context and stream setup to the program stream demuxer.v2-drop-avc-leading-pictures, cut from upstream’sv2, drops the leading pictures of an open H.264 group when decoding starts at its key packet, as v2 drops RASL pictures for HEVC; Chromium’s decoder drops them too, and the sample path, which hands decoded frames its input timestamps in order, otherwise gave every later frame one leading picture’s timestamp too early after a seek into such a group (10-27).fix-lookup-retry-fallback, cut from upstream’smainat 1.62.1 and merged into the fork’sv2, kept a Matroska lookup’s retry near the block it seeks (10-28). 1.62.1’s lookups step back through the cluster position cache with doubling steps, and that cache only holds the clusters read so far, so a step past its first entry scanned the segment from its first cluster, and a step past the block’s cluster landed in a group read long before. A seek back into a group that playback or an earlier seek had read then read the file up to that group, up to 395 MB of a 743 MB Matroska file in one seek, and stalled for seconds over the network. The branch’s retry started from the later of the stepped entry and the cue point that entry won over, and a step past the first entry stopped at it. Upstream fixed the same scan in 1.65.2 (#621): after a scan that finds nothing, a lookup starts again from the latest cue point or cached cluster before where that scan started, and a key packet lookup passes over cached clusters without a key frame; the 11-04 merge took that lookup, for Matroska and ISO BMFF, in place of this branch’s retry. v2 replaces the media sinks with packet readers and cursors, soTrackIteration.tsgives the worker what the sinks’packets()andsamples()gave: packets read about a second ahead, and decoded samples from a start time, each ending at once onreturn(). One case differed (10-29): a start before a track’s first key packet, as in a file that opens on leading pictures, wheresamples()fell back to the first key packet and v2’s cursor started its decode on no packet and failed an assertion. The fork’s cursor now starts at the first key packet decoding can start at, and rejects withThe track has no key packet that decoding can start at.when there is none, which the worker reports assource-unsupported, so renegotiation names the source.VC1PresentationOrder.tsfollows v2’s track backing, which writes each packet into a result value, and presents a restamped packet through Mediabunny’s internal clone, which keeps where the demuxer finds the packet again. The 10-29 branches cut from upstream’sv2came from playing a whole library through the worker, each fixing a file that fell back to the host.v2-cursor-start-before-first-keyis the cursor fix above.v2-avc-recovery-point-keystakes an H.264 I picture with a recovery point SEI as a key packet on Chromium 145 and later, whoseParseH264KeyFrameaccepts one since itskParseSEIRecoveryPointsfeature shipped; v2 took none on Chromium, so a stream without IDR pictures had no key packet to start or seek at.v2-hevc-inter-rps-countcounts a predicted short-term reference picture set’s pictures by theirused_by_curr_pic_flaganduse_delta_flag, as H.265 7.4.8 and FFmpeg count them; v2 took the reference set’s count, misread every later field, and returned no SPS, so a Matroska track without a Colour element lost its colour and HEVC in AVI its picture order.v2-avcc-extension-reserved-bitsreads an avcC record’s closing bytes as its High-profile extension only when their reserved bits are set; v2 read any four closing bytes as one, overran the record on a muxer’s junk tail, and dropped the whole record, its parameter sets and colour with it.v2-avc-codec-string-from-spstakes an AVC codec string from the record’s first SPS when the record leaves its profile, compatibility, and level bytes zero, as one Matroska muxer writes them; the string readavc1.000000, which no browser decoder supports, so the track failed preparation and renegotiated.v2-matroska-deferred-attachmentsreads the Attachments, and Tags over 1 MiB, that precede the first Cluster only when metadata tags are asked for; v2 read them before the tracks, up to 146 MB of fonts in one file, so a run waited 18 s over an 8 MB/s link before its first packet.v2-matroska-zero-segment-sizereads a Segment of size 0, the placeholder mkvmerge writes until it finalizes a file, as unknown-size, as FFmpeg does, and stops at the first top-level Cluster after a segment rather than reading every cluster for another; v2 read the segment as empty and then every cluster of a 17.7 GB file for tracks it never found. Since that fix, a Segment chained after a stray top-level Cluster is not read, and FFmpeg does not read one either.v2-avi-audio-framing, cut from the fork’sv2, splits AVI MPEG audio and AC-3 chunks into one packet per frame (see Formats), since a muxer that times audio by bytes cuts its chunks anywhere and Chrome’s MP3 decoder and the AC-3 package take exactly one frame per packet. A stream whose first 64 KiB hold no frame it can read, as free-format MPEG audio’s do, keeps its chunks as packets, as before; the library’s furthest first frame sat 10 KiB in. - The engine is an npm workspace of its host (10-02).
npm installs the engine’s dependencies, so the host lists only what it uses directly.
Inside the host the engine has no
node_modulesof its own, and its tooling finds packages by walking up from the engine root. - The Jellyfin player stays in the host (10-02). Moving it would make the engine Jellyfin-aware; the plugin’s book records the decision.
- Engine workers are prebuilt (10-02).
esbuild bundles them as classic workers, which the decoder glue’s
importScriptsrequired until the CPU decoders left (10-13). They are served at stable URLs underlibraries/webgpu-player/with a per-build?v=key, not as host bundler chunks. - Typed asset names replace hash pins (10-02).
src/EngineAssets.tsis the typed manifest of runtime assets. The SHA-256 pins, verify scripts, vector registry, and per-file byte pins were removed; committed bytes and output oracle tests remain. - Decoders build with make (10-02).
wasm/Makefile(GNU Make) replaced the Python builders, and since 10-13 each decoder project’sMakefilebuilds its module with the rules inwgpud/rules.mk. FFmpeg and dcadec are pinned, shallow,update = nonesubmodules, whichmake -C wgpud sourcesfetches. There is no Docker and no Python of our own in the build.make -C wasm checkandnpm run decoders -- --checkverify byte-reproducible outputs, and whoever distributes the LGPL packages offers their corresponding source frommake -C wgpud source-archives, which since 11-02 archives the decoders repository at its checked-out commit beside the FFmpeg and dcadec trees, and which every decoders release carries. - Decoder builds are not committed (10-05).
bin/holds generated output, and onlybin/codec_vector_assets/is committed, sobin/wasm/,bin/wgpud/, andbin/wgpup/are ignored. Each checkout builds the engine’s modules withmake -C wasm allbefore the asset build; until 11-02 it built the decoder projects withnpm run decodersbefore their tests, and since then it installs the pinned decoder and pass releases withnpm run pinned-releasesinstead (see the 11-02 entry above). The decoder glue’s hand-written declarations lived inwasm/<kit>/, where thetypescondition of the#wasm/*import resolved TypeScript to them, until the CPU decoders left (10-13) and took the import with them. - Audio decoder binaries were served files (10-08), until the audio decoders became packages (10-13), which the engine does not serve.
The DTS, TrueHD, and E-AC-3 kits linked without
SINGLE_FILE: esbuild bundled their ES module glue into the workers, and each.wasmwas served fromlibraries/<kit>/. Embedded, the binaries were base64 in every worker that imported them: 1.4 MB of the playback worker for every session, and DTS and TrueHD downloaded a second time inside their probe workers. A served binary was one cache entry for the probe and playback workers, compiled while it streamed, and loaded only when a decoder of its kit was created. The glue always gotlocateFile, orwasmBinarywith bytes the page already fetched, throughsrc/DecoderWASMSource.ts, which left with it. - Codec vectors are generated into a committed
bin/folder (10-05).bin/codec_vector_assets/is the one committed folder inbin/, so every change to a qualification stream, generated module, or reference shows in review. The generators are all Python, inscripts/codec_vector_assets/..gitattributesstores the folder without line-ending conversion. Hand-made vectors live insrc/capability/vectors/, and local playback media goes to the ignoredbin/playback_smoke_media/. - Known-answer media is a “vector”, never a “fixture” (10-05): qualification vectors ship to the browser, test vectors feed only tests.
- Folder names live in
tools/constants.json(10-05). Scripts, tests, the Makefile, and the host read paths from it instead of restating them. - The sources are laid out by stage (10-14).
src/demux/holds what reads the container and shapes packets: the media source and its fetch policy, the input formats, track selection, codec configuration records, packet restamping, and the bitstream metadata packets carry, with a folder per codec.src/decode/holds what turns packets into frames and PCM: the owned WebCodecs paths, the GPU decoder package route with its coordinator and parse workers, the audio decoders, and the package loader that video and audio share. A module goes where its work belongs, not where its name points: the HDR10+ frame queues pair packet metadata with decoded frames, so they are decoding, and the Dolby Vision EL geometry and the frame-pool signal, which only presentation reads, are presentation.src/video/,src/decoders/, andsrc/audio/decoders/left without re-export stubs, andtest/mirrors the new folders. - x265 gets no level-idc for the range-extension vectors (10-06).
With CRF, x265 enforces a requested level through VBV, which it reports as non-deterministic.
The generator encodes without a level and writes
general_level_idc93 (Level 3.1) into the VPS and SPS itself. The slices were byte-identical either way. - Decoder kits were named for their library and codecs (10-06):
ffmpeg-eac3,ffmpeg-truehd,ffmpeg-mpeg2-vc1,ffmpeg-hevc,libdcadec-dts, andlibdovi. Their classes followed the codecs too, as inMPEG2VC1SoftwareVideoDecoder, and a name that covered one codec only said so, as inbundledMPEG2besidebundledVC1. Since 10-13 a decoder project is named for its codec, whatever library it builds (eac3,truehd,dts,hevc,vc1), andlibdoviis the one kit left. - The decoder projects live in
wgpud/and the pass projects inwgpup/(10-15). The names mark both as module types of this player: a decoder package or a pass file is something a host loads into the engine, not engine code.decoders/becamewgpud/, and its packages build intobin/wgpud/.wgpup/holds the optional passes a host may offer: the sharpen, saturation, and temporal blend examples and the Catmull-Rom scaler, which were modules ofsrc/presentation/passes/that no route used, and the Anime4K upscalers. The engine never loads a pass itself; until 11-02npm run buildcopied the pass files intobin/wgpup/, and since thennpm run pinned-releasesinstalls them from the pinned passes release, ornpm run passescopies them from the submodule, where a host serves them, and the sandbox lists them. Since 11-02 both folders are submodules of repositories of their own (see the 11-02 entry above). The Anime4K passes are generated from the upstream mpv GLSL by the project’s own generators and committed, so a build needs no Python. - Our sources in
wasm/,wgpud/, andwgpup/carry no license headers (10-06). The repository’sLICENSEcovers them; since 11-02wgpud/andwgpup/are repositories of their own, each covered by its ownLICENSE, a copy of the engine’s. - Both repositories lint the engine (10-02).
The engine has its own ESLint config adapted from Jellyfin Web’s, and the host’s lint also covers the engine’s
src/andtest/, so the engine stays clean under both. - The documentation is this book (10-06): one mdBook in
docs/, with no READMEs nested in other folders and no separate agent map. The decoders and passes repositories’ READMEs (11-02) only point here. It covers only the engine (10-09); the Jellyfin plugin documents its integration in its own book. - Diagrams are PlantUML, rendered to committed SVGs in two variants (10-09).
They replace the text diagrams and the PlantUML activity diagrams of the Jellyfin Web fork, whose paths, line numbers, and timeouts no longer matched the code.
One theme, adapted from the fork’s Boreal theme, renders a light variant for the rust book theme and a dark one for coal, and the book shows the variant matching the reader’s theme.
The SVGs are committed so a book builds without Java; the PlantUML jar is not, and the renderer downloads it into
bin/plantuml/against a pinned SHA-256.
Maintaining this book
The book is the engine’s only documentation. It is built with mdBook 0.5.
Build and read it
- Install mdBook once:
cargo install mdbook --version 0.5.4 --locked. - From the engine root, run
mdbook serve docs --openwhile editing; it rebuilds on every save. - Before committing, run
mdbook build docs; it fails on a brokenSUMMARY.md. It writes the book todocs/book/, which is not tracked.
A push to main that changes docs/ publishes the book to https://alchemyyy.github.io/WebGPU-Player/, through .github/workflows/docs.yml; its MDBOOK_VERSION is the version in step 1.
docs/book.toml holds the configuration, docs/theme/ the stylesheets, and docs/src/SUMMARY.md the table of contents.
The Jellyfin plugin’s book imports these stylesheets from their source paths, so keep their file names stable.
docs/theme/favicon.svg is a plain-SVG export of the logo; after changing images/webgpu-player-logo.svg, export it again from the engine root:
inkscape images/webgpu-player-logo.svg --export-plain-svg --export-filename=docs/theme/favicon.svg
A chapter that is not listed in SUMMARY.md is not built.
Diagrams
Diagrams are PlantUML sources in docs/diagrams/plantuml/, rendered to SVG in docs/src/diagrams/.
Both folders are tracked, so a book builds without Java.
Every diagram renders twice: a light variant for the rust theme and a dark variant for coal, and docs/theme/diagrams.css shows the one that matches the reader’s theme.
docs/theme/diagrams.js turns each embedded diagram into an in-page viewer: drag pans, Ctrl+wheel, a pinch, or a double-click zooms, and a toolbar zooms, fits, or opens the SVG; a plain wheel still scrolls the page.
Without JavaScript the diagram scales to the column and links to its SVG.
You need Java 11 or later on PATH.
The first render downloads the pinned PlantUML jar into bin/plantuml/ and checks its SHA-256.
-
Edit or add a source in
docs/diagrams/plantuml/. Start it with@startumland!include diagram-theme.puml, and color activity nodes with the theme’s semantic colors ($HL_BLUE,$HL_GREEN,$HL_AMBER,$HL_NAVY,$HL_RED). Never end a line inside a multi-line activity label with;, which ends the label. -
Run
node tools/render-diagrams.mjsfrom the engine root. With--watchit renders again on every save, besidemdbook serve. -
Embed both variants where the diagram belongs:
<div class="diagram"> <a class="diagram-light" href="diagrams/<name>.light.svg"><img src="diagrams/<name>.light.svg" alt="<what it shows>"></a> <a class="diagram-dark" href="diagrams/<name>.dark.svg"><img src="diagrams/<name>.dark.svg" alt="<what it shows>"></a> </div> -
Before committing, run
node tools/render-diagrams.mjs --check, which fails when a committed SVG is stale, missing, or has no source.
The Jellyfin plugin’s book includes diagram-theme.puml from here, so keep its name and variables stable.
When to update it
Update the chapter in the same change that alters what it describes:
- a route, a probe, a route key, or an eligibility rule: Eligibility and routes and HEVC and Dolby Vision support;
- a file added, moved, or removed: Module map, and Repository layout for a folder;
- the decoder package format or an interface a package implements: Decoder packages;
- the pass file format, the code generated around a pass, or a reason a pass is left out: Create a new WebGPU pass, and Migrate an mpv shader to a wgpup, whose converter writes that format;
- a decoder project, an engine module, or a build step: WebAssembly build;
- a vector or a generator: Codec vectors;
- a settled investigation: Decisions. Never delete an entry; revise it when its facts change, rather than adding one that contradicts it.
How to write it
- Record durable facts: architecture, routes, layout, procedures, and decisions. Git already records history and working-tree state, so there are no changelogs, status snapshots, or “uncommitted” notes.
- Write procedures as recipes: what you need, numbered steps, then how to check the result.
- Prefer plain sentences and short lists. Use a table only for data that has columns. Keep bold, callouts, and decoration out.
- Put each sentence on its own line; never wrap a line at a fixed column.
- Use ASCII only.
- Follow the path conventions in the Introduction: engine paths from the engine root.
- Keep Jellyfin integration out: the player, the device profile, PlaybackInfo, settings, and the add-on belong in the Jellyfin plugin’s book. Jellyfin’s media metadata, which the engine reads as its input, belongs here.
- Never include credentials, server addresses, item IDs, media titles or paths, or absolute machine paths.