wai: reader limits for the extension formats' durations #258
42 changed files with 3910 additions and 297 deletions
19
.github/workflows/wai-conformance.yml
vendored
19
.github/workflows/wai-conformance.yml
vendored
|
|
@ -220,6 +220,14 @@ jobs:
|
|||
cargo clippy --lib --no-default-features --features neural_int -- -A clippy::all
|
||||
cargo test --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film --test jpegai_decode_symbols
|
||||
|
||||
# Each extension container refuses a count its bytes cannot hold, without reserving
|
||||
# it first (found by the coverage-guided fuzzer, wai-rs/fuzz/, target `containers`),
|
||||
# and a declared duration past the reader limits it shares with SPEC §2.
|
||||
- name: Extension containers reserve no more than their bytes hold, and are held to their duration limits
|
||||
shell: bash
|
||||
working-directory: wai/wai-rs
|
||||
run: cargo test --no-default-features --features world,audio_scene,haptic,splat4d,score,film --test container_reservations --test extension_duration_limits
|
||||
|
||||
# The integer decoders run fast kernels (src/int_kernels.rs) that must give
|
||||
# the same integer as the scalar reference kernels for every output element.
|
||||
# The feature list above leaves out audio_int, video_int, world_int and
|
||||
|
|
@ -974,18 +982,19 @@ jobs:
|
|||
|
||||
# Every one of these test files is `#![cfg(feature = "codecs")]`: built without
|
||||
# the feature each reports "0 passed" and exits 0. So the step names the feature,
|
||||
# and then requires that each of the four binaries ran at least one test.
|
||||
- name: Fuzz smoke, the text decode limits and the C entry points
|
||||
# and then requires that each of the five binaries ran at least one test.
|
||||
- name: Fuzz smoke, the text and media decode limits and the C entry points
|
||||
working-directory: wai/wai-rs
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
cargo test --locked --features codecs \
|
||||
--test fuzz_smoke --test text_decode_bounds --test text_decode_memory --test ffi_robustness \
|
||||
--test fuzz_smoke --test text_decode_bounds --test text_decode_memory \
|
||||
--test media_decode_bounds --test ffi_robustness \
|
||||
2>&1 | tee "$RUNNER_TEMP/codec-wrappers.log"
|
||||
ran=$(grep -cE '^test result: ok\. [1-9][0-9]* passed' "$RUNNER_TEMP/codec-wrappers.log" || true)
|
||||
if [ "$ran" -ne 4 ]; then
|
||||
echo "::error::expected four test binaries that each ran tests; $ran did"
|
||||
if [ "$ran" -ne 5 ]; then
|
||||
echo "::error::expected five test binaries that each ran tests; $ran did"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
|
|||
61
.github/workflows/wai-fuzz.yml
vendored
Normal file
61
.github/workflows/wai-fuzz.yml
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Coverage-guided fuzzing of the WAI reader, every night.
|
||||
#
|
||||
# `wai/wai-rs/fuzz/run.sh` runs four libFuzzer targets (cargo-fuzz, AddressSanitizer):
|
||||
# envelope version detection, the WAI1 and WAI2 readers, the listing form
|
||||
# int_payload int_payload::decode_int against every committed parameter set
|
||||
# media the picture, audio, video and text decoders of the floor and the
|
||||
# recommended set
|
||||
# containers the extension containers: world, causal op, film, reel, score,
|
||||
# haptic, splat4d, audio scene
|
||||
# each for 15 minutes, seeded from the repository's own envelopes, integer payloads and
|
||||
# containers, and a small valid payload per decoder.
|
||||
#
|
||||
# Nightly and never per pull request: the runners are the bottleneck, and fifteen
|
||||
# minutes a target is worth running only where it can grow a corpus. The corpus is kept
|
||||
# on the runner host's CI volume, so each night starts where the last left off.
|
||||
#
|
||||
# A find fails the job (exit 1) and keeps its input as an artifact; a target that does
|
||||
# not build or run fails it too (exit 2). Turn a find into a regression test as the
|
||||
# header of run.sh says.
|
||||
name: WAI fuzz (coverage-guided, nightly)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 4 * * *'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
fuzz:
|
||||
name: envelope, integer payloads, media decoders and containers · 15 min each
|
||||
# The macOS runner's host has the C codec libraries the media target links.
|
||||
runs-on: macos
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install a pinned nightly and cargo-fuzz
|
||||
shell: bash
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
rustup toolchain install nightly-2026-09-28 --profile minimal
|
||||
command -v cargo-fuzz >/dev/null || cargo install cargo-fuzz --locked
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Fuzz
|
||||
working-directory: wai/wai-rs/fuzz
|
||||
shell: bash
|
||||
env:
|
||||
WAI_FUZZ_TOOLCHAIN: nightly-2026-09-28
|
||||
WAI_FUZZ_CORPUS: /Volumes/macos_4TB_external_1/ci/wai-fuzz-corpus
|
||||
run: ./run.sh 900
|
||||
|
||||
- name: Keep what it found
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: wai-fuzz-artifacts
|
||||
path: wai/wai-rs/fuzz/artifacts
|
||||
if-no-files-found: ignore
|
||||
|
|
@ -6,6 +6,83 @@ wire format may change and such changes are recorded here.
|
|||
|
||||
## v0.4.0 — current
|
||||
|
||||
### Reader limits for the extension formats' durations
|
||||
|
||||
A score, haptic signal, audio scene, splat4d clip or film declares its length in its
|
||||
contract, and rendering it allocates in proportion. Nothing bounded the declaration
|
||||
(found while fuzzing: a score's `mixdown` allocated `duration` samples outright).
|
||||
|
||||
- **The limits** (each extension's §2.1, `wai::limits`), shared with SPEC §2:
|
||||
- score and haptics: `duration × channels` at most 2²⁷ samples;
|
||||
- audio scene: `duration` at most 2²⁶, so its stereo floor is at most 2²⁷;
|
||||
- splat4d (`n_frames`), film (`frames`) and a reel's shots together: at most 2²⁰
|
||||
frames.
|
||||
|
||||
A reader may refuse past a limit (`duration_too_long`), when it reads the contract.
|
||||
- **Film tick arithmetic.** `start_tick + f × ticks_per_frame` wrapped in release
|
||||
builds and panicked in debug ones. A reader now refuses a schedule whose last tick
|
||||
passes 2⁶⁴ − 1, and `frame_tick` saturates rather than wraps.
|
||||
- **Tests.** `tests/extension_duration_limits.rs` rewrites a committed vector of each
|
||||
format to sit exactly at its limit (it reads) and one past it (refused by name), and
|
||||
checks the film schedule at and past 2⁶⁴ − 1. The old readers fail all three tests,
|
||||
and every committed corpus verifies unchanged.
|
||||
|
||||
### Coverage-guided fuzzing of the reader
|
||||
|
||||
`wai-rs/fuzz/` adds four libFuzzer targets, built by cargo-fuzz with AddressSanitizer:
|
||||
- `envelope`: the `WAI1` and `WAI2` readers and the listing form;
|
||||
- `int_payload`: every integer capability, against the committed parameter sets;
|
||||
- `media`: the decoders of the floor and the recommended set;
|
||||
- `containers`: the extension containers.
|
||||
|
||||
They are seeded from the repository's own vectors. `wai-fuzz.yml` runs each target for
|
||||
15 minutes every night and keeps the corpus between runs; it never runs on a pull
|
||||
request. `fuzz/run.sh` runs the same targets anywhere a nightly toolchain is installed.
|
||||
SECURITY.md now says what is fuzzed and what is not.
|
||||
|
||||
- **Found and fixed.** In its first seconds, `containers` found the audio scene reader
|
||||
reserving entries for a `u32` count read from the input, before it read one: 48 GiB
|
||||
asked for by a few bytes. The world, reel, score, haptic and splat4d readers did the
|
||||
same. Each now reserves no more than its remaining bytes could hold.
|
||||
`tests/container_reservations.rs` rewrites a committed vector of each to declare
|
||||
2³² − 1 entries in four bytes and requires a refusal with no allocation past a few
|
||||
megabytes; on the old readers the world asset table alone asked for 256 GiB.
|
||||
|
||||
### Reader limits for picture, audio and video payloads
|
||||
|
||||
The last decoders that allocated what a payload declared now hold it to limits
|
||||
(SPEC §2), as manifests and text payloads already were.
|
||||
|
||||
- **The limits.** A reader must accept a picture up to 65 536 pixels on a side and
|
||||
2²⁷ pixels, audio up to 2²⁷ samples counting every channel, and AV1 up to 2²⁰
|
||||
temporal units of such pictures. It may refuse past them (`image_too_large`,
|
||||
`audio_decoded_too_long`, `video_too_large`), from what the payload declares wherever
|
||||
it declares it. The writers refuse the same.
|
||||
- **Payload framing** (SPEC §5). The AV1 framing that the reference has always used,
|
||||
and packaging §7 writes, is now in the standard itself. So are the FLAC and Ogg Opus
|
||||
forms, and the rule that an image payload decodes only as its own format.
|
||||
- **Defects fixed in the reference decoders.**
|
||||
- A FLAC `STREAMINFO` declaring 2³⁶ − 1 samples made the decoder ask for 256 GiB up
|
||||
front.
|
||||
- A 24-bit FLAC decoded to about ±256 instead of ±1.
|
||||
- An Ogg Opus stream whose source rate was not an Opus rate (44.1 kHz) was refused.
|
||||
- `png_decode` and `jpeg_decode` decoded any format the bytes resembled.
|
||||
- `jxl_decode` refused a deeper-than-8-bit picture and returned one or four channels
|
||||
where it promised three.
|
||||
- The AV1 decoder decoded every packet before truncating to `n`. It panicked on
|
||||
monochrome, misread 4:2:2, 4:4:4 and 10- and 12-bit frames, and let dav1d allocate
|
||||
whatever a sequence header declared.
|
||||
- **Streaming AV1.** `av1_decode_each` hands on one frame at a time. `av1_decode` and
|
||||
`wai_video_av1_decode`, which return every frame at once, refuse past 4 GiB of output
|
||||
from the header (`video_decoded_too_large`). `wai decode` streams frames to its file.
|
||||
- **Unchanged.** Every 8-bit 4:2:0 frame decodes byte for byte as before. The AVIF path
|
||||
now calls libavif directly with the reader limits as its decoder limits (it drops
|
||||
`libavif-image` and the rav1e build inside libavif), and `include/wai.h` is
|
||||
regenerated.
|
||||
- **Tests.** `tests/media_decode_bounds.rs` checks each limit at its edge and one past
|
||||
it, and each defect above. CI runs it in `codec-wrappers`. Five mutations, each
|
||||
reintroducing one defect, fail it.
|
||||
|
||||
### Vector graphics (Draft): a title card and credit roll in the corpus
|
||||
|
||||
`wai/vector-conformance/` gains `motion/stroke-titles`: a 1280 × 720 document at 25 fps
|
||||
|
|
|
|||
|
|
@ -117,12 +117,35 @@ counting allocator, that refusing a payload of a few kilobytes that would expand
|
|||
four times a limit takes no more output buffer than the limit. The C entry points
|
||||
`wai_text_zstd_decode` and `wai_text_xz_decode` are these decoders.
|
||||
|
||||
The picture, audio and video payloads of the floor and the recommended set have reader
|
||||
limits as well (SPEC §2): a picture at most 65 536 pixels wide and tall and 2²⁷ pixels
|
||||
in all, audio of at most 2²⁷ samples counting every channel, and AV1 of at most 2²⁰
|
||||
temporal units whose frames are pictures within those limits. The reference decoders
|
||||
(`codecs/image.rs`, `codecs/audio.rs`, `codecs/video.rs`) refuse a picture from the
|
||||
size its header declares, before they decode it (`image_too_large`): PNG and JPEG
|
||||
through the `image` crate's header read, AVIF through libavif's own decoder limits,
|
||||
which it checks against the `ispe` of every image item while it parses the container
|
||||
and again against the AV1 sequence header, and JPEG XL through a pass of libjxl that
|
||||
reads only the basic information. Each decodes only its own format. The FLAC decoder
|
||||
refuses a `STREAMINFO` whose declared total passes the limit before it decodes, and
|
||||
never sizes its output from the declaration past the limit; both audio decoders count
|
||||
their output as it grows and refuse it past the limit (`audio_decoded_too_long`). The
|
||||
AV1 decoder refuses a framing header past the limits (`video_too_large`), more temporal
|
||||
units than the header declares, and a frame larger than it declares, and decodes every
|
||||
layout and depth AV1 allows rather than assuming 8-bit 4:2:0. `av1_decode_each` hands
|
||||
on one frame at a time, so its memory is one frame and dav1d's reference frames whatever
|
||||
the payload's length; `av1_decode` and `wai_video_av1_decode`, which return every frame
|
||||
at once, refuse a payload whose frames would take more than 4 GiB together, from its
|
||||
header (`video_decoded_too_large`). The encoders refuse what a reader may refuse.
|
||||
`tests/media_decode_bounds.rs` checks each limit at its edge and one past it, on real
|
||||
payloads and on headers rewritten to declare more than their data holds.
|
||||
|
||||
## Native codec libraries and sandboxed decode
|
||||
|
||||
The default `codecs` feature binds C and C++ libraries for the floor and the
|
||||
recommended set: libzstd and liblzma for `wai.text.zstd` and `wai.text.xz`, libjxl
|
||||
for JPEG XL, libavif for AVIF decode, libopus, dav1d for AV1 decode, and libFLAC for
|
||||
FLAC encode. PNG, JPEG and FLAC decode run in Rust (the `image` and `claxon` crates).
|
||||
for JPEG XL, libavif for AVIF decode (with dav1d inside it), libopus, dav1d for AV1
|
||||
decode, and libFLAC for FLAC encode. PNG, JPEG and FLAC decode run in Rust (the `image` and `claxon` crates).
|
||||
The `neural` feature binds ONNX Runtime for the float neural decoders. These libraries
|
||||
parse payload bytes directly, and they are the usual place a memory-safety defect
|
||||
in a media stack lives.
|
||||
|
|
@ -136,8 +159,8 @@ run it in a WebAssembly runtime.
|
|||
What does not touch them:
|
||||
|
||||
- Most CI builds. One job builds `codecs`: `codec-wrappers` in `wai-conformance.yml`,
|
||||
on the macOS runner, which runs the wrappers' fuzz smoke, the text decode limits and
|
||||
the C entry points through the libraries. `wai-neural-gates.yml` and
|
||||
on the macOS runner, which runs the wrappers' fuzz smoke, the text and media decode
|
||||
limits and the C entry points through the libraries. `wai-neural-gates.yml` and
|
||||
`wai-neural-e2e.yml` build the `neural` feature, which links ONNX Runtime and
|
||||
libzstd, and run the float neural decoders through them. No workflow enables
|
||||
`semantic`, and no other workflow's build links any of these libraries.
|
||||
|
|
@ -158,15 +181,43 @@ model-checked. Equality tests hold them to the scalar reference kernels, element
|
|||
element beside each kernel and over every committed integer-decoder vector
|
||||
(`tests/int_kernels_vectors.rs`), on every platform leg of `wai-conformance.yml`.
|
||||
|
||||
The extension formats are held to the reader limits they share with SPEC §2
|
||||
(`wai::limits`): a score or haptic signal renders at most 2²⁷ samples counting every
|
||||
channel, an audio scene's stereo floor the same, and a splat4d clip, a film or a reel's
|
||||
shots together at most 2²⁰ frames. Each reader refuses a contract past them when it
|
||||
reads it (`duration_too_long`), before anything is rendered, and refuses a film or reel
|
||||
schedule whose last tick a `u64` does not hold (the tick arithmetic used to wrap).
|
||||
`tests/extension_duration_limits.rs` checks each at the limit and one past it.
|
||||
|
||||
The reader is fuzzed with coverage guidance. `wai-rs/fuzz/` holds four libFuzzer
|
||||
targets, built by cargo-fuzz with AddressSanitizer:
|
||||
- `envelope`: version detection, the `WAI1` and `WAI2` readers and the listing form;
|
||||
- `int_payload`: `int_payload::decode_int` against each parameter set the integer
|
||||
conformance corpus commits;
|
||||
- `media`: the picture, audio, video and text decoders of the floor and the recommended
|
||||
set;
|
||||
- `containers`: the extension containers (world, causal op, film, reel, score, haptic,
|
||||
splat4d, audio scene).
|
||||
|
||||
They are seeded from the repository's own envelopes, integer payloads and containers,
|
||||
and from a valid payload per decoder. `wai-fuzz.yml` runs each for 15 minutes every
|
||||
night on the macOS runner and keeps the corpus between nights. A find fails the job
|
||||
and keeps its input; `fuzz/run.sh` says how to turn one into a regression test. On every
|
||||
change, `tests/fuzz_smoke.rs` still runs seeded random bytes through each wrapper and
|
||||
`Wai::from_bytes`.
|
||||
|
||||
Known gaps:
|
||||
|
||||
- **The image, audio and video decoders set no output limit of their own.** They
|
||||
allocate what the bitstream declares, within the libraries' own limits.
|
||||
- **Fuzzing is shallow.** `tests/fuzz_smoke.rs` feeds seeded random bytes to each
|
||||
codec wrapper and to `Wai::from_bytes` and requires that nothing panics. It runs in
|
||||
CI on one platform (`codec-wrappers` in `wai-conformance.yml`). There is no
|
||||
coverage-guided fuzzing of the manifest parser, the integer payload parsers or the
|
||||
codec wrappers.
|
||||
- **The C libraries are not instrumented.** AddressSanitizer covers the Rust code. The
|
||||
codec libraries run as the system builds them, so a memory error inside one is seen
|
||||
only when it crashes the process or exceeds a limit.
|
||||
- **Replaying a world costs CPU in proportion to the tick it is asked for.** A film or
|
||||
reel samples its embedded world at the ticks its schedule names, and the reference
|
||||
replay steps every tick from genesis. The schedule's frames are bounded and its last
|
||||
tick must fit a `u64`, but no limit yet bounds how far a schedule may reach, so a
|
||||
small film can ask a renderer for a long replay. Memory is bounded; time is not.
|
||||
- **The float neural decoders are not fuzzed.** They need the `neural` feature and its
|
||||
runtime. The integer decoders are fuzzed.
|
||||
|
||||
## Module authorisation
|
||||
|
||||
|
|
|
|||
42
wai/SPEC.md
42
wai/SPEC.md
|
|
@ -132,6 +132,33 @@ the other, and the decoder's fixed state. As for a manifest, a conforming encode
|
|||
no payload past either limit, and the reference writers refuse one; bytes longer than
|
||||
the length limit are carried as more than one object.
|
||||
|
||||
A picture, audio or video payload of the mandatory floor or the recommended set (§5)
|
||||
has reader limits too, since its format bounds what it may ask of a decoder loosely or
|
||||
not at all. A reader MUST NOT refuse a picture (a `wai.image.png`, `wai.image.jpeg`,
|
||||
`wai.image.avif` or `wai.image.jxl` payload, or a frame of a `wai.video.av1` or
|
||||
`wai.video.av1.lossless` one) for its size while it is at most 65 536 pixels wide and
|
||||
tall and holds at most 134 217 728 pixels (2²⁷, a 16 384 × 8 192 picture). It MUST NOT
|
||||
refuse a `wai.audio.flac` or `wai.audio.opus` payload for its length while it decodes to
|
||||
at most 134 217 728 samples (2²⁷), counting every channel: 46 minutes of one channel at
|
||||
48 kHz. It MUST NOT refuse a `wai.video.av1` or `wai.video.av1.lossless` payload for its
|
||||
length while its framing header (§5, *Payload framing*) declares at most 1 048 576
|
||||
temporal units (2²⁰), 4.9 hours at 60 frames a second. It MAY refuse one past any of
|
||||
these: a picture (`image_too_large`), audio (`audio_decoded_too_long`) or video
|
||||
(`video_too_large`). A reader that refuses a picture does so from the size its payload
|
||||
declares, before it decodes a pixel: the PNG `IHDR`, the JPEG frame header, the `ispe`
|
||||
property of each image item of an AVIF, the JPEG XL size header, the AV1 framing
|
||||
header's `h` and `w`. One that refuses audio does so when its output would pass the
|
||||
limit, in a buffer it never sizes past the limit, and where a FLAC `STREAMINFO` declares
|
||||
a total past the limit, from the declaration, before it decodes. One that refuses video
|
||||
does so from the framing header. A picture or audio decode then takes its output and
|
||||
the decoder's state; a video decode that hands on each frame as it decodes takes one
|
||||
frame and the decoder's reference frames, whatever the payload's length. (A reader that
|
||||
keeps every frame at once needs n · h · w pixels; the reference library's whole-output
|
||||
entry points refuse a payload past a budget, 4 GiB by default, and decode one frame at
|
||||
a time otherwise; informative, `SECURITY.md`.) As for text, a conforming encoder writes
|
||||
no payload past these limits, and the reference writers refuse one; longer audio and
|
||||
video are carried as more than one object.
|
||||
|
||||
An envelope delivered over HTTP is one object of the proposed media type
|
||||
`application/wai`, named by its receipt content hash, and fetched whole or, for a
|
||||
`WAI2`, one verified component at a time
|
||||
|
|
@ -603,6 +630,21 @@ Every conforming sink SHOULD implement:
|
|||
| `wai.video.av1.lossless` | AV1 (AOMedia), `quantizer=0` | mathematically lossless YUV (lossy in RGB due to color conversion) |
|
||||
| `wai.text.xz` | XZ/LZMA2 | maximum classical text ratio |
|
||||
|
||||
**Payload framing.** A `wai.image.*` payload is one file of its format as the named
|
||||
specification defines it, and a reader decodes it as that format only: PNG bytes are
|
||||
not a `wai.image.jpeg` payload. A `wai.audio.flac` payload is a FLAC stream (RFC 9639),
|
||||
its `fLaC` marker and `STREAMINFO` first. A `wai.audio.opus` payload is an Ogg Opus stream
|
||||
(RFC 7845); its `OpusHead` input sample rate is informational (RFC 7845 §5.1), so a
|
||||
reader decodes at a rate of its own choosing when the declared one is not an Opus rate.
|
||||
A `wai.video.av1` or `wai.video.av1.lossless` payload is five `u32` little-endian
|
||||
values, `n`, `h`, `w`, `fps_num` and `fps_den` (the number of temporal units, the
|
||||
largest frame's height and width, and the frame rate as `fps_num` / `fps_den` frames a
|
||||
second), then each temporal unit as a `u32` little-endian length and its bytes: the
|
||||
temporal unit in the low-overhead bitstream format (AV1 §5), every OBU with its size
|
||||
field. A payload holding more temporal units than `n`, or a frame larger than `h` × `w`,
|
||||
is malformed. [packaging](extensions/packaging.md) §7 writes its floor objects in
|
||||
this framing. A sink decodes AV1 in any profile, layout and depth AV1 allows.
|
||||
|
||||
### Neural capabilities (OPTIONAL, sink advertises if its ML runtime supports them)
|
||||
|
||||
Each names a model the sink MAY have installed. Payload is a compact
|
||||
|
|
|
|||
|
|
@ -50,6 +50,11 @@ authoritative.
|
|||
|
||||
Haptic rates are low (≈1 kHz); `duration` is in samples.
|
||||
|
||||
**Reader limit.** A reader MUST NOT refuse a signal for its length while `duration ×
|
||||
channels` is at most 134 217 728 (2²⁷, the audio limit of SPEC §2), and MAY refuse one
|
||||
past it (`duration_too_long`), when it reads the contract, before it reconstructs
|
||||
anything. A conforming encoder writes no signal past it.
|
||||
|
||||
### 2.2 Channel signals
|
||||
|
||||
`n_channels u32`, then per channel: `n_keys u16`, then
|
||||
|
|
|
|||
|
|
@ -73,6 +73,12 @@ sampled states, but the verified quantity is the schedule above. `fps` is
|
|||
**not** in the contract — it is playback speed (presentation), not part of
|
||||
the film's identity.
|
||||
|
||||
**Reader limit.** A reader MUST NOT refuse a film for its length while `frames` is at
|
||||
most 1 048 576 (2²⁰, the video limit of SPEC §2), and MAY refuse one past it
|
||||
(`duration_too_long`), when it reads the contract, before it renders anything. It MUST
|
||||
refuse a contract whose last tick, `start_tick + (frames − 1) × ticks_per_frame`, is
|
||||
past 2⁶⁴ − 1: that schedule names no tick. A conforming encoder writes neither.
|
||||
|
||||
### 2.2 Embedded world
|
||||
|
||||
The exact bytes of a sealed `wai.world.replay` (`WWLD`) container. The film
|
||||
|
|
@ -157,7 +163,8 @@ Container `WREL`: `0x01` contract (canonical JSON, `{ "numeric", "shots" }`),
|
|||
`0x02` embedded sealed world (`WWLD`), `0x03` shots — `n_shots u32`, then per
|
||||
shot `frames u64 | start_tick u64 | ticks_per_frame u64 | camera track`. Each
|
||||
shot's keys MUST be strictly ascending by tick; a shot's local frame `j`
|
||||
samples world tick `start_tick + j * ticks_per_frame`.
|
||||
samples world tick `start_tick + j * ticks_per_frame`. The frame limit of §2.1 holds
|
||||
for the sum of the shots' frames, and each shot's last tick is held as a film's is.
|
||||
|
||||
Conformance is **reel-equivalence**, film-equivalence extended over cuts: the
|
||||
global frame `f` belongs to a shot, samples the world at that shot's tick, is
|
||||
|
|
|
|||
|
|
@ -48,6 +48,11 @@ runs in the `wai.det.fixed64` floor; `media` takes the value `"audio"`;
|
|||
|
||||
`duration` is the rendered mixdown length in samples.
|
||||
|
||||
**Reader limit.** A reader MUST NOT refuse a score for its length while `duration ×
|
||||
channels` is at most 134 217 728 (2²⁷, the audio limit of SPEC §2), and MAY refuse one
|
||||
past it (`duration_too_long`), when it reads the contract, before it renders anything.
|
||||
A conforming encoder writes no score past it.
|
||||
|
||||
### 2.2 Notes
|
||||
|
||||
`n_notes u32`, then per note (65 bytes):
|
||||
|
|
|
|||
|
|
@ -67,6 +67,12 @@ mandatory render target; richer layouts (`5.1`, `binaural@<hrtf-id>`)
|
|||
are OPTIONAL and, for binaural, perceptual (the HRTF is a
|
||||
requirement-of-existence, not bit-exact across HRTFs).
|
||||
|
||||
**Reader limit.** A reader MUST NOT refuse a scene for its length while `duration` is
|
||||
at most 67 108 864 (2²⁶): its stereo floor mixdown is then at most 2²⁷ samples, the audio
|
||||
limit of SPEC §2. It MAY refuse one past it (`duration_too_long`), when it reads the
|
||||
contract, before it renders anything. A richer layout is a render the sink chooses, at
|
||||
its own cost. A conforming encoder writes no scene past the limit.
|
||||
|
||||
### 2.2 Object table
|
||||
|
||||
`n_objects u32`, then per object:
|
||||
|
|
|
|||
|
|
@ -51,6 +51,11 @@ informational value `"volumetric"`; `wai.splat.4d` is authoritative.
|
|||
"numeric": "wai.det.fixed64" }
|
||||
```
|
||||
|
||||
**Reader limit.** A reader MUST NOT refuse a clip for its length while `n_frames` is at
|
||||
most 1 048 576 (2²⁰, the video limit of SPEC §2), and MAY refuse one past it
|
||||
(`duration_too_long`), when it reads the contract, before it reconstructs anything. A
|
||||
conforming encoder writes no clip past it.
|
||||
|
||||
### 2.2 Gaussian tracks
|
||||
|
||||
`n_gaussians u32`, then per Gaussian: `n_keys u16`, then
|
||||
|
|
|
|||
189
wai/wai-rs/Cargo.lock
generated
189
wai/wai-rs/Cargo.lock
generated
|
|
@ -332,12 +332,6 @@ version = "2.13.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "bitstream-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2"
|
||||
|
||||
[[package]]
|
||||
name = "bitstream-io"
|
||||
version = "4.10.0"
|
||||
|
|
@ -486,12 +480,6 @@ dependencies = [
|
|||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "built"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b"
|
||||
|
||||
[[package]]
|
||||
name = "built"
|
||||
version = "0.8.1"
|
||||
|
|
@ -693,16 +681,6 @@ dependencies = [
|
|||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.15.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon 0.12.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.20.9"
|
||||
|
|
@ -710,7 +688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon 0.13.5",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1078,7 +1056,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "c3c91aea6668645415331133ed6f8ddf0e7f40160cd97a12d59e68716a58704b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"system-deps 7.0.8",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1873,15 +1851,6 @@ dependencies = [
|
|||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
|
|
@ -2073,25 +2042,6 @@ dependencies = [
|
|||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libavif"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b710faf0de92da0e32a9a9c89bbf849e37277fd447b4f17b0f0f798796ae1f2"
|
||||
dependencies = [
|
||||
"libavif-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libavif-image"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0a6b5ea7c9d9260fa57daa8f238992de5d9c7a3c82a78e8dbc4d1367e7c28be"
|
||||
dependencies = [
|
||||
"image",
|
||||
"libavif",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libavif-sys"
|
||||
version = "0.17.0+libavif.1.0.4"
|
||||
|
|
@ -2101,7 +2051,6 @@ dependencies = [
|
|||
"cmake",
|
||||
"libc",
|
||||
"libdav1d-sys",
|
||||
"rav1e 0.7.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2304,15 +2253,6 @@ dependencies = [
|
|||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nasm-rs"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe4d98d0065f4b1daf164b3eafb11974c94662e5e2396cf03f32d0bb5c17da51"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndarray"
|
||||
version = "0.17.2"
|
||||
|
|
@ -3166,43 +3106,6 @@ dependencies = [
|
|||
"rasn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rav1e"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"arg_enum_proc_macro",
|
||||
"arrayvec",
|
||||
"av1-grain",
|
||||
"bitstream-io 2.6.0",
|
||||
"built 0.7.7",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"interpolate_name",
|
||||
"itertools 0.12.1",
|
||||
"libc",
|
||||
"libfuzzer-sys",
|
||||
"log",
|
||||
"maybe-rayon",
|
||||
"nasm-rs",
|
||||
"new_debug_unreachable",
|
||||
"noop_proc_macro",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"paste",
|
||||
"profiling",
|
||||
"rand 0.8.8",
|
||||
"rand_chacha 0.3.1",
|
||||
"scan_fmt",
|
||||
"simd_helpers",
|
||||
"system-deps 6.2.2",
|
||||
"thiserror 1.0.69",
|
||||
"v_frame",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rav1e"
|
||||
version = "0.8.1"
|
||||
|
|
@ -3215,8 +3118,8 @@ dependencies = [
|
|||
"arrayvec",
|
||||
"av-scenechange",
|
||||
"av1-grain",
|
||||
"bitstream-io 4.10.0",
|
||||
"built 0.8.1",
|
||||
"bitstream-io",
|
||||
"built",
|
||||
"cfg-if",
|
||||
"interpolate_name",
|
||||
"itertools 0.14.0",
|
||||
|
|
@ -3248,7 +3151,7 @@ dependencies = [
|
|||
"imgref",
|
||||
"loop9",
|
||||
"quick-error",
|
||||
"rav1e 0.8.1",
|
||||
"rav1e",
|
||||
"rgb",
|
||||
]
|
||||
|
||||
|
|
@ -3543,12 +3446,6 @@ dependencies = [
|
|||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scan_fmt"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b53b0a5db882a8e2fdaae0a43f7b39e7e9082389e978398bdf223a55b581248"
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
|
|
@ -3705,15 +3602,6 @@ dependencies = [
|
|||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
|
|
@ -4004,26 +3892,13 @@ dependencies = [
|
|||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
|
||||
dependencies = [
|
||||
"cfg-expr 0.15.8",
|
||||
"heck",
|
||||
"pkg-config",
|
||||
"toml 0.8.23",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "7.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
|
||||
dependencies = [
|
||||
"cfg-expr 0.20.9",
|
||||
"cfg-expr",
|
||||
"heck",
|
||||
"pkg-config",
|
||||
"toml 1.1.4+spec-1.1.0",
|
||||
|
|
@ -4036,12 +3911,6 @@ version = "1.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
|
|
@ -4165,18 +4034,6 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.12+spec-1.1.0"
|
||||
|
|
@ -4185,7 +4042,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
|||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"serde_core",
|
||||
"serde_spanned 1.1.1",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
|
|
@ -4200,22 +4057,13 @@ checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
|
|||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"serde_core",
|
||||
"serde_spanned 1.1.1",
|
||||
"serde_spanned",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
|
|
@ -4234,19 +4082,6 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
|
|
@ -4438,14 +4273,15 @@ dependencies = [
|
|||
"image",
|
||||
"jpeg-encoder",
|
||||
"jpegxl-rs",
|
||||
"libavif-image",
|
||||
"jpegxl-sys",
|
||||
"libavif-sys",
|
||||
"ndarray",
|
||||
"num-bigint",
|
||||
"ogg",
|
||||
"opus",
|
||||
"ort",
|
||||
"pkg-config",
|
||||
"rav1e 0.8.1",
|
||||
"rav1e",
|
||||
"rustls",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
|
|
@ -4971,9 +4807,6 @@ name = "winnow"
|
|||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
|
|
|
|||
|
|
@ -40,8 +40,9 @@ serde_json = { version = "1", features = ["preserve_order", "raw_value"] }
|
|||
# Image
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "avif"], optional = true } # PNG, JPEG decode, AVIF encode (via ravif)
|
||||
jpeg-encoder = { version = "0.7", optional = true } # pure-Rust JPEG with optimized Huffman tables
|
||||
libavif-image = { version = "0.14", optional = true } # AVIF decode via system libavif (fixes silent-empty decode bug)
|
||||
libavif-sys = { version = "0.17", optional = true, default-features = false, features = ["codec-dav1d"] } # AVIF decode through libavif itself, with the reader limits as its decoder limits
|
||||
jpegxl-rs = { version = "0.14", optional = true } # libjxl bindings (links system libjxl; 0.11.x and 0.12.x both work)
|
||||
jpegxl-sys = { version = "0.12", optional = true } # libjxl itself, to read a payload's declared size before it decodes
|
||||
# Audio
|
||||
opus = { version = "0.3", optional = true } # libopus bindings (lossy)
|
||||
ogg = { version = "0.9", optional = true } # Ogg container for standard .opus interop
|
||||
|
|
@ -82,8 +83,7 @@ sha2 = "0.10"
|
|||
# Writes an actual signed manifest carrying the energy + reconstruction
|
||||
# bindings, rather than only defining their shapes. Off by default: the SDK
|
||||
# is a large dependency chain and most consumers only need the types.
|
||||
c2pa = { version = "0.90", optional = true, default-features = false,
|
||||
features = ["rust_native_crypto"] }
|
||||
c2pa = { version = "0.90", optional = true, default-features = false, features = ["rust_native_crypto"] }
|
||||
|
||||
# ---- interactive worlds (optional, behind the `world` feature) ----
|
||||
# extensions/interactive-worlds: WWLD container, signed CausalOps,
|
||||
|
|
@ -116,7 +116,7 @@ default = ["codecs"]
|
|||
# Disable for targets where the C codec chain can't follow (wasm32) —
|
||||
# the envelope, manifest, and `world` engine build without it.
|
||||
codecs = [
|
||||
"dep:image", "dep:jpeg-encoder", "dep:libavif-image", "dep:jpegxl-rs",
|
||||
"dep:image", "dep:jpeg-encoder", "dep:libavif-sys", "dep:jpegxl-rs", "dep:jpegxl-sys",
|
||||
"dep:opus", "dep:ogg", "dep:claxon", "dep:flac-bound",
|
||||
"dep:rav1e", "dep:dav1d",
|
||||
"dep:zstd", "dep:xz2", "dep:byteorder",
|
||||
|
|
@ -1131,6 +1131,21 @@ name = "text_decode_bounds"
|
|||
path = "tests/text_decode_bounds.rs"
|
||||
required-features = ["codecs"]
|
||||
|
||||
[[test]]
|
||||
name = "media_decode_bounds"
|
||||
path = "tests/media_decode_bounds.rs"
|
||||
required-features = ["codecs"]
|
||||
|
||||
[[test]]
|
||||
name = "container_reservations"
|
||||
path = "tests/container_reservations.rs"
|
||||
required-features = ["world", "audio_scene", "film", "score", "haptic", "splat4d"]
|
||||
|
||||
[[test]]
|
||||
name = "extension_duration_limits"
|
||||
path = "tests/extension_duration_limits.rs"
|
||||
required-features = ["world", "audio_scene", "film", "score", "haptic", "splat4d"]
|
||||
|
||||
[[test]]
|
||||
name = "text_decode_memory"
|
||||
path = "tests/text_decode_memory.rs"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Cross-platform linker paths for the C libraries we wrap.
|
||||
//!
|
||||
//! Most of our codec deps (`opus`, `jpegxl-rs`, `dav1d`, `zstd`, `xz2`,
|
||||
//! `libavif-image`) already have build.rs files that probe pkg-config
|
||||
//! `libavif-sys`) already have build.rs files that probe pkg-config
|
||||
//! themselves. `flac-bound` does NOT — it just links `-lflac` and
|
||||
//! expects the linker to find it. On Homebrew macOS that lives under
|
||||
//! `/opt/homebrew/opt/flac/lib`, which Cargo doesn't probe. We use the
|
||||
|
|
|
|||
4
wai/wai-rs/fuzz/.gitignore
vendored
Normal file
4
wai/wai-rs/fuzz/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
target/
|
||||
corpus/
|
||||
artifacts/
|
||||
coverage/
|
||||
1820
wai/wai-rs/fuzz/Cargo.lock
generated
Normal file
1820
wai/wai-rs/fuzz/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
64
wai/wai-rs/fuzz/Cargo.toml
Normal file
64
wai/wai-rs/fuzz/Cargo.toml
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Coverage-guided fuzzing of the reader: the envelope and manifest parsers, the integer
|
||||
# payload parsers against the committed parameter sets, every media decoder, and the
|
||||
# extension containers.
|
||||
# `run.sh` builds the seed corpora and runs each target; see the header there.
|
||||
[package]
|
||||
name = "wai-fuzz"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
serde_json = "1"
|
||||
wai = { path = "..", features = ["codecs", "video_int", "audio_int", "splat_int", "world", "film", "score", "haptic", "splat4d", "audio_scene"] }
|
||||
|
||||
# A workspace of its own: cargo-fuzz builds with sanitizer and coverage flags that
|
||||
# wai-rs's own builds must not share.
|
||||
[workspace]
|
||||
members = ["."]
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "envelope"
|
||||
path = "fuzz_targets/envelope.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "int_payload"
|
||||
path = "fuzz_targets/int_payload.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "media"
|
||||
path = "fuzz_targets/media.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "containers"
|
||||
path = "fuzz_targets/containers.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
# Writes the seed corpora (run.sh runs it); not a fuzz target.
|
||||
[[bin]]
|
||||
name = "seeds"
|
||||
path = "src/bin/seeds.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[profile.release]
|
||||
debug = 1
|
||||
11
wai/wai-rs/fuzz/fuzz_targets/containers.rs
Normal file
11
wai/wai-rs/fuzz/fuzz_targets/containers.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
//! The extension containers' readers (world, causal op, film, reel, score, haptic,
|
||||
//! splat4d, audio scene): the first byte picks one (`wai_fuzz::CONTAINERS`), the rest
|
||||
//! is its bytes.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Some((&pick, bytes)) = data.split_first() {
|
||||
wai_fuzz::container_read(pick, bytes);
|
||||
}
|
||||
});
|
||||
11
wai/wai-rs/fuzz/fuzz_targets/envelope.rs
Normal file
11
wai/wai-rs/fuzz/fuzz_targets/envelope.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
//! The envelope readers: version detection, `WAI1` and `WAI2` (container, manifest,
|
||||
//! rendition table) and the parameter-set listing form.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let _ = wai::container::detect_version(data);
|
||||
let _ = wai::Wai::from_bytes(data);
|
||||
let _ = wai::container::WaiMulti::from_bytes(data);
|
||||
let _ = wai::pin::parse_listing(data);
|
||||
});
|
||||
25
wai/wai-rs/fuzz/fuzz_targets/int_payload.rs
Normal file
25
wai/wai-rs/fuzz/fuzz_targets/int_payload.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
//! The integer payload decoders (`int_payload::decode_int`), each against a parameter
|
||||
//! set the conformance corpus commits: the first byte picks the (capability, set) pair,
|
||||
//! the rest is the payload.
|
||||
#![no_main]
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wai::int_payload::{IntParams, decode_int, resolve};
|
||||
use wai::pin::HeldBytes;
|
||||
|
||||
fn entries() -> &'static [wai_fuzz::IntEntry] {
|
||||
static E: OnceLock<Vec<wai_fuzz::IntEntry>> = OnceLock::new();
|
||||
E.get_or_init(wai_fuzz::int_entries)
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Some((&pick, payload)) = data.split_first() else { return };
|
||||
let es = entries();
|
||||
let e = &es[pick as usize % es.len()];
|
||||
let view: BTreeMap<String, &[u8]> = e.set.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect();
|
||||
let params = resolve(&e.cap, None, &[HeldBytes::Set { listing: b"", files: &view }], None)
|
||||
.unwrap_or_else(|_| IntParams::none());
|
||||
let _ = decode_int(&e.cap, payload, ¶ms);
|
||||
});
|
||||
10
wai/wai-rs/fuzz/fuzz_targets/media.rs
Normal file
10
wai/wai-rs/fuzz/fuzz_targets/media.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
//! The picture, audio, video and text decoders of the floor and the recommended set:
|
||||
//! the first byte picks the decoder (`wai_fuzz::MEDIA`), the rest is the payload.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Some((&pick, payload)) = data.split_first() {
|
||||
wai_fuzz::media_decode(pick, payload);
|
||||
}
|
||||
});
|
||||
60
wai/wai-rs/fuzz/run.sh
Executable file
60
wai/wai-rs/fuzz/run.sh
Executable file
|
|
@ -0,0 +1,60 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# run.sh — coverage-guided fuzzing of the reader (libFuzzer through cargo-fuzz).
|
||||
#
|
||||
# run.sh [seconds-per-target] [target ...]
|
||||
# (default 600; envelope int_payload media containers)
|
||||
#
|
||||
# Targets:
|
||||
# envelope version detection, the WAI1 and WAI2 readers, the listing form
|
||||
# int_payload int_payload::decode_int against every committed parameter set
|
||||
# media the picture, audio, video and text decoders of the floor and the
|
||||
# recommended set
|
||||
# containers the extension containers: world, causal op, film, reel, score,
|
||||
# haptic, splat4d, audio scene
|
||||
#
|
||||
# Builds the seed corpora from the repository (`seeds`), then runs each target for
|
||||
# the time given, adding what it finds to the corpus. The corpus lives in
|
||||
# $WAI_FUZZ_CORPUS (default fuzz/corpus): point it at a directory that persists between
|
||||
# runs and each run starts where the last left off. A crash, a timeout (60 s) or an
|
||||
# input that takes more than 4 GiB stops that target; its input lands in
|
||||
# fuzz/artifacts/<target>/, the script carries on with the next target, and exits 1.
|
||||
# A target that fails without leaving an input (it did not build, or cargo-fuzz could
|
||||
# not run) is a tool failure, not a find: exit 2.
|
||||
# Turn a find into a regression test: `cargo +nightly fuzz tmin <target> <artifact>`,
|
||||
# then add the minimised input to tests/fuzz_smoke.rs.
|
||||
#
|
||||
# Needs a nightly toolchain and cargo-fuzz (`cargo install cargo-fuzz`). Heavy: run it
|
||||
# on the build host, under memguard, never on the dev Mac.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
SECS=${1:-600}
|
||||
shift || true
|
||||
TARGETS=${*:-envelope int_payload media containers}
|
||||
CORPUS=${WAI_FUZZ_CORPUS:-corpus}
|
||||
TOOLCHAIN=${WAI_FUZZ_TOOLCHAIN:-nightly}
|
||||
|
||||
cargo +"$TOOLCHAIN" run --quiet --release --bin seeds -- "$CORPUS" || exit 2
|
||||
found=() broken=()
|
||||
for t in $TARGETS; do
|
||||
echo "== $t: ${SECS}s"
|
||||
mkdir -p "$CORPUS/$t" "artifacts/$t"
|
||||
before=$(ls "artifacts/$t" | wc -l)
|
||||
# --no-cfg-fuzzing: rav1e (the AV1 encoder wai links) depends on libfuzzer-sys under
|
||||
# cfg(fuzzing), which would leave wai's cdylib with an undefined libFuzzer symbol. The
|
||||
# targets use libfuzzer-sys directly and need no cfg.
|
||||
if ! cargo +"$TOOLCHAIN" fuzz run --no-cfg-fuzzing "$t" "$CORPUS/$t" -- \
|
||||
-max_total_time="$SECS" -timeout=60 -rss_limit_mb=4096 -malloc_limit_mb=4096 \
|
||||
-max_len=1048576 -print_final_stats=1; then
|
||||
if [ "$(ls "artifacts/$t" | wc -l)" -gt "$before" ]; then found+=("$t"); else broken+=("$t"); fi
|
||||
fi
|
||||
done
|
||||
if [ ${#broken[@]} -gt 0 ]; then
|
||||
echo "run.sh: did not run: ${broken[*]} (see the output above)" >&2
|
||||
fi
|
||||
if [ ${#found[@]} -gt 0 ]; then
|
||||
echo "run.sh: found something in: ${found[*]}; inputs in fuzz/artifacts/" >&2
|
||||
exit 1
|
||||
fi
|
||||
[ ${#broken[@]} -eq 0 ] || exit 2
|
||||
echo "run.sh: ${TARGETS} ran ${SECS}s each and found nothing"
|
||||
90
wai/wai-rs/fuzz/src/bin/seeds.rs
Normal file
90
wai/wai-rs/fuzz/src/bin/seeds.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
//! Writes the seed corpora into the directory given (default `corpus`), one file per
|
||||
//! seed, named by its FNV-1a hash so a rerun adds nothing it already holds:
|
||||
//!
|
||||
//! - `envelope/`: every committed `.wai` under `wai/` of at most 512 KiB;
|
||||
//! - `int_payload/`: every payload the integer corpus commits, after its entry's
|
||||
//! selector byte;
|
||||
//! - `media/`: one small valid payload for each decoder, made with the reference
|
||||
//! writers, after its selector byte;
|
||||
//! - `containers/`: every committed file that begins with an extension container's
|
||||
//! magic, after that container's selector byte.
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn put(dir: &Path, bytes: &[u8]) {
|
||||
let mut h = 0xcbf2_9ce4_8422_2325u64;
|
||||
for &b in bytes {
|
||||
h = (h ^ b as u64).wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join(format!("{h:016x}")), bytes).unwrap();
|
||||
}
|
||||
|
||||
/// Every file under `dir` of at most 512 KiB, skipping build and dependency trees.
|
||||
fn files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let p = e.path();
|
||||
let name = e.file_name();
|
||||
if p.is_dir() {
|
||||
if !matches!(name.to_str(), Some("target" | "node_modules" | "fuzz" | ".git")) {
|
||||
files(&p, out);
|
||||
}
|
||||
} else if e.metadata().is_ok_and(|m| m.len() <= 512 * 1024) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::args().nth(1).unwrap_or_else(|| "corpus".into()));
|
||||
|
||||
let mut all = Vec::new();
|
||||
files(&wai_fuzz::wai_dir(), &mut all);
|
||||
let (mut n_env, mut n_con) = (0, 0);
|
||||
for f in &all {
|
||||
if f.extension().is_some_and(|x| x == "wai") {
|
||||
put(&root.join("envelope"), &std::fs::read(f).unwrap());
|
||||
n_env += 1;
|
||||
}
|
||||
// A committed file that begins with a container's magic seeds that container.
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
for (i, (_, magic)) in wai_fuzz::CONTAINERS.iter().enumerate() {
|
||||
if bytes.starts_with(&magic[..]) {
|
||||
put(&root.join("containers"), &[&[i as u8][..], &bytes].concat());
|
||||
n_con += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let entries = wai_fuzz::int_entries();
|
||||
let mut n_int = 0;
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
for p in &e.payloads {
|
||||
put(&root.join("int_payload"), &[&[i as u8][..], p].concat());
|
||||
n_int += 1;
|
||||
}
|
||||
}
|
||||
|
||||
use wai::codecs::{audio, image, text, video};
|
||||
let rgb: Vec<u8> = (0..16 * 16 * 3).map(|i| (i * 7 % 256) as u8).collect();
|
||||
let tone: Vec<f32> = (0..4_800).map(|i| 0.4 * (i as f32 * 0.03).sin()).collect();
|
||||
let words = b"a WAI text payload, repeated: a WAI text payload, repeated.".repeat(8);
|
||||
let media: Vec<(usize, Vec<u8>)> = vec![
|
||||
(0, image::png_encode(&rgb, 16, 16).unwrap()),
|
||||
(1, image::jpeg_encode(&rgb, 16, 16, 80).unwrap()),
|
||||
(2, image::avif_encode(&rgb, 16, 16, 80).unwrap()),
|
||||
(3, image::jxl_encode(&rgb, 16, 16, None).unwrap()),
|
||||
(3, image::jxl_encode(&rgb, 16, 16, Some(80.0)).unwrap()),
|
||||
(4, audio::flac_encode(&tone, 48_000).unwrap()),
|
||||
(5, audio::opus_encode(&tone, 48_000, 32_000).unwrap()),
|
||||
(6, video::av1_encode(&[rgb.clone(), rgb.clone()], 16, 16, 30, 1, false, 60).unwrap()),
|
||||
(7, text::zstd_encode(&words, 3).unwrap()),
|
||||
(8, text::xz_encode(&words, 6).unwrap()),
|
||||
];
|
||||
for (pick, p) in &media {
|
||||
put(&root.join("media"), &[&[*pick as u8][..], p].concat());
|
||||
}
|
||||
println!(
|
||||
"seeds: {n_env} envelopes, {n_int} integer payloads over {} entries, {} media payloads, {n_con} containers, in {}",
|
||||
entries.len(), media.len(), root.display()
|
||||
);
|
||||
}
|
||||
106
wai/wai-rs/fuzz/src/lib.rs
Normal file
106
wai/wai-rs/fuzz/src/lib.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! What the fuzz targets and the seed writer share: the integer-payload entries, read
|
||||
//! from the committed corpus, and the media decoders and extension containers by
|
||||
//! selector byte.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The `wai/` directory.
|
||||
pub fn wai_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
|
||||
}
|
||||
|
||||
/// One capability with one parameter set, as `int-payload-conformance/vectors.json`
|
||||
/// pairs them, and the payloads committed for that pair.
|
||||
pub struct IntEntry {
|
||||
pub cap: String,
|
||||
pub set: BTreeMap<String, Vec<u8>>,
|
||||
pub payloads: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// The distinct (capability, parameter set) pairs of the committed corpus, in the
|
||||
/// order the index first names them. A fuzz input's first byte picks one.
|
||||
pub fn int_entries() -> Vec<IntEntry> {
|
||||
let dir = wai_dir();
|
||||
let read = |rel: &str| std::fs::read(dir.join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}"));
|
||||
let idx: serde_json::Value =
|
||||
serde_json::from_slice(&read("int-payload-conformance/vectors.json")).expect("the index parses");
|
||||
let mut out: Vec<IntEntry> = Vec::new();
|
||||
let mut keys: Vec<(String, Vec<(String, String)>)> = Vec::new();
|
||||
for list in ["vectors", "refusals"] {
|
||||
for e in idx[list].as_array().into_iter().flatten() {
|
||||
let cap = e["capability"].as_str().expect("a capability").to_string();
|
||||
let set: Vec<(String, String)> = e["set"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(k, v)| (k.clone(), v.as_str().expect("a path").to_string()))
|
||||
.collect();
|
||||
let key = (cap.clone(), set.clone());
|
||||
let i = match keys.iter().position(|k| *k == key) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
keys.push(key);
|
||||
out.push(IntEntry {
|
||||
cap,
|
||||
set: set.iter().map(|(k, p)| (k.clone(), read(p))).collect(),
|
||||
payloads: Vec::new(),
|
||||
});
|
||||
out.len() - 1
|
||||
}
|
||||
};
|
||||
if let Some(p) = e["payload"].as_str() {
|
||||
out[i].payloads.push(read(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!out.is_empty() && out.len() <= 256, "{} entries: one selector byte must address them", out.len());
|
||||
out
|
||||
}
|
||||
|
||||
/// The media decoders, in selector order.
|
||||
pub const MEDIA: [&str; 9] = ["png", "jpeg", "avif", "jxl", "flac", "opus", "av1", "zstd", "xz"];
|
||||
|
||||
/// Decodes `p` with the decoder `pick` selects, discarding the result: every input must
|
||||
/// come back as a value or a refusal, never as a crash, a hang or unbounded memory.
|
||||
pub fn media_decode(pick: u8, p: &[u8]) {
|
||||
use wai::codecs::{audio, image, text, video};
|
||||
match pick as usize % MEDIA.len() {
|
||||
0 => drop(image::png_decode(p)),
|
||||
1 => drop(image::jpeg_decode(p)),
|
||||
2 => drop(image::avif_decode(p)),
|
||||
3 => drop(image::jxl_decode(p)),
|
||||
4 => drop(audio::flac_decode(p)),
|
||||
5 => drop(audio::opus_decode(p)),
|
||||
// One frame at a time: the whole-output form would only add an allocation.
|
||||
6 => drop(video::av1_decode_each(p, |_, _, _| Ok(()))),
|
||||
7 => drop(text::zstd_decode(p)),
|
||||
_ => drop(text::xz_decode(p)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The extension containers, in selector order, with the magic each begins with.
|
||||
pub const CONTAINERS: [(&str, &[u8; 4]); 8] = [
|
||||
("world", b"WWLD"),
|
||||
("causal op", b"WWLD"),
|
||||
("film", b"WFLM"),
|
||||
("reel", b"WREL"),
|
||||
("score", b"WSCR"),
|
||||
("haptic", b"WHAP"),
|
||||
("splat4d", b"W4DS"),
|
||||
("audio scene", b"WSCN"),
|
||||
];
|
||||
|
||||
/// Reads `p` as the container `pick` selects, discarding the result.
|
||||
pub fn container_read(pick: u8, p: &[u8]) {
|
||||
match pick as usize % CONTAINERS.len() {
|
||||
0 => drop(wai::world::WorldContainer::from_bytes(p)),
|
||||
1 => drop(wai::world::op::CausalOp::from_bytes(p)),
|
||||
2 => drop(wai::film::Film::from_bytes(p)),
|
||||
3 => drop(wai::film::Reel::from_bytes(p)),
|
||||
4 => drop(wai::score::Score::from_bytes(p)),
|
||||
5 => drop(wai::haptic::HapticSignal::from_bytes(p)),
|
||||
6 => drop(wai::splat4d::Splat4dClipData::from_bytes(p)),
|
||||
_ => drop(wai::audio_scene::SpatialScene::from_bytes(p)),
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,25 @@
|
|||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/**
|
||||
* The most samples a reader must accept from one audio payload (SPEC §2, *Reader
|
||||
* limits*), counting every channel: 2^27, 46 minutes of one channel at 48 kHz. Decoded
|
||||
* to `f32` it is 512 MiB. Longer audio is carried as more than one object.
|
||||
*/
|
||||
#define AUDIO_DECODED_MAX_SAMPLES (1 << 27)
|
||||
|
||||
/**
|
||||
* The widest and the tallest picture a reader must accept (SPEC §2, *Reader limits*):
|
||||
* 65 536 pixels, the largest side an AV1 sequence header can declare.
|
||||
*/
|
||||
#define IMAGE_MAX_SIDE (1 << 16)
|
||||
|
||||
/**
|
||||
* The most pixels a reader must accept in one picture (SPEC §2, *Reader limits*):
|
||||
* 2^27, a 16 384 × 8 192 picture. Decoded to 8-bit RGB it is 384 MiB.
|
||||
*/
|
||||
#define IMAGE_MAX_PIXELS (1 << 27)
|
||||
|
||||
/**
|
||||
* The most bytes a `wai.text.zstd` or `wai.text.xz` payload may decode to that a reader
|
||||
* must accept (SPEC §2, *Reader limits*): 2^27. The decoders here write no more than
|
||||
|
|
@ -54,6 +73,23 @@
|
|||
*/
|
||||
#define TEXT_WINDOW_MAX_LEN (1 << TEXT_WINDOW_MAX_LOG)
|
||||
|
||||
/**
|
||||
* The most temporal units a reader must accept in one AV1 payload (SPEC §2, *Reader
|
||||
* limits*): 2^20, 4.9 hours at 60 frames a second. Each frame is held to the picture
|
||||
* limits of [`crate::codecs::image::check_dimensions`].
|
||||
*/
|
||||
#define VIDEO_MAX_TEMPORAL_UNITS (1 << 20)
|
||||
|
||||
/**
|
||||
* What the frames [`av1_decode`] returns may take together: 4 GiB of RGB. A payload
|
||||
* within the reader limits can declare far more (2^20 frames of 2^27 pixels), and the
|
||||
* whole-output entry points keep every frame at once, so they refuse one past their
|
||||
* budget (`video_decoded_too_large`) from its header, before they decode.
|
||||
* [`av1_decode_each`] keeps one frame at a time and decodes any payload within the
|
||||
* reader limits.
|
||||
*/
|
||||
#define AV1_DECODE_DEFAULT_MAX_LEN 4294967296
|
||||
|
||||
/**
|
||||
* The longest manifest, in bytes, a reader must accept (SPEC §2, *Reader limits*):
|
||||
* 2^27 (128 MiB). It is sized to the largest manifest a conforming layer has: a `prev`
|
||||
|
|
@ -80,6 +116,18 @@
|
|||
*/
|
||||
#define MANIFEST_MAX_VALUES (1 << 22)
|
||||
|
||||
/**
|
||||
* The most samples, counting every channel, a reader must be willing to render from
|
||||
* one object: 2^27, the audio limit of SPEC §2 (46 minutes of one channel at 48 kHz).
|
||||
*/
|
||||
#define RENDER_MAX_SAMPLES (1 << 27)
|
||||
|
||||
/**
|
||||
* The most frames a reader must be willing to render from one object: 2^20, the
|
||||
* video limit of SPEC §2 (4.9 hours at 60 frames a second).
|
||||
*/
|
||||
#define RENDER_MAX_FRAMES (1 << 20)
|
||||
|
||||
#define WAI_OK 0
|
||||
|
||||
#define WAI_ERR_INVALID_ARG 1
|
||||
|
|
@ -521,6 +569,9 @@ int wai_int_payload_declared(const char *capability,
|
|||
* *Candidate capabilities*), and a payload that is not the format SPEC §5 pins its
|
||||
* integer capability or fallback to (*Payloads of the WAI-native integer codecs*):
|
||||
* it emits through `Wai::to_emit_bytes`.
|
||||
*
|
||||
* # Safety
|
||||
* `manifest_json` NUL-terminated; `payload` valid for `payload_len` bytes; `out` writable.
|
||||
*/
|
||||
|
||||
int wai_envelope_pack(const char *manifest_json,
|
||||
|
|
@ -531,6 +582,9 @@ int wai_envelope_pack(const char *manifest_json,
|
|||
/**
|
||||
* Detect WAI version from a byte stream. Returns 1 (WAI1), 2 (WAI2),
|
||||
* or 0 with an error message in `wai_last_error` for unknown magic.
|
||||
*
|
||||
* # Safety
|
||||
* `bytes` valid for `len` bytes.
|
||||
*/
|
||||
int wai_envelope_detect_version(const uint8_t *bytes, uintptr_t len);
|
||||
|
||||
|
|
@ -574,6 +628,9 @@ int wai_envelope_pack_multi(const char *manifest_json,
|
|||
* This avoids exposing a C struct array (and the ownership pain that
|
||||
* comes with it). Any JSON parser the consumer already has decodes
|
||||
* the rendition table in two lines.
|
||||
*
|
||||
* # Safety
|
||||
* `bytes` valid for `len` bytes; `out_manifest` writable; `out_renditions_json` writable; `out_payload_block` writable.
|
||||
*/
|
||||
|
||||
int wai_envelope_unpack_multi(const uint8_t *bytes,
|
||||
|
|
@ -586,6 +643,9 @@ int wai_envelope_unpack_multi(const uint8_t *bytes,
|
|||
* Parses a WAI envelope. Writes the manifest JSON exactly as the envelope
|
||||
* carries it to `out_manifest` (so `security`, and members this library does not
|
||||
* model, survive) and the raw payload to `out_payload`.
|
||||
*
|
||||
* # Safety
|
||||
* `bytes` valid for `len` bytes; `out_manifest` writable; `out_payload` writable.
|
||||
*/
|
||||
|
||||
int wai_envelope_unpack(const uint8_t *bytes,
|
||||
|
|
@ -631,6 +691,15 @@ int wai_image_png_encode(const uint8_t *rgb,
|
|||
|
||||
/**
|
||||
* Decode a PNG byte stream to RGB. Returns `(rgb, h, w)`.
|
||||
*
|
||||
* A picture past the reader limits of SPEC §2 (wider or taller than
|
||||
* [`IMAGE_MAX_SIDE`](crate::codecs::image::IMAGE_MAX_SIDE) or holding more than
|
||||
* [`IMAGE_MAX_PIXELS`](crate::codecs::image::IMAGE_MAX_PIXELS) pixels) is refused with
|
||||
* `WAI_ERR_CODEC` from the size it declares, before it decodes; the message begins
|
||||
* `image_too_large`.
|
||||
*
|
||||
* # Safety
|
||||
* `bytes` valid for `len` bytes; `out` writable; `out_h` writable; `out_w` writable.
|
||||
*/
|
||||
|
||||
int wai_image_png_decode(const uint8_t *bytes,
|
||||
|
|
@ -639,6 +708,10 @@ int wai_image_png_decode(const uint8_t *bytes,
|
|||
uint32_t *out_h,
|
||||
uint32_t *out_w);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `rgb` valid for `len` bytes; `out` writable.
|
||||
*/
|
||||
|
||||
int wai_image_avif_encode(const uint8_t *rgb,
|
||||
uintptr_t len,
|
||||
|
|
@ -647,6 +720,16 @@ int wai_image_avif_encode(const uint8_t *rgb,
|
|||
uint8_t quality,
|
||||
struct wai_buffer_t *out);
|
||||
|
||||
/**
|
||||
* A picture past the reader limits of SPEC §2 (wider or taller than
|
||||
* [`IMAGE_MAX_SIDE`](crate::codecs::image::IMAGE_MAX_SIDE) or holding more than
|
||||
* [`IMAGE_MAX_PIXELS`](crate::codecs::image::IMAGE_MAX_PIXELS) pixels) is refused with
|
||||
* `WAI_ERR_CODEC` from the size it declares, before it decodes; the message begins
|
||||
* `image_too_large`.
|
||||
*
|
||||
* # Safety
|
||||
* `bytes` valid for `len` bytes; `out` writable; `out_h` writable; `out_w` writable.
|
||||
*/
|
||||
|
||||
int wai_image_avif_decode(const uint8_t *bytes,
|
||||
uintptr_t len,
|
||||
|
|
@ -657,6 +740,9 @@ int wai_image_avif_decode(const uint8_t *bytes,
|
|||
/**
|
||||
* `quality = 0` ⇒ lossless. Otherwise butteraugli distance proxy
|
||||
* (higher quality = smaller distance internally).
|
||||
*
|
||||
* # Safety
|
||||
* `rgb` valid for `len` bytes; `out` writable.
|
||||
*/
|
||||
|
||||
int wai_image_jxl_encode(const uint8_t *rgb,
|
||||
|
|
@ -666,6 +752,16 @@ int wai_image_jxl_encode(const uint8_t *rgb,
|
|||
uint8_t quality,
|
||||
struct wai_buffer_t *out);
|
||||
|
||||
/**
|
||||
* A picture past the reader limits of SPEC §2 (wider or taller than
|
||||
* [`IMAGE_MAX_SIDE`](crate::codecs::image::IMAGE_MAX_SIDE) or holding more than
|
||||
* [`IMAGE_MAX_PIXELS`](crate::codecs::image::IMAGE_MAX_PIXELS) pixels) is refused with
|
||||
* `WAI_ERR_CODEC` from the size it declares, before it decodes; the message begins
|
||||
* `image_too_large`.
|
||||
*
|
||||
* # Safety
|
||||
* `bytes` valid for `len` bytes; `out` writable; `out_h` writable; `out_w` writable.
|
||||
*/
|
||||
|
||||
int wai_image_jxl_decode(const uint8_t *bytes,
|
||||
uintptr_t len,
|
||||
|
|
@ -673,6 +769,10 @@ int wai_image_jxl_decode(const uint8_t *bytes,
|
|||
uint32_t *out_h,
|
||||
uint32_t *out_w);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `samples` valid for `n` `f32`s; `out` writable.
|
||||
*/
|
||||
|
||||
int wai_audio_opus_encode(const float *samples,
|
||||
uintptr_t n,
|
||||
|
|
@ -680,24 +780,50 @@ int wai_audio_opus_encode(const float *samples,
|
|||
int32_t bitrate_bps,
|
||||
struct wai_buffer_t *out);
|
||||
|
||||
/**
|
||||
* Output past the reader limit of SPEC §2,
|
||||
* [`AUDIO_DECODED_MAX_SAMPLES`](crate::codecs::audio::AUDIO_DECODED_MAX_SAMPLES)
|
||||
* samples, is refused with `WAI_ERR_CODEC`; the message begins
|
||||
* `audio_decoded_too_long`.
|
||||
*
|
||||
* # Safety
|
||||
* `bytes` valid for `len` bytes; `out` writable; `out_sr` writable.
|
||||
*/
|
||||
|
||||
int wai_audio_opus_decode(const uint8_t *bytes,
|
||||
uintptr_t len,
|
||||
struct wai_buffer_t *out,
|
||||
uint32_t *out_sr);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `samples` valid for `n` `f32`s; `out` writable.
|
||||
*/
|
||||
|
||||
int wai_audio_flac_encode(const float *samples,
|
||||
uintptr_t n,
|
||||
uint32_t sr,
|
||||
struct wai_buffer_t *out);
|
||||
|
||||
/**
|
||||
* Output past the reader limit of SPEC §2,
|
||||
* [`AUDIO_DECODED_MAX_SAMPLES`](crate::codecs::audio::AUDIO_DECODED_MAX_SAMPLES)
|
||||
* samples, is refused with `WAI_ERR_CODEC`; the message begins
|
||||
* `audio_decoded_too_long`.
|
||||
*
|
||||
* # Safety
|
||||
* `bytes` valid for `len` bytes; `out` writable; `out_sr` writable.
|
||||
*/
|
||||
|
||||
int wai_audio_flac_decode(const uint8_t *bytes,
|
||||
uintptr_t len,
|
||||
struct wai_buffer_t *out,
|
||||
uint32_t *out_sr);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `data` valid for `len` bytes; `out` writable.
|
||||
*/
|
||||
|
||||
int wai_text_zstd_encode(const uint8_t *data,
|
||||
uintptr_t len,
|
||||
|
|
@ -710,9 +836,16 @@ int wai_text_zstd_encode(const uint8_t *data,
|
|||
* and [`TEXT_WINDOW_MAX_LEN`](crate::codecs::text::TEXT_WINDOW_MAX_LEN) bytes of
|
||||
* window. A payload past either is refused with `WAI_ERR_CODEC`; the message begins
|
||||
* `text_decoded_too_long` or `text_window_too_large`.
|
||||
*
|
||||
* # Safety
|
||||
* `bytes` valid for `len` bytes; `out` writable.
|
||||
*/
|
||||
int wai_text_zstd_decode(const uint8_t *bytes, uintptr_t len, struct wai_buffer_t *out);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `data` valid for `len` bytes; `out` writable.
|
||||
*/
|
||||
|
||||
int wai_text_xz_encode(const uint8_t *data,
|
||||
uintptr_t len,
|
||||
|
|
@ -722,11 +855,17 @@ int wai_text_xz_encode(const uint8_t *data,
|
|||
/**
|
||||
* Decode a `wai.text.xz` payload within the same limits as
|
||||
* [`wai_text_zstd_decode`], the window limit applying to the dictionary.
|
||||
*
|
||||
* # Safety
|
||||
* `bytes` valid for `len` bytes; `out` writable.
|
||||
*/
|
||||
int wai_text_xz_decode(const uint8_t *bytes, uintptr_t len, struct wai_buffer_t *out);
|
||||
|
||||
/**
|
||||
* Encode a sequence of `n` RGB frames as AV1.
|
||||
*
|
||||
* # Safety
|
||||
* `rgb` valid for `len` bytes; `out` writable.
|
||||
*/
|
||||
|
||||
int wai_video_av1_encode(const uint8_t *rgb,
|
||||
|
|
@ -742,6 +881,17 @@ int wai_video_av1_encode(const uint8_t *rgb,
|
|||
|
||||
/**
|
||||
* Decode an AV1 WAI payload to a packed RGB buffer `n*h*w*3` bytes.
|
||||
*
|
||||
* A payload past the reader limits of SPEC §2 (more than
|
||||
* [`VIDEO_MAX_TEMPORAL_UNITS`](crate::codecs::video::VIDEO_MAX_TEMPORAL_UNITS)
|
||||
* temporal units, or frames past the picture limits) is refused from its header;
|
||||
* the message begins `video_too_large`. Since the buffer holds every frame, so is
|
||||
* one whose frames would take more than
|
||||
* [`AV1_DECODE_DEFAULT_MAX_LEN`](crate::codecs::video::AV1_DECODE_DEFAULT_MAX_LEN)
|
||||
* bytes together (`video_decoded_too_large`), and one whose frames differ in size.
|
||||
*
|
||||
* # Safety
|
||||
* `bytes` valid for `len` bytes; `out` writable; `out_n` writable; `out_h` writable; `out_w` writable; `out_fps_num` writable; `out_fps_den` writable.
|
||||
*/
|
||||
|
||||
int wai_video_av1_decode(const uint8_t *bytes,
|
||||
|
|
|
|||
|
|
@ -45,12 +45,20 @@ impl SceneContract {
|
|||
if sample_rate == 0 || sample_rate > u32::MAX as u64 {
|
||||
return Err(AudioError::BadContract("sample_rate out of range".into()));
|
||||
}
|
||||
// The mandatory floor is a stereo mixdown: `duration` samples on each of two
|
||||
// channels. Richer layouts are renders the sink chooses.
|
||||
let duration = u("duration")?;
|
||||
let samples = duration.saturating_mul(2);
|
||||
if samples > crate::limits::RENDER_MAX_SAMPLES {
|
||||
return Err(AudioError::BadContract(crate::limits::duration_too_long(
|
||||
"samples in its stereo floor (duration × 2)", samples, crate::limits::RENDER_MAX_SAMPLES)));
|
||||
}
|
||||
Ok(SceneContract {
|
||||
sample_rate: sample_rate as u32,
|
||||
numeric: s("numeric")?,
|
||||
pan_law: s("pan_law")?,
|
||||
distance: s("distance")?,
|
||||
duration: u("duration")?,
|
||||
duration,
|
||||
floor_layout: s("floor_layout")?,
|
||||
})
|
||||
}
|
||||
|
|
@ -110,7 +118,9 @@ pub fn object_table_bytes(objects: &[AudioObject]) -> Vec<u8> {
|
|||
fn parse_object_table(bytes: &[u8]) -> Result<Vec<AudioObject>, AudioError> {
|
||||
let mut c = Cur { b: bytes, p: 0 };
|
||||
let n = c.u32()? as usize;
|
||||
let mut objs = Vec::with_capacity(n);
|
||||
// Reserve no more entries than the bytes left could hold: the count is the input's,
|
||||
// and a reservation sized by it alone let a few bytes ask for gigabytes.
|
||||
let mut objs = Vec::with_capacity(n.min(bytes.len() / (32 + 8 + 2)));
|
||||
for _ in 0..n {
|
||||
let source_hash = c.arr::<32>()?;
|
||||
let gain = Fx::from_bits(c.i64()?);
|
||||
|
|
@ -144,7 +154,9 @@ fn encode_assets(a: &[AssetEntry]) -> Vec<u8> {
|
|||
fn parse_assets(bytes: &[u8]) -> Result<Vec<AssetEntry>, AudioError> {
|
||||
let mut c = Cur { b: bytes, p: 0 };
|
||||
let n = c.u32()? as usize;
|
||||
let mut out = Vec::with_capacity(n);
|
||||
// Reserve no more entries than the bytes left could hold: the count is the input's,
|
||||
// and a reservation sized by it alone let a few bytes ask for gigabytes.
|
||||
let mut out = Vec::with_capacity(n.min(bytes.len() / (32 + 1 + 4)));
|
||||
for _ in 0..n {
|
||||
let hash = c.arr::<32>()?;
|
||||
let role = c.u8()?;
|
||||
|
|
|
|||
|
|
@ -318,14 +318,23 @@ fn decode(input: &str, out: &str) -> Result<(), String> {
|
|||
fs::write(out, &d).map_err(|e| e.to_string())?;
|
||||
}
|
||||
c if c == CAP_VIDEO_AV1 || c == CAP_VIDEO_AV1_LOSSLESS => {
|
||||
let (frames, h, w, fps_num, fps_den) =
|
||||
video::av1_decode(&wai.payload).map_err(|e| format!("{:?}", e))?;
|
||||
let n = frames.len() as u32;
|
||||
let mut packed = Vec::with_capacity(frames.len() * (h * w * 3) as usize);
|
||||
for f in &frames { packed.extend_from_slice(f); }
|
||||
fs::write(out, &packed).map_err(|e| e.to_string())?;
|
||||
// One frame at a time to the file: a payload within the reader limits can
|
||||
// decode to far more than fits in memory at once.
|
||||
use std::io::Write as _;
|
||||
let hd = video::av1_header(&wai.payload).map_err(|e| format!("{:?}", e))?;
|
||||
let mut file = std::io::BufWriter::new(fs::File::create(out).map_err(|e| e.to_string())?);
|
||||
let mut n = 0u32;
|
||||
video::av1_decode_each(&wai.payload, |rgb, w, h| {
|
||||
if (w, h) != (hd.w, hd.h) {
|
||||
return Err(video::VideoError(format!(
|
||||
"a {w}×{h} frame in a payload of {}×{} frames; the packed output holds one size", hd.w, hd.h)));
|
||||
}
|
||||
n += 1;
|
||||
file.write_all(rgb).map_err(|e| video::VideoError(e.to_string()))
|
||||
}).map_err(|e| format!("{:?}", e))?;
|
||||
file.flush().map_err(|e| e.to_string())?;
|
||||
fs::write(format!("{out}.meta"),
|
||||
format!("{n} {h} {w} {fps_num} {fps_den}\n"))
|
||||
format!("{n} {} {} {} {}\n", hd.h, hd.w, hd.fps_num, hd.fps_den))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
other => return Err(format!("unsupported capability for CLI decode: {other}")),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,17 @@ impl<E: std::fmt::Display> From<E> for AudioError {
|
|||
|
||||
pub type Result<T> = std::result::Result<T, AudioError>;
|
||||
|
||||
/// The most samples a reader must accept from one audio payload (SPEC §2, *Reader
|
||||
/// limits*), counting every channel: 2^27, 46 minutes of one channel at 48 kHz. Decoded
|
||||
/// to `f32` it is 512 MiB. Longer audio is carried as more than one object.
|
||||
pub const AUDIO_DECODED_MAX_SAMPLES: usize = 1 << 27;
|
||||
|
||||
fn too_long(what: &str, max: usize) -> AudioError {
|
||||
AudioError(format!(
|
||||
"audio_decoded_too_long: the payload {what} more than {max} samples, the most this reader accepts (SPEC §2)"
|
||||
))
|
||||
}
|
||||
|
||||
// ---- Opus (lossy) ---------------------------------------------------
|
||||
// Opus only supports specific sample rates (8/12/16/24/48 kHz) and
|
||||
// fixed frame sizes. We frame at 20 ms (the Opus default) and resample
|
||||
|
|
@ -33,6 +44,10 @@ fn opus_frame_size(sr: u32) -> usize { (sr as usize) * OPUS_FRAME_MS / 1000 }
|
|||
/// libopus only accepts 8/12/16/24/48 kHz. Other rates MUST be
|
||||
/// resampled upstream.
|
||||
pub fn opus_encode(samples: &[f32], sr: u32, bitrate_bps: i32) -> Result<Vec<u8>> {
|
||||
// A conforming encoder writes no payload a conforming reader may refuse (SPEC §2).
|
||||
if samples.len() > AUDIO_DECODED_MAX_SAMPLES {
|
||||
return Err(too_long("would decode to", AUDIO_DECODED_MAX_SAMPLES));
|
||||
}
|
||||
if !matches!(sr, 8_000 | 12_000 | 16_000 | 24_000 | 48_000) {
|
||||
return Err(AudioError(format!(
|
||||
"opus only supports sr in {{8000,12000,16000,24000,48000}} \
|
||||
|
|
@ -93,7 +108,15 @@ pub fn opus_encode(samples: &[f32], sr: u32, bitrate_bps: i32) -> Result<Vec<u8>
|
|||
Ok(out.into_inner())
|
||||
}
|
||||
|
||||
/// Decodes an Ogg Opus payload (RFC 7845) to mono `f32` samples within the reader
|
||||
/// limit of SPEC §2, [`AUDIO_DECODED_MAX_SAMPLES`].
|
||||
pub fn opus_decode(bytes: &[u8]) -> Result<(Vec<f32>, u32)> {
|
||||
opus_decode_within(bytes, AUDIO_DECODED_MAX_SAMPLES)
|
||||
}
|
||||
|
||||
/// [`opus_decode`] with the sample limit the caller gives (`audio_decoded_too_long`).
|
||||
/// While it decodes, it holds at most that many samples, the pre-skip and one packet.
|
||||
pub fn opus_decode_within(bytes: &[u8], max_samples: usize) -> Result<(Vec<f32>, u32)> {
|
||||
let mut rd = PacketReader::new(Cursor::new(bytes));
|
||||
// First packet: OpusHead — parse for sample-rate + pre-skip
|
||||
let head_pkt = rd.read_packet_expected()
|
||||
|
|
@ -102,7 +125,13 @@ pub fn opus_decode(bytes: &[u8]) -> Result<(Vec<f32>, u32)> {
|
|||
return Err(AudioError("not an Ogg-Opus stream".into()));
|
||||
}
|
||||
let pre_skip = u16::from_le_bytes(head_pkt.data[10..12].try_into().unwrap()) as usize;
|
||||
let sr = u32::from_le_bytes(head_pkt.data[12..16].try_into().unwrap());
|
||||
// OpusHead's input sample rate is informational (RFC 7845 §5.1): it names the rate
|
||||
// the source had, not one the stream needs. Decode at it when libopus can, and at
|
||||
// 48 kHz otherwise, so a stream whose source was 44.1 kHz still decodes.
|
||||
let sr = match u32::from_le_bytes(head_pkt.data[12..16].try_into().unwrap()) {
|
||||
r @ (8_000 | 12_000 | 16_000 | 24_000 | 48_000) => r,
|
||||
_ => 48_000,
|
||||
};
|
||||
// Second packet: OpusTags — skip its content
|
||||
let _ = rd.read_packet_expected()
|
||||
.map_err(|e| AudioError(format!("ogg read OpusTags: {e:?}")))?;
|
||||
|
|
@ -110,6 +139,7 @@ pub fn opus_decode(bytes: &[u8]) -> Result<(Vec<f32>, u32)> {
|
|||
let mut dec = Decoder::new(sr, Channels::Mono)?;
|
||||
let frame = opus_frame_size(sr);
|
||||
let mut buf = vec![0f32; frame * 6];
|
||||
let drop = (pre_skip as u64 * sr as u64 / 48_000) as usize;
|
||||
let mut out: Vec<f32> = Vec::new();
|
||||
let mut last_granule: u64 = 0;
|
||||
loop {
|
||||
|
|
@ -119,17 +149,26 @@ pub fn opus_decode(bytes: &[u8]) -> Result<(Vec<f32>, u32)> {
|
|||
None => break,
|
||||
};
|
||||
let got = dec.decode_float(&pkt.data, &mut buf, false)?;
|
||||
// A packet of a few bytes decodes to up to 120 ms, so the output is bounded
|
||||
// here, as it grows, and not by the payload's length. What is decoded runs
|
||||
// ahead of what is kept by the pre-skip and the last packet's padding, so the
|
||||
// bound allows those; the kept samples are held to the limit below.
|
||||
if out.len() + got > max_samples.saturating_add(drop).saturating_add(buf.len()) {
|
||||
return Err(too_long("decodes to", max_samples));
|
||||
}
|
||||
out.extend_from_slice(&buf[..got]);
|
||||
last_granule = pkt.absgp_page();
|
||||
}
|
||||
// Trim pre-skip (algorithmic delay padding) and any trailing pad
|
||||
// implied by the final granule position. Granule is in 48 kHz; convert
|
||||
// back to input-rate samples.
|
||||
let drop = (pre_skip as u64 * sr as u64 / 48_000) as usize;
|
||||
if drop < out.len() { out.drain(..drop); } else { out.clear(); }
|
||||
let total_at_sr = (last_granule * sr as u64 / 48_000) as usize;
|
||||
let total_at_sr = total_at_sr.saturating_sub(drop);
|
||||
if total_at_sr < out.len() { out.truncate(total_at_sr); }
|
||||
if out.len() > max_samples {
|
||||
return Err(too_long("decodes to", max_samples));
|
||||
}
|
||||
Ok((out, sr))
|
||||
}
|
||||
|
||||
|
|
@ -137,6 +176,10 @@ pub fn opus_decode(bytes: &[u8]) -> Result<(Vec<f32>, u32)> {
|
|||
// libFLAC bindings for encode; pure-Rust claxon for decode.
|
||||
|
||||
pub fn flac_encode(samples: &[f32], sr: u32) -> Result<Vec<u8>> {
|
||||
// A conforming encoder writes no payload a conforming reader may refuse (SPEC §2).
|
||||
if samples.len() > AUDIO_DECODED_MAX_SAMPLES {
|
||||
return Err(too_long("would decode to", AUDIO_DECODED_MAX_SAMPLES));
|
||||
}
|
||||
use flac_bound::{FlacEncoder, WriteWrapper};
|
||||
// FLAC takes int32 samples; widen i16-equivalent quant to i32 in 16-bit
|
||||
let i32_samples: Vec<i32> = samples.iter()
|
||||
|
|
@ -161,14 +204,33 @@ pub fn flac_encode(samples: &[f32], sr: u32) -> Result<Vec<u8>> {
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decodes a FLAC payload (RFC 9639) to `f32` samples within the reader limit of
|
||||
/// SPEC §2, [`AUDIO_DECODED_MAX_SAMPLES`], counting every channel.
|
||||
pub fn flac_decode(bytes: &[u8]) -> Result<(Vec<f32>, u32)> {
|
||||
flac_decode_within(bytes, AUDIO_DECODED_MAX_SAMPLES)
|
||||
}
|
||||
|
||||
/// [`flac_decode`] with the sample limit the caller gives (`audio_decoded_too_long`).
|
||||
pub fn flac_decode_within(bytes: &[u8], max_samples: usize) -> Result<(Vec<f32>, u32)> {
|
||||
let mut rdr = claxon::FlacReader::new(Cursor::new(bytes))
|
||||
.map_err(|e| AudioError(format!("flac open: {e}")))?;
|
||||
let info = rdr.streaminfo();
|
||||
let sr = info.sample_rate;
|
||||
let mut out = Vec::with_capacity(info.samples.unwrap_or(0) as usize);
|
||||
// STREAMINFO's total is a 36-bit count of inter-channel samples, from the payload:
|
||||
// refuse one past the limit from the declaration, and never size the output from
|
||||
// it past the limit (a 40-byte stream may declare 2^36).
|
||||
let declared = info.samples.map(|n| n.saturating_mul(info.channels as u64));
|
||||
if declared.is_some_and(|n| n > max_samples as u64) {
|
||||
return Err(too_long("declares", max_samples));
|
||||
}
|
||||
let mut out = Vec::with_capacity(declared.unwrap_or(0) as usize);
|
||||
// Samples come scaled to the stream's own bit depth (4 to 32 bits).
|
||||
let scale = 1.0 / (1u64 << (info.bits_per_sample.clamp(1, 32) - 1)) as f32;
|
||||
for s in rdr.samples() {
|
||||
out.push((s.map_err(|e| AudioError(format!("flac sample: {e}")))? as f32) / 32768.0);
|
||||
if out.len() == max_samples {
|
||||
return Err(too_long("decodes to", max_samples));
|
||||
}
|
||||
out.push((s.map_err(|e| AudioError(format!("flac sample: {e}")))? as f32) * scale);
|
||||
}
|
||||
Ok((out, sr))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@
|
|||
//! Each codec exposes `encode(rgb, h, w, quality) -> Vec<u8>` and
|
||||
//! `decode(bytes) -> (rgb, h, w)`. RGB is row-major, 8-bit, 3 channels.
|
||||
//! `quality` is in 1..=100 where applicable.
|
||||
//!
|
||||
//! Every decoder holds a picture to the reader limits of SPEC §2: at most
|
||||
//! [`IMAGE_MAX_SIDE`] wide and tall and at most [`IMAGE_MAX_PIXELS`] pixels. It refuses
|
||||
//! one past them (`image_too_large`) from the dimensions the payload declares, before it
|
||||
//! decodes a pixel, and decodes only the format its capability names.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
|
|
@ -20,8 +25,47 @@ impl<E: std::fmt::Display> From<E> for ImageError {
|
|||
|
||||
pub type Result<T> = std::result::Result<T, ImageError>;
|
||||
|
||||
/// The widest and the tallest picture a reader must accept (SPEC §2, *Reader limits*):
|
||||
/// 65 536 pixels, the largest side an AV1 sequence header can declare.
|
||||
pub const IMAGE_MAX_SIDE: u32 = 1 << 16;
|
||||
|
||||
/// The most pixels a reader must accept in one picture (SPEC §2, *Reader limits*):
|
||||
/// 2^27, a 16 384 × 8 192 picture. Decoded to 8-bit RGB it is 384 MiB.
|
||||
pub const IMAGE_MAX_PIXELS: u64 = 1 << 27;
|
||||
|
||||
/// Refuses a picture past the reader limits of SPEC §2 (`image_too_large`), given the
|
||||
/// width and height its payload declares. The writers refuse the same pictures: a
|
||||
/// conforming encoder writes no payload a conforming reader may refuse.
|
||||
pub fn check_dimensions(w: u64, h: u64) -> Result<()> {
|
||||
if w > IMAGE_MAX_SIDE as u64 || h > IMAGE_MAX_SIDE as u64 || w * h > IMAGE_MAX_PIXELS {
|
||||
return Err(ImageError(format!(
|
||||
"image_too_large: the payload declares a {w} × {h} picture; a reader need accept at most {IMAGE_MAX_SIDE} on a side and {IMAGE_MAX_PIXELS} pixels (SPEC §2)"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decodes a PNG or a JPEG payload, refusing any other format and any picture past
|
||||
/// the reader limits, which it reads from the header before it decodes.
|
||||
fn decode_as(bytes: &[u8], format: image::ImageFormat) -> Result<(Vec<u8>, u32, u32)> {
|
||||
let (w, h) = image::ImageReader::with_format(Cursor::new(bytes), format).into_dimensions()?;
|
||||
check_dimensions(w as u64, h as u64)?;
|
||||
let mut reader = image::ImageReader::with_format(Cursor::new(bytes), format);
|
||||
// The decoder's own buffer is at most 16-bit RGBA, 8 bytes a pixel; the default
|
||||
// allocation limit (512 MiB) would refuse such a picture within the reader limits.
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(IMAGE_MAX_SIDE);
|
||||
limits.max_image_height = Some(IMAGE_MAX_SIDE);
|
||||
limits.max_alloc = Some(IMAGE_MAX_PIXELS * 8 + (1 << 24));
|
||||
reader.limits(limits);
|
||||
let img = reader.decode()?.to_rgb8();
|
||||
let (w, h) = img.dimensions();
|
||||
Ok((img.into_raw(), h, w))
|
||||
}
|
||||
|
||||
// ---- PNG (lossless, universal) ----
|
||||
pub fn png_encode(rgb: &[u8], h: u32, w: u32) -> Result<Vec<u8>> {
|
||||
check_dimensions(w as u64, h as u64)?;
|
||||
let mut out = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(&mut out)
|
||||
.write_image(rgb, w, h, image::ExtendedColorType::Rgb8)?;
|
||||
|
|
@ -29,10 +73,7 @@ pub fn png_encode(rgb: &[u8], h: u32, w: u32) -> Result<Vec<u8>> {
|
|||
}
|
||||
|
||||
pub fn png_decode(bytes: &[u8]) -> Result<(Vec<u8>, u32, u32)> {
|
||||
let img = image::ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()?.decode()?.to_rgb8();
|
||||
let (w, h) = img.dimensions();
|
||||
Ok((img.into_raw(), h, w))
|
||||
decode_as(bytes, image::ImageFormat::Png)
|
||||
}
|
||||
|
||||
// ---- JPEG (lossy) ----
|
||||
|
|
@ -58,6 +99,7 @@ fn remap_q_to_jpeg_encoder(public_q: u8) -> u8 {
|
|||
}
|
||||
|
||||
pub fn jpeg_encode(rgb: &[u8], h: u32, w: u32, quality: u8) -> Result<Vec<u8>> {
|
||||
check_dimensions(w as u64, h as u64)?;
|
||||
let mut out = Vec::new();
|
||||
let q = remap_q_to_jpeg_encoder(quality);
|
||||
let enc = jpeg_encoder::Encoder::new(&mut out, q);
|
||||
|
|
@ -68,14 +110,12 @@ pub fn jpeg_encode(rgb: &[u8], h: u32, w: u32, quality: u8) -> Result<Vec<u8>> {
|
|||
}
|
||||
|
||||
pub fn jpeg_decode(bytes: &[u8]) -> Result<(Vec<u8>, u32, u32)> {
|
||||
let img = image::ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()?.decode()?.to_rgb8();
|
||||
let (w, h) = img.dimensions();
|
||||
Ok((img.into_raw(), h, w))
|
||||
decode_as(bytes, image::ImageFormat::Jpeg)
|
||||
}
|
||||
|
||||
// ---- AVIF (modern lossy, AV1-based) ----
|
||||
pub fn avif_encode(rgb: &[u8], h: u32, w: u32, quality: u8) -> Result<Vec<u8>> {
|
||||
check_dimensions(w as u64, h as u64)?;
|
||||
let mut out = Vec::new();
|
||||
image::codecs::avif::AvifEncoder::new_with_speed_quality(&mut out, 6, quality)
|
||||
.write_image(rgb, w, h, image::ExtendedColorType::Rgb8)?;
|
||||
|
|
@ -83,18 +123,63 @@ pub fn avif_encode(rgb: &[u8], h: u32, w: u32, quality: u8) -> Result<Vec<u8>> {
|
|||
}
|
||||
|
||||
pub fn avif_decode(bytes: &[u8]) -> Result<(Vec<u8>, u32, u32)> {
|
||||
// Use libavif-image (links the system libavif). The `image` crate's
|
||||
// own AVIF decoder is encode-only in the default build; relying on
|
||||
// it silently returned empty buffers (bench harness caught this).
|
||||
let img = libavif_image::read(bytes)
|
||||
.map_err(|e| ImageError(format!("libavif decode: {e:?}")))?
|
||||
.to_rgb8();
|
||||
let (w, h) = img.dimensions();
|
||||
Ok((img.into_raw(), h, w))
|
||||
// libavif directly. The `image` crate's own AVIF decoder is encode-only in the
|
||||
// default build; relying on it silently returned empty buffers (bench harness
|
||||
// caught this). libavif checks the size every image item declares (its `ispe`)
|
||||
// against the decoder's limits while it parses the container, before it decodes,
|
||||
// and checks the AV1 sequence header against them again, so the limits are set to
|
||||
// the reader limits rather than libavif's defaults (whose 32 768 side would refuse
|
||||
// a picture SPEC §2 says a reader must accept).
|
||||
use libavif_sys as sys;
|
||||
struct Decoder(*mut sys::avifDecoder);
|
||||
impl Drop for Decoder {
|
||||
fn drop(&mut self) { unsafe { sys::avifDecoderDestroy(self.0) } }
|
||||
}
|
||||
struct Image(*mut sys::avifImage);
|
||||
impl Drop for Image {
|
||||
fn drop(&mut self) { unsafe { sys::avifImageDestroy(self.0) } }
|
||||
}
|
||||
// SAFETY: each pointer libavif returns is checked for null before use and freed by
|
||||
// its guard; `bytes` outlives the decode that reads it; `out` is sized for the RGB
|
||||
// image libavif writes (`rowBytes` × `height`) before libavif writes it.
|
||||
unsafe {
|
||||
let dec = Decoder(sys::avifDecoderCreate());
|
||||
let img = Image(sys::avifImageCreateEmpty());
|
||||
if dec.0.is_null() || img.0.is_null() {
|
||||
return Err(ImageError("libavif decode: out of memory".into()));
|
||||
}
|
||||
(*dec.0).imageSizeLimit = IMAGE_MAX_PIXELS as u32;
|
||||
(*dec.0).imageDimensionLimit = IMAGE_MAX_SIDE;
|
||||
let r = sys::avifDecoderReadMemory(dec.0, img.0, bytes.as_ptr(), bytes.len());
|
||||
if r != sys::AVIF_RESULT_OK {
|
||||
let diag = std::ffi::CStr::from_ptr((*dec.0).diag.error.as_ptr()).to_string_lossy();
|
||||
if diag.contains("too large") {
|
||||
return Err(ImageError(format!(
|
||||
"image_too_large: {diag}; a reader need accept at most {IMAGE_MAX_SIDE} on a side and {IMAGE_MAX_PIXELS} pixels (SPEC §2)"
|
||||
)));
|
||||
}
|
||||
return Err(ImageError(format!("libavif decode: result {r}: {diag}")));
|
||||
}
|
||||
let (w, h) = ((*img.0).width, (*img.0).height);
|
||||
check_dimensions(w as u64, h as u64)?;
|
||||
let mut rgb = sys::avifRGBImage::default();
|
||||
sys::avifRGBImageSetDefaults(&mut rgb, img.0);
|
||||
rgb.format = sys::AVIF_RGB_FORMAT_RGB;
|
||||
rgb.depth = 8;
|
||||
rgb.rowBytes = w * 3;
|
||||
let mut out = vec![0u8; w as usize * h as usize * 3];
|
||||
rgb.pixels = out.as_mut_ptr();
|
||||
let r = sys::avifImageYUVToRGB(img.0, &mut rgb);
|
||||
if r != sys::AVIF_RESULT_OK {
|
||||
return Err(ImageError(format!("libavif YUV to RGB: result {r}")));
|
||||
}
|
||||
Ok((out, h, w))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- JPEG-XL (lossless or lossy) ----
|
||||
pub fn jxl_encode(rgb: &[u8], h: u32, w: u32, quality: Option<f32>) -> Result<Vec<u8>> {
|
||||
check_dimensions(w as u64, h as u64)?;
|
||||
// `quality=None` ⇒ mathematically lossless (the `.lossless(true)`
|
||||
// flag is what enables this; distance=0 alone is *not* sufficient).
|
||||
// `quality=Some(q)` ⇒ lossy with butteraugli distance derived from q.
|
||||
|
|
@ -117,14 +202,59 @@ pub fn jxl_encode(rgb: &[u8], h: u32, w: u32, quality: Option<f32>) -> Result<Ve
|
|||
Ok(r.data)
|
||||
}
|
||||
|
||||
pub fn jxl_decode(bytes: &[u8]) -> Result<(Vec<u8>, u32, u32)> {
|
||||
let dec = decoder_builder().build()?;
|
||||
let r = dec.decode(bytes)?;
|
||||
let (info, pixels) = r;
|
||||
let pixels = match pixels {
|
||||
jpegxl_rs::decode::Pixels::Uint8(v) => v,
|
||||
_ => return Err(ImageError("jxl: non-u8 pixel buffer".into())),
|
||||
/// The width and height a JPEG XL payload declares, read by libjxl from its header
|
||||
/// alone (the decoder is asked for nothing past the basic information).
|
||||
fn jxl_dimensions(bytes: &[u8]) -> Result<(u32, u32)> {
|
||||
use jpegxl_sys::decode::{
|
||||
JxlDecoderCloseInput, JxlDecoderCreate, JxlDecoderDestroy, JxlDecoderGetBasicInfo,
|
||||
JxlDecoderProcessInput, JxlDecoderSetInput, JxlDecoderStatus, JxlDecoderSubscribeEvents,
|
||||
};
|
||||
struct Decoder(*mut jpegxl_sys::decode::JxlDecoder);
|
||||
impl Drop for Decoder {
|
||||
fn drop(&mut self) { unsafe { JxlDecoderDestroy(self.0) } }
|
||||
}
|
||||
// SAFETY: the decoder is checked for null and freed by its guard; `bytes` outlives
|
||||
// every call that reads it (input is closed and the decoder destroyed before this
|
||||
// returns); `info` is written by libjxl only when it reports success.
|
||||
unsafe {
|
||||
let dec = Decoder(JxlDecoderCreate(std::ptr::null()));
|
||||
if dec.0.is_null() {
|
||||
return Err(ImageError("jxl: out of memory".into()));
|
||||
}
|
||||
if JxlDecoderSubscribeEvents(dec.0, JxlDecoderStatus::BasicInfo as i32) != JxlDecoderStatus::Success
|
||||
|| JxlDecoderSetInput(dec.0, bytes.as_ptr(), bytes.len()) != JxlDecoderStatus::Success
|
||||
{
|
||||
return Err(ImageError("jxl: decoder setup failed".into()));
|
||||
}
|
||||
JxlDecoderCloseInput(dec.0);
|
||||
if JxlDecoderProcessInput(dec.0) != JxlDecoderStatus::BasicInfo {
|
||||
return Err(ImageError("jxl: no header to read".into()));
|
||||
}
|
||||
let mut info = std::mem::MaybeUninit::<jpegxl_sys::metadata::codestream_header::JxlBasicInfo>::zeroed();
|
||||
if JxlDecoderGetBasicInfo(dec.0, info.as_mut_ptr()) != JxlDecoderStatus::Success {
|
||||
return Err(ImageError("jxl: no header to read".into()));
|
||||
}
|
||||
let info = info.assume_init();
|
||||
Ok((info.xsize, info.ysize))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn jxl_decode(bytes: &[u8]) -> Result<(Vec<u8>, u32, u32)> {
|
||||
let (w, h) = jxl_dimensions(bytes)?;
|
||||
check_dimensions(w as u64, h as u64)?;
|
||||
// Three 8-bit channels whatever the source holds: libjxl converts a grey picture to
|
||||
// RGB, drops alpha and rounds a deeper one to 8 bits, so every JPEG XL payload
|
||||
// decodes to the RGB this module promises.
|
||||
let dec = decoder_builder()
|
||||
.pixel_format(jpegxl_rs::decode::PixelFormat { num_channels: 3, ..Default::default() })
|
||||
.build()?;
|
||||
let (info, pixels) = dec.decode_with::<u8>(bytes)?;
|
||||
check_dimensions(info.width as u64, info.height as u64)?;
|
||||
if pixels.len() != info.width as usize * info.height as usize * 3 {
|
||||
return Err(ImageError(format!(
|
||||
"jxl: {} bytes decoded for a {} × {} picture", pixels.len(), info.width, info.height
|
||||
)));
|
||||
}
|
||||
Ok((pixels, info.height, info.width))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,66 @@ impl<E: std::fmt::Display> From<E> for VideoError {
|
|||
|
||||
pub type Result<T> = std::result::Result<T, VideoError>;
|
||||
|
||||
/// The most temporal units a reader must accept in one AV1 payload (SPEC §2, *Reader
|
||||
/// limits*): 2^20, 4.9 hours at 60 frames a second. Each frame is held to the picture
|
||||
/// limits of [`crate::codecs::image::check_dimensions`].
|
||||
pub const VIDEO_MAX_TEMPORAL_UNITS: u32 = 1 << 20;
|
||||
|
||||
/// What the frames [`av1_decode`] returns may take together: 4 GiB of RGB. A payload
|
||||
/// within the reader limits can declare far more (2^20 frames of 2^27 pixels), and the
|
||||
/// whole-output entry points keep every frame at once, so they refuse one past their
|
||||
/// budget (`video_decoded_too_large`) from its header, before they decode.
|
||||
/// [`av1_decode_each`] keeps one frame at a time and decodes any payload within the
|
||||
/// reader limits.
|
||||
// A literal, not `1 << 32`: cbindgen copies the expression into include/wai.h, where
|
||||
// it would shift a C `int` by its width.
|
||||
pub const AV1_DECODE_DEFAULT_MAX_LEN: u64 = 4_294_967_296;
|
||||
|
||||
/// The framing header of an AV1 payload (SPEC §5; extensions/packaging §7.2): the
|
||||
/// number of temporal units, the largest frame's height and width, and the timebase.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Av1Header {
|
||||
pub n: u32,
|
||||
pub h: u32,
|
||||
pub w: u32,
|
||||
pub fps_num: u32,
|
||||
pub fps_den: u32,
|
||||
}
|
||||
|
||||
/// Reads and checks an AV1 payload's framing header, refusing one past the reader
|
||||
/// limits of SPEC §2 (`video_too_large`).
|
||||
pub fn av1_header(bytes: &[u8]) -> Result<Av1Header> {
|
||||
// The fixed header is 20 bytes (5×u32).
|
||||
if bytes.len() < 20 {
|
||||
return Err(VideoError(format!(
|
||||
"payload too short for AV1 header: {} < 20", bytes.len())));
|
||||
}
|
||||
let mut rd = Cursor::new(bytes);
|
||||
let n = rd.read_u32::<LittleEndian>()?;
|
||||
let h = rd.read_u32::<LittleEndian>()?;
|
||||
let w = rd.read_u32::<LittleEndian>()?;
|
||||
let fps_num = rd.read_u32::<LittleEndian>()?;
|
||||
let fps_den = rd.read_u32::<LittleEndian>()?;
|
||||
if n == 0 || h == 0 || w == 0 {
|
||||
return Err(VideoError(format!("empty AV1 payload: {n} temporal units of {w}×{h}")));
|
||||
}
|
||||
if fps_num == 0 || fps_den == 0 {
|
||||
return Err(VideoError("fps_num/fps_den must be non-zero".into()));
|
||||
}
|
||||
if n > VIDEO_MAX_TEMPORAL_UNITS {
|
||||
return Err(VideoError(format!(
|
||||
"video_too_large: the payload declares {n} temporal units; a reader need accept at most {VIDEO_MAX_TEMPORAL_UNITS} (SPEC §2)"
|
||||
)));
|
||||
}
|
||||
if crate::codecs::image::check_dimensions(w as u64, h as u64).is_err() {
|
||||
use crate::codecs::image::{IMAGE_MAX_PIXELS, IMAGE_MAX_SIDE};
|
||||
return Err(VideoError(format!(
|
||||
"video_too_large: the payload declares frames of up to {w} × {h}; a reader need accept at most {IMAGE_MAX_SIDE} on a side and {IMAGE_MAX_PIXELS} pixels a frame (SPEC §2)"
|
||||
)));
|
||||
}
|
||||
Ok(Av1Header { n, h, w, fps_num, fps_den })
|
||||
}
|
||||
|
||||
/// Encode a sequence of RGB frames as AV1.
|
||||
///
|
||||
/// `frames`: each is row-major H×W×3 u8 RGB; all the same dimensions.
|
||||
|
|
@ -35,6 +95,17 @@ pub type Result<T> = std::result::Result<T, VideoError>;
|
|||
/// quality there).
|
||||
pub fn av1_encode(frames: &[Vec<u8>], h: u32, w: u32, fps_num: u32,
|
||||
fps_den: u32, lossless: bool, quality: u8) -> Result<Vec<u8>> {
|
||||
// A conforming encoder writes no payload a conforming reader may refuse (SPEC §2):
|
||||
// the header it writes is held to the same limits the reader holds it to.
|
||||
if frames.len() > VIDEO_MAX_TEMPORAL_UNITS as usize {
|
||||
return Err(VideoError(format!(
|
||||
"video_too_large: {} frames; a reader need accept at most {VIDEO_MAX_TEMPORAL_UNITS} (SPEC §2)", frames.len())));
|
||||
}
|
||||
let mut header = Vec::with_capacity(20);
|
||||
for v in [frames.len().max(1) as u32, h, w, fps_num.max(1), fps_den.max(1)] {
|
||||
header.write_u32::<LittleEndian>(v)?;
|
||||
}
|
||||
av1_header(&header)?;
|
||||
let mut enc_cfg = EncoderConfig::with_speed_preset(6);
|
||||
enc_cfg.width = w as usize;
|
||||
enc_cfg.height = h as usize;
|
||||
|
|
@ -151,40 +222,44 @@ fn rgb_to_yuv420_into(rgb: &[u8], h: usize, w: usize, frame: &mut rav1e::Frame<u
|
|||
}
|
||||
}
|
||||
|
||||
/// Decode a WAI AV1 payload back to a sequence of RGB frames.
|
||||
/// Decodes an AV1 payload one frame at a time, handing each to `each` as 8-bit RGB
|
||||
/// with its width and height, and returns the payload's header.
|
||||
///
|
||||
/// Defensive: every value read from the header is sanity-checked before
|
||||
/// being used so malformed input can't trigger dav1d's C-level abort()
|
||||
/// (which would bypass Rust's catch_unwind). All errors return a clean
|
||||
/// `VideoError`; we never feed a zero-length or out-of-bounds packet to
|
||||
/// dav1d_send_data.
|
||||
pub fn av1_decode(bytes: &[u8]) -> Result<(Vec<Vec<u8>>, u32, u32, u32, u32)> {
|
||||
// The fixed header is 20 bytes (5×u32).
|
||||
if bytes.len() < 20 {
|
||||
return Err(VideoError(format!(
|
||||
"payload too short for AV1 header: {} < 20", bytes.len())));
|
||||
}
|
||||
/// Defensive: every value read from the header is checked before it is used, so
|
||||
/// malformed input cannot reach dav1d's C-level abort() (which would bypass Rust's
|
||||
/// catch_unwind), and every packet fed to `dav1d_send_data` is non-empty and in bounds.
|
||||
/// It refuses a payload past the reader limits of SPEC §2 from its header, more temporal
|
||||
/// units than the header declares, and a frame larger than it declares. It keeps one
|
||||
/// frame and dav1d's reference frames, whatever the payload's length.
|
||||
pub fn av1_decode_each(
|
||||
bytes: &[u8],
|
||||
mut each: impl FnMut(&[u8], u32, u32) -> Result<()>,
|
||||
) -> Result<Av1Header> {
|
||||
let hd = av1_header(bytes)?;
|
||||
let mut rd = Cursor::new(bytes);
|
||||
let nframes = rd.read_u32::<LittleEndian>()? as usize;
|
||||
let h = rd.read_u32::<LittleEndian>()?;
|
||||
let w = rd.read_u32::<LittleEndian>()?;
|
||||
let fps_num = rd.read_u32::<LittleEndian>()?;
|
||||
let fps_den = rd.read_u32::<LittleEndian>()?;
|
||||
|
||||
// sanity bounds — reject malformed/adversarial headers.
|
||||
if nframes == 0 || nframes > 1_000_000 {
|
||||
return Err(VideoError(format!("nframes out of range: {nframes}")));
|
||||
}
|
||||
if h == 0 || w == 0 || h > 32_768 || w > 32_768 {
|
||||
return Err(VideoError(format!("dimensions out of range: {w}×{h}")));
|
||||
}
|
||||
if fps_num == 0 || fps_den == 0 {
|
||||
return Err(VideoError("fps_num/fps_den must be non-zero".into()));
|
||||
}
|
||||
|
||||
let mut dec = dav1d::Decoder::new()
|
||||
rd.set_position(20);
|
||||
// dav1d allocates a frame's buffers from the AV1 sequence header, before a picture
|
||||
// reaches the size check below, so it is given the header's frame size as its own
|
||||
// limit: a sequence header declaring more is refused before it allocates.
|
||||
let mut settings = dav1d::Settings::new();
|
||||
settings.set_frame_size_limit((hd.h as u64 * hd.w as u64) as u32);
|
||||
let mut dec = dav1d::Decoder::with_settings(&settings)
|
||||
.map_err(|e| VideoError(format!("dav1d new: {e:?}")))?;
|
||||
let mut frames = Vec::with_capacity(nframes);
|
||||
let (mut units, mut frames) = (0u32, 0u32);
|
||||
let mut emit = |pic: dav1d::Picture| -> Result<()> {
|
||||
frames += 1;
|
||||
if frames > hd.n {
|
||||
return Err(VideoError(format!("the payload shows more frames than its {} temporal units", hd.n)));
|
||||
}
|
||||
if pic.width() > hd.w || pic.height() > hd.h {
|
||||
return Err(VideoError(format!(
|
||||
"a {}×{} frame in a payload whose frames are at most {}×{}",
|
||||
pic.width(), pic.height(), hd.w, hd.h
|
||||
)));
|
||||
}
|
||||
let rgb = picture_to_rgb(&pic)?;
|
||||
each(&rgb, pic.width(), pic.height())
|
||||
};
|
||||
|
||||
loop {
|
||||
if rd.position() as usize >= bytes.len() {
|
||||
|
|
@ -205,37 +280,123 @@ pub fn av1_decode(bytes: &[u8]) -> Result<(Vec<Vec<u8>>, u32, u32, u32, u32)> {
|
|||
return Err(VideoError(format!(
|
||||
"packet size {sz} exceeds {remaining} remaining bytes")));
|
||||
}
|
||||
units += 1;
|
||||
if units > hd.n {
|
||||
return Err(VideoError(format!("the payload holds more than the {} temporal units its header declares", hd.n)));
|
||||
}
|
||||
let mut pkt = vec![0u8; sz];
|
||||
rd.read_exact(&mut pkt)?;
|
||||
dec.send_data(pkt, None, None, None)
|
||||
.map_err(|e| VideoError(format!("dav1d send: {e:?}")))?;
|
||||
while let Ok(pic) = dec.get_picture() {
|
||||
frames.push(picture_to_rgb(&pic));
|
||||
emit(pic)?;
|
||||
}
|
||||
}
|
||||
dec.send_pending_data().ok();
|
||||
while let Ok(pic) = dec.get_picture() {
|
||||
frames.push(picture_to_rgb(&pic));
|
||||
emit(pic)?;
|
||||
}
|
||||
frames.truncate(nframes);
|
||||
Ok((frames, h, w, fps_num, fps_den))
|
||||
Ok(hd)
|
||||
}
|
||||
|
||||
fn picture_to_rgb(pic: &dav1d::Picture) -> Vec<u8> {
|
||||
/// Decode a WAI AV1 payload back to a sequence of RGB frames, every one kept at once,
|
||||
/// within [`AV1_DECODE_DEFAULT_MAX_LEN`] bytes of output.
|
||||
pub fn av1_decode(bytes: &[u8]) -> Result<(Vec<Vec<u8>>, u32, u32, u32, u32)> {
|
||||
av1_decode_within(bytes, AV1_DECODE_DEFAULT_MAX_LEN)
|
||||
}
|
||||
|
||||
/// [`av1_decode`] with the output budget the caller gives. It refuses a payload whose
|
||||
/// frames would take more than `max_len` bytes together (`video_decoded_too_large`)
|
||||
/// from its header, before it decodes, and one whose frames are not all the size its
|
||||
/// header declares (the frames it returns share one height and width).
|
||||
pub fn av1_decode_within(bytes: &[u8], max_len: u64) -> Result<(Vec<Vec<u8>>, u32, u32, u32, u32)> {
|
||||
let hd = av1_header(bytes)?;
|
||||
let need = hd.n as u64 * hd.h as u64 * hd.w as u64 * 3;
|
||||
if need > max_len {
|
||||
return Err(VideoError(format!(
|
||||
"video_decoded_too_large: the payload's {} frames of {}×{} take {need} bytes of RGB together, past this reader's {max_len}; av1_decode_each decodes one frame at a time",
|
||||
hd.n, hd.w, hd.h
|
||||
)));
|
||||
}
|
||||
let mut frames = Vec::with_capacity(hd.n as usize);
|
||||
av1_decode_each(bytes, |rgb, w, h| {
|
||||
if (w, h) != (hd.w, hd.h) {
|
||||
return Err(VideoError(format!(
|
||||
"a {w}×{h} frame in a payload of {}×{} frames; av1_decode_each decodes frames of varying size",
|
||||
hd.w, hd.h
|
||||
)));
|
||||
}
|
||||
frames.push(rgb.to_vec());
|
||||
Ok(())
|
||||
})?;
|
||||
Ok((frames, hd.h, hd.w, hd.fps_num, hd.fps_den))
|
||||
}
|
||||
|
||||
/// One decoded frame as 8-bit RGB (BT.601, full range), from any layout and depth AV1
|
||||
/// allows: 4:2:0, 4:2:2, 4:4:4 and monochrome, 8, 10 and 12 bits.
|
||||
fn picture_to_rgb(pic: &dav1d::Picture) -> Result<Vec<u8>> {
|
||||
use dav1d::{PixelLayout, PlanarImageComponent as C};
|
||||
let (w, h) = (pic.width() as usize, pic.height() as usize);
|
||||
let y_plane = pic.plane(dav1d::PlanarImageComponent::Y);
|
||||
let u_plane = pic.plane(dav1d::PlanarImageComponent::U);
|
||||
let v_plane = pic.plane(dav1d::PlanarImageComponent::V);
|
||||
let y_stride = pic.stride(dav1d::PlanarImageComponent::Y) as usize;
|
||||
let u_stride = pic.stride(dav1d::PlanarImageComponent::U) as usize;
|
||||
let v_stride = pic.stride(dav1d::PlanarImageComponent::V) as usize;
|
||||
let y_plane = pic.plane(C::Y);
|
||||
let y_stride = pic.stride(C::Y) as usize;
|
||||
let mut out = vec![0u8; w * h * 3];
|
||||
if pic.bit_depth() == 8 && pic.pixel_layout() == PixelLayout::I420 {
|
||||
// The 8-bit 4:2:0 path, exactly as every committed vector was decoded.
|
||||
let u_plane = pic.plane(C::U);
|
||||
let v_plane = pic.plane(C::V);
|
||||
let u_stride = pic.stride(C::U) as usize;
|
||||
let v_stride = pic.stride(C::V) as usize;
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
let y = y_plane.as_ref()[i * y_stride + j] as f32;
|
||||
let ci = i / 2; let cj = j / 2;
|
||||
let cb = u_plane.as_ref()[ci * u_stride + cj] as f32 - 128.0;
|
||||
let cr = v_plane.as_ref()[ci * v_stride + cj] as f32 - 128.0;
|
||||
let r = y + 1.402 * cr;
|
||||
let g = y - 0.344136 * cb - 0.714136 * cr;
|
||||
let b = y + 1.772 * cb;
|
||||
let p = (i * w + j) * 3;
|
||||
out[p] = r.clamp(0.0, 255.0).round() as u8;
|
||||
out[p + 1] = g.clamp(0.0, 255.0).round() as u8;
|
||||
out[p + 2] = b.clamp(0.0, 255.0).round() as u8;
|
||||
}
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
// Every other layout and depth. A sample at depth d is brought to the 8-bit scale
|
||||
// by 2^(d-8); planes deeper than 8 bits hold one native-endian u16 a sample.
|
||||
let wide = pic.bit_depth() > 8;
|
||||
let bits = pic.bits_per_component().map_or(8, |b| b.0);
|
||||
if !(8..=12).contains(&bits) {
|
||||
return Err(VideoError(format!("AV1 frame of {bits} bits per component")));
|
||||
}
|
||||
let scale = 1.0 / (1u32 << (bits - 8)) as f32;
|
||||
let at = |plane: &[u8], stride: usize, i: usize, j: usize| -> Result<f32> {
|
||||
let v = if wide {
|
||||
let k = i * stride + 2 * j;
|
||||
plane.get(k..k + 2).map(|b| u16::from_ne_bytes([b[0], b[1]]) as f32)
|
||||
} else {
|
||||
plane.get(i * stride + j).map(|&b| b as f32)
|
||||
};
|
||||
v.map(|v| v * scale).ok_or_else(|| VideoError("AV1 frame plane shorter than its geometry".into()))
|
||||
};
|
||||
let (sub_x, sub_y) = match pic.pixel_layout() {
|
||||
PixelLayout::I420 => (1, 1),
|
||||
PixelLayout::I422 => (1, 0),
|
||||
PixelLayout::I444 | PixelLayout::I400 => (0, 0),
|
||||
};
|
||||
let mono = pic.pixel_layout() == PixelLayout::I400;
|
||||
let (u_plane, v_plane) = (pic.plane(C::U), pic.plane(C::V));
|
||||
let (u_stride, v_stride) = (pic.stride(C::U) as usize, pic.stride(C::V) as usize);
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
let y = y_plane.as_ref()[i * y_stride + j] as f32;
|
||||
let ci = i / 2; let cj = j / 2;
|
||||
let cb = u_plane.as_ref()[ci * u_stride + cj] as f32 - 128.0;
|
||||
let cr = v_plane.as_ref()[ci * v_stride + cj] as f32 - 128.0;
|
||||
let y = at(y_plane.as_ref(), y_stride, i, j)?;
|
||||
let (cb, cr) = if mono {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
let (ci, cj) = (i >> sub_y, j >> sub_x);
|
||||
(at(u_plane.as_ref(), u_stride, ci, cj)? - 128.0, at(v_plane.as_ref(), v_stride, ci, cj)? - 128.0)
|
||||
};
|
||||
let r = y + 1.402 * cr;
|
||||
let g = y - 0.344136 * cb - 0.714136 * cr;
|
||||
let b = y + 1.772 * cb;
|
||||
|
|
@ -245,7 +406,7 @@ fn picture_to_rgb(pic: &dav1d::Picture) -> Vec<u8> {
|
|||
out[p + 2] = b.clamp(0.0, 255.0).round() as u8;
|
||||
}
|
||||
}
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ pub unsafe extern "C" fn wai_image_png_encode(
|
|||
|
||||
/// Decode a PNG byte stream to RGB. Returns `(rgb, h, w)`.
|
||||
///
|
||||
/// A picture past the reader limits of SPEC §2 (wider or taller than
|
||||
/// [`IMAGE_MAX_SIDE`](crate::codecs::image::IMAGE_MAX_SIDE) or holding more than
|
||||
/// [`IMAGE_MAX_PIXELS`](crate::codecs::image::IMAGE_MAX_PIXELS) pixels) is refused with
|
||||
/// `WAI_ERR_CODEC` from the size it declares, before it decodes; the message begins
|
||||
/// `image_too_large`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `bytes` valid for `len` bytes; `out` writable; `out_h` writable; `out_w` writable.
|
||||
#[unsafe(no_mangle)]
|
||||
|
|
@ -113,6 +119,12 @@ pub unsafe extern "C" fn wai_image_avif_encode(
|
|||
})
|
||||
}
|
||||
|
||||
/// A picture past the reader limits of SPEC §2 (wider or taller than
|
||||
/// [`IMAGE_MAX_SIDE`](crate::codecs::image::IMAGE_MAX_SIDE) or holding more than
|
||||
/// [`IMAGE_MAX_PIXELS`](crate::codecs::image::IMAGE_MAX_PIXELS) pixels) is refused with
|
||||
/// `WAI_ERR_CODEC` from the size it declares, before it decodes; the message begins
|
||||
/// `image_too_large`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `bytes` valid for `len` bytes; `out` writable; `out_h` writable; `out_w` writable.
|
||||
#[unsafe(no_mangle)]
|
||||
|
|
@ -158,6 +170,12 @@ pub unsafe extern "C" fn wai_image_jxl_encode(
|
|||
})
|
||||
}
|
||||
|
||||
/// A picture past the reader limits of SPEC §2 (wider or taller than
|
||||
/// [`IMAGE_MAX_SIDE`](crate::codecs::image::IMAGE_MAX_SIDE) or holding more than
|
||||
/// [`IMAGE_MAX_PIXELS`](crate::codecs::image::IMAGE_MAX_PIXELS) pixels) is refused with
|
||||
/// `WAI_ERR_CODEC` from the size it declares, before it decodes; the message begins
|
||||
/// `image_too_large`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `bytes` valid for `len` bytes; `out` writable; `out_h` writable; `out_w` writable.
|
||||
#[unsafe(no_mangle)]
|
||||
|
|
@ -195,6 +213,11 @@ pub unsafe extern "C" fn wai_audio_opus_encode(
|
|||
})
|
||||
}
|
||||
|
||||
/// Output past the reader limit of SPEC §2,
|
||||
/// [`AUDIO_DECODED_MAX_SAMPLES`](crate::codecs::audio::AUDIO_DECODED_MAX_SAMPLES)
|
||||
/// samples, is refused with `WAI_ERR_CODEC`; the message begins
|
||||
/// `audio_decoded_too_long`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `bytes` valid for `len` bytes; `out` writable; `out_sr` writable.
|
||||
#[unsafe(no_mangle)]
|
||||
|
|
@ -246,6 +269,11 @@ pub unsafe extern "C" fn wai_audio_flac_encode(
|
|||
})
|
||||
}
|
||||
|
||||
/// Output past the reader limit of SPEC §2,
|
||||
/// [`AUDIO_DECODED_MAX_SAMPLES`](crate::codecs::audio::AUDIO_DECODED_MAX_SAMPLES)
|
||||
/// samples, is refused with `WAI_ERR_CODEC`; the message begins
|
||||
/// `audio_decoded_too_long`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `bytes` valid for `len` bytes; `out` writable; `out_sr` writable.
|
||||
#[unsafe(no_mangle)]
|
||||
|
|
@ -380,6 +408,14 @@ pub unsafe extern "C" fn wai_video_av1_encode(
|
|||
|
||||
/// Decode an AV1 WAI payload to a packed RGB buffer `n*h*w*3` bytes.
|
||||
///
|
||||
/// A payload past the reader limits of SPEC §2 (more than
|
||||
/// [`VIDEO_MAX_TEMPORAL_UNITS`](crate::codecs::video::VIDEO_MAX_TEMPORAL_UNITS)
|
||||
/// temporal units, or frames past the picture limits) is refused from its header;
|
||||
/// the message begins `video_too_large`. Since the buffer holds every frame, so is
|
||||
/// one whose frames would take more than
|
||||
/// [`AV1_DECODE_DEFAULT_MAX_LEN`](crate::codecs::video::AV1_DECODE_DEFAULT_MAX_LEN)
|
||||
/// bytes together (`video_decoded_too_large`), and one whose frames differ in size.
|
||||
///
|
||||
/// # Safety
|
||||
/// `bytes` valid for `len` bytes; `out` writable; `out_n` writable; `out_h` writable; `out_w` writable; `out_fps_num` writable; `out_fps_den` writable.
|
||||
#[unsafe(no_mangle)]
|
||||
|
|
|
|||
|
|
@ -92,12 +92,27 @@ impl FilmContract {
|
|||
.and_then(|x| x.as_str())
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| FilmError::BadContract("missing `numeric`".into()))?,
|
||||
start_tick: u("start_tick")?,
|
||||
start_tick: last_tick(frames, u("start_tick")?, ticks_per_frame)?.0,
|
||||
ticks_per_frame,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks a frame schedule: at most [`crate::limits::RENDER_MAX_FRAMES`] frames
|
||||
/// (`duration_too_long`), and a last tick, `start_tick + (frames - 1) × ticks_per_frame`,
|
||||
/// that a `u64` holds. Returns `(start_tick, last tick)`.
|
||||
pub(super) fn last_tick(frames: u64, start_tick: u64, ticks_per_frame: u64) -> Result<(u64, u64), FilmError> {
|
||||
if frames > crate::limits::RENDER_MAX_FRAMES {
|
||||
return Err(FilmError::BadContract(crate::limits::duration_too_long(
|
||||
"frames", frames, crate::limits::RENDER_MAX_FRAMES)));
|
||||
}
|
||||
let last = (frames.max(1) - 1)
|
||||
.checked_mul(ticks_per_frame)
|
||||
.and_then(|t| t.checked_add(start_tick))
|
||||
.ok_or_else(|| FilmError::BadContract("the frame schedule's last tick passes 2^64 - 1".into()))?;
|
||||
Ok((start_tick, last))
|
||||
}
|
||||
|
||||
/// Camera-track bytes: `n_keys u32`, then per key `tick u64 | pos[3] i64 |
|
||||
/// rot[4] i64 | fov i64` (all little-endian; the `Fx` values as raw bits).
|
||||
pub fn camera_bytes(keys: &[CamKey]) -> Vec<u8> {
|
||||
|
|
|
|||
|
|
@ -113,9 +113,12 @@ pub(crate) fn pose_bytes(p: &CamPose) -> Vec<u8> {
|
|||
}
|
||||
|
||||
impl Film {
|
||||
/// The world tick frame `f` samples: `start_tick + f * ticks_per_frame`.
|
||||
/// The world tick frame `f` samples: `start_tick + f * ticks_per_frame`. Within the
|
||||
/// film's frames it cannot overflow: the contract's reader refuses a schedule whose
|
||||
/// last tick a `u64` does not hold. Past them it saturates rather than wrap to an
|
||||
/// earlier tick.
|
||||
pub fn frame_tick(&self, f: u64) -> u64 {
|
||||
self.contract.start_tick + f * self.contract.ticks_per_frame
|
||||
f.saturating_mul(self.contract.ticks_per_frame).saturating_add(self.contract.start_tick)
|
||||
}
|
||||
|
||||
/// The frame ticks, ascending — what the world is sampled at.
|
||||
|
|
|
|||
|
|
@ -127,7 +127,9 @@ fn parse_camkeys(b: &[u8], p: &mut usize) -> Result<Vec<CamKey>, ReelError> {
|
|||
};
|
||||
let n = u32::from_le_bytes(rd(*p, 4)?.try_into().unwrap()) as usize;
|
||||
*p += 4;
|
||||
let mut keys = Vec::with_capacity(n);
|
||||
// Reserve no more entries than the bytes left could hold: the count is the input's,
|
||||
// and a reservation sized by it alone let a few bytes ask for gigabytes.
|
||||
let mut keys = Vec::with_capacity(n.min(b.len().saturating_sub(*p) / CAMKEY_BYTES));
|
||||
let mut last: Option<u64> = None;
|
||||
for _ in 0..n {
|
||||
let tick = u64::from_le_bytes(rd(*p, 8)?.try_into().unwrap());
|
||||
|
|
@ -158,7 +160,10 @@ fn parse_shots(b: &[u8]) -> Result<Vec<Shot>, ReelError> {
|
|||
let rd = |q: usize| -> Result<u64, ReelError> {
|
||||
if q + 8 > b.len() { Err(ReelError::Truncated) } else { Ok(u64::from_le_bytes(b[q..q + 8].try_into().unwrap())) }
|
||||
};
|
||||
let mut shots = Vec::with_capacity(n);
|
||||
// Reserve no more entries than the bytes left could hold: the count is the input's,
|
||||
// and a reservation sized by it alone let a few bytes ask for gigabytes.
|
||||
let mut shots = Vec::with_capacity(n.min(b.len() / 24));
|
||||
let mut total = 0u64;
|
||||
for _ in 0..n {
|
||||
let frames = rd(p)?;
|
||||
let start_tick = rd(p + 8)?;
|
||||
|
|
@ -166,6 +171,16 @@ fn parse_shots(b: &[u8]) -> Result<Vec<Shot>, ReelError> {
|
|||
if frames == 0 || ticks_per_frame == 0 {
|
||||
return Err(ReelError::BadShots("shot frames and ticks_per_frame must be >= 1".into()));
|
||||
}
|
||||
// The reel renders every shot's frames: their sum is held to the frame limit, and
|
||||
// each shot's last tick to what a u64 holds.
|
||||
total = total.saturating_add(frames);
|
||||
if total > crate::limits::RENDER_MAX_FRAMES {
|
||||
return Err(ReelError::BadShots(crate::limits::duration_too_long(
|
||||
"frames across its shots", total, crate::limits::RENDER_MAX_FRAMES)));
|
||||
}
|
||||
if (frames - 1).checked_mul(ticks_per_frame).and_then(|t| t.checked_add(start_tick)).is_none() {
|
||||
return Err(ReelError::BadShots("a shot's last tick passes 2^64 - 1".into()));
|
||||
}
|
||||
p += 24;
|
||||
let camera = parse_camkeys(b, &mut p)?;
|
||||
shots.push(Shot { frames, start_tick, ticks_per_frame, camera });
|
||||
|
|
@ -198,7 +213,7 @@ impl Reel {
|
|||
pub fn frame_tick(&self, f: u64) -> Option<u64> {
|
||||
let (i, local) = self.locate(f)?;
|
||||
let s = &self.shots[i];
|
||||
Some(s.start_tick + local * s.ticks_per_frame)
|
||||
local.checked_mul(s.ticks_per_frame).and_then(|t| t.checked_add(s.start_tick))
|
||||
}
|
||||
|
||||
/// The camera pose at global frame `f` (its shot's track, at `f`'s tick).
|
||||
|
|
|
|||
|
|
@ -67,7 +67,13 @@ impl HapticContract {
|
|||
return Err(HapticError::BadContract("sample_rate out of range".into()));
|
||||
}
|
||||
let ch = u("channels")?;
|
||||
Ok(HapticContract { sample_rate: sr as u32, numeric: s("numeric")?, channels: ch as u32, duration: u("duration")? })
|
||||
let duration = u("duration")?;
|
||||
let samples = duration.saturating_mul(ch.max(1));
|
||||
if samples > crate::limits::RENDER_MAX_SAMPLES {
|
||||
return Err(HapticError::BadContract(crate::limits::duration_too_long(
|
||||
"samples (duration × channels)", samples, crate::limits::RENDER_MAX_SAMPLES)));
|
||||
}
|
||||
Ok(HapticContract { sample_rate: sr as u32, numeric: s("numeric")?, channels: ch as u32, duration })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,7 +232,9 @@ fn parse_channels(b: &[u8]) -> Result<Vec<Vec<HapticKey>>, HapticError> {
|
|||
};
|
||||
let nc = u32::from_le_bytes(rd(0, 4)?.try_into().unwrap()) as usize;
|
||||
let mut p = 4usize;
|
||||
let mut chans = Vec::with_capacity(nc);
|
||||
// Reserve no more entries than the bytes left could hold: the count is the input's,
|
||||
// and a reservation sized by it alone let a few bytes ask for gigabytes.
|
||||
let mut chans = Vec::with_capacity(nc.min(b.len() / 2));
|
||||
for _ in 0..nc {
|
||||
let nk = u16::from_le_bytes(rd(p, 2)?.try_into().unwrap()) as usize;
|
||||
p += 2;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ pub mod bench_env;
|
|||
// Parameter-set pins (SPEC §3.1): the listing form, and verifying a pin against
|
||||
// the sets a sink holds. Pure Rust over bytes, so it is always on.
|
||||
pub mod pin;
|
||||
// The reader limits the extension formats share with SPEC §2: how many samples, counting
|
||||
// every channel, and how many frames a reader must be willing to render. Always on.
|
||||
pub mod limits;
|
||||
#[cfg(feature = "ffi")]
|
||||
pub mod ffi;
|
||||
#[cfg(feature = "codecs")]
|
||||
|
|
|
|||
20
wai/wai-rs/src/limits.rs
Normal file
20
wai/wai-rs/src/limits.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//! Reader limits the extension formats share with SPEC §2.
|
||||
//!
|
||||
//! A score, haptic signal or audio scene declares how many samples it renders, and a
|
||||
//! splat4d clip, film or reel how many frames; rendering allocates in proportion. Each
|
||||
//! extension's reader refuses a contract past these limits when it reads the contract
|
||||
//! (`duration_too_long`), before anything is rendered, as SPEC §2 does for audio and
|
||||
//! video payloads.
|
||||
|
||||
/// The most samples, counting every channel, a reader must be willing to render from
|
||||
/// one object: 2^27, the audio limit of SPEC §2 (46 minutes of one channel at 48 kHz).
|
||||
pub const RENDER_MAX_SAMPLES: u64 = 1 << 27;
|
||||
|
||||
/// The most frames a reader must be willing to render from one object: 2^20, the
|
||||
/// video limit of SPEC §2 (4.9 hours at 60 frames a second).
|
||||
pub const RENDER_MAX_FRAMES: u64 = 1 << 20;
|
||||
|
||||
/// The refusal for a contract past either limit, as its message.
|
||||
pub fn duration_too_long(what: &str, declared: u64, limit: u64) -> String {
|
||||
format!("duration_too_long: the contract declares {what} {declared}, past the {limit} a reader need render")
|
||||
}
|
||||
|
|
@ -62,6 +62,12 @@ impl ScoreContract {
|
|||
return Err(ScoreError::BadContract("sample_rate out of range".into()));
|
||||
}
|
||||
let ch = u("channels")?;
|
||||
let duration = u("duration")?;
|
||||
let samples = duration.saturating_mul(ch.max(1));
|
||||
if samples > crate::limits::RENDER_MAX_SAMPLES {
|
||||
return Err(ScoreError::BadContract(crate::limits::duration_too_long(
|
||||
"samples (duration × channels)", samples, crate::limits::RENDER_MAX_SAMPLES)));
|
||||
}
|
||||
Ok(ScoreContract {
|
||||
sample_rate: sr as u32,
|
||||
numeric: o
|
||||
|
|
@ -70,7 +76,7 @@ impl ScoreContract {
|
|||
.map(str::to_owned)
|
||||
.ok_or_else(|| ScoreError::BadContract("missing `numeric`".into()))?,
|
||||
channels: ch as u32,
|
||||
duration: u("duration")?,
|
||||
duration,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -107,7 +113,9 @@ fn parse_notes(b: &[u8]) -> Result<Vec<Note>, ScoreError> {
|
|||
let i64a = |s: &[u8]| i64::from_le_bytes(s.try_into().unwrap());
|
||||
let nn = u32::from_le_bytes(rd(0, 4)?.try_into().unwrap()) as usize;
|
||||
let mut p = 4usize;
|
||||
let mut notes = Vec::with_capacity(nn);
|
||||
// Reserve no more entries than the bytes left could hold: the count is the input's,
|
||||
// and a reservation sized by it alone let a few bytes ask for gigabytes.
|
||||
let mut notes = Vec::with_capacity(nn.min(b.len() / 65));
|
||||
for _ in 0..nn {
|
||||
let start = u64a(rd(p, 8)?);
|
||||
let dur = u64a(rd(p + 8, 8)?);
|
||||
|
|
|
|||
|
|
@ -73,11 +73,16 @@ impl Splat4dContract {
|
|||
if ng > u32::MAX as u64 {
|
||||
return Err(Splat4dError::BadContract("n_gaussians out of range".into()));
|
||||
}
|
||||
let n_frames = u("n_frames")?;
|
||||
if n_frames > crate::limits::RENDER_MAX_FRAMES {
|
||||
return Err(Splat4dError::BadContract(crate::limits::duration_too_long(
|
||||
"frames", n_frames, crate::limits::RENDER_MAX_FRAMES)));
|
||||
}
|
||||
Ok(Splat4dContract {
|
||||
frame_rate: fr as u32,
|
||||
numeric: s("numeric")?,
|
||||
n_gaussians: ng as u32,
|
||||
n_frames: u("n_frames")?,
|
||||
n_frames,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -117,7 +122,9 @@ fn parse_tracks(b: &[u8]) -> Result<Vec<Vec<GaussKey>>, Splat4dError> {
|
|||
};
|
||||
let ng = u32::from_le_bytes(rd(0, 4)?.try_into().unwrap()) as usize;
|
||||
let mut p = 4usize;
|
||||
let mut gaussians = Vec::with_capacity(ng);
|
||||
// Reserve no more entries than the bytes left could hold: the count is the input's,
|
||||
// and a reservation sized by it alone let a few bytes ask for gigabytes.
|
||||
let mut gaussians = Vec::with_capacity(ng.min(b.len() / 2));
|
||||
for _ in 0..ng {
|
||||
let nk = u16::from_le_bytes(rd(p, 2)?.try_into().unwrap()) as usize;
|
||||
p += 2;
|
||||
|
|
|
|||
|
|
@ -217,7 +217,9 @@ fn decode_assets(bytes: &[u8]) -> Result<Vec<AssetEntry>, WorldError> {
|
|||
}
|
||||
let n = u32::from_le_bytes(bytes[..4].try_into().unwrap()) as usize;
|
||||
let mut pos = 4usize;
|
||||
let mut out = Vec::with_capacity(n);
|
||||
// Reserve no more entries than the bytes left could hold: the count is the input's,
|
||||
// and a reservation sized by it alone let a few bytes ask for gigabytes.
|
||||
let mut out = Vec::with_capacity(n.min(bytes.len() / 37));
|
||||
for _ in 0..n {
|
||||
if pos + 37 > bytes.len() {
|
||||
return Err(WorldError::Truncated);
|
||||
|
|
|
|||
133
wai/wai-rs/tests/container_reservations.rs
Normal file
133
wai/wai-rs/tests/container_reservations.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
//! A container's reader reserves no more entries than its bytes could hold.
|
||||
//!
|
||||
//! Each extension container (world, audio scene, reel, score, haptic, splat4d) reads a
|
||||
//! `u32` count of entries from its input and used to reserve that many before reading
|
||||
//! one: a few bytes could ask the allocator for gigabytes. The coverage-guided fuzzer
|
||||
//! (`fuzz/`, target `containers`) found it in the audio scene's object table, asking
|
||||
//! for 48 GiB.
|
||||
//!
|
||||
//! For each container a committed vector is rewritten so that the section holding a
|
||||
//! count declares 2^32 - 1 entries and holds nothing after the count. The reader must
|
||||
//! refuse it having asked for no single allocation past a few megabytes. The allocator
|
||||
//! here records the largest request, and refuses any past 256 MiB, so a reader that
|
||||
//! still sizes its reservation from the count fails here instead of reserving.
|
||||
#![cfg(all(
|
||||
feature = "world",
|
||||
feature = "audio_scene",
|
||||
feature = "film",
|
||||
feature = "score",
|
||||
feature = "haptic",
|
||||
feature = "splat4d"
|
||||
))]
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
|
||||
|
||||
/// The system allocator, recording the largest single request.
|
||||
struct Largest;
|
||||
static LARGEST: AtomicUsize = AtomicUsize::new(0);
|
||||
const REFUSE_PAST: usize = 256 << 20;
|
||||
|
||||
unsafe impl GlobalAlloc for Largest {
|
||||
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
|
||||
LARGEST.fetch_max(l.size(), Relaxed);
|
||||
if l.size() > REFUSE_PAST { std::ptr::null_mut() } else { unsafe { System.alloc(l) } }
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 {
|
||||
LARGEST.fetch_max(l.size(), Relaxed);
|
||||
if l.size() > REFUSE_PAST { std::ptr::null_mut() } else { unsafe { System.alloc_zeroed(l) } }
|
||||
}
|
||||
unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 {
|
||||
LARGEST.fetch_max(new, Relaxed);
|
||||
if new > REFUSE_PAST { std::ptr::null_mut() } else { unsafe { System.realloc(p, l, new) } }
|
||||
}
|
||||
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
|
||||
unsafe { System.dealloc(p, l) }
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static A: Largest = Largest;
|
||||
|
||||
fn vector(rel: &str) -> Vec<u8> {
|
||||
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join(rel);
|
||||
std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()))
|
||||
}
|
||||
|
||||
/// The sections of a container in the layout every one of these shares: a magic, a
|
||||
/// `u16` count, a table of `(kind u8, offset u32, length u32)`, then the bodies.
|
||||
fn sections(b: &[u8]) -> Vec<(u8, Vec<u8>)> {
|
||||
let n = u16::from_le_bytes([b[4], b[5]]) as usize;
|
||||
let body = &b[6 + 9 * n..];
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let e = &b[6 + 9 * i..6 + 9 * (i + 1)];
|
||||
let off = u32::from_le_bytes(e[1..5].try_into().unwrap()) as usize;
|
||||
let len = u32::from_le_bytes(e[5..9].try_into().unwrap()) as usize;
|
||||
(e[0], body[off..off + len].to_vec())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The container rebuilt with section `kind`'s body replaced by a count of 2^32 - 1.
|
||||
fn declaring_too_many(b: &[u8], kind: u8) -> Vec<u8> {
|
||||
let mut secs = sections(b);
|
||||
let s = secs.iter_mut().find(|(k, _)| *k == kind).expect("the vector has the section");
|
||||
s.1 = u32::MAX.to_le_bytes().to_vec();
|
||||
let mut out = b[..4].to_vec();
|
||||
out.extend_from_slice(&(secs.len() as u16).to_le_bytes());
|
||||
let mut off = 0u32;
|
||||
for (k, body) in &secs {
|
||||
out.push(*k);
|
||||
out.extend_from_slice(&off.to_le_bytes());
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
off += body.len().div_ceil(8) as u32 * 8;
|
||||
}
|
||||
for (_, body) in &secs {
|
||||
out.extend_from_slice(body);
|
||||
out.resize(out.len() + (body.len().div_ceil(8) * 8 - body.len()), 0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_container_refuses_a_count_its_bytes_cannot_hold_without_reserving_it() {
|
||||
let cases: [(&str, &str, u8, fn(&[u8]) -> bool); 7] = [
|
||||
("world assets", "wai-web/demo/vectors/gallery.wwld", 0x03, |b| wai::world::WorldContainer::from_bytes(b).is_err()),
|
||||
("audio scene objects", "wai-web/demo/audio/moving.wscn", 0x02, |b| wai::audio_scene::SpatialScene::from_bytes(b).is_err()),
|
||||
("audio scene assets", "wai-web/demo/audio/moving.wscn", 0x03, |b| wai::audio_scene::SpatialScene::from_bytes(b).is_err()),
|
||||
("reel shots", "film-conformance/vectors/coverage.wrel", 0x03, |b| wai::film::Reel::from_bytes(b).is_err()),
|
||||
("score notes", "score-conformance/vectors/arp.wscr", 0x02, |b| wai::score::Score::from_bytes(b).is_err()),
|
||||
("haptic channels", "haptic-conformance/vectors/ramp.whap", 0x02, |b| wai::haptic::HapticSignal::from_bytes(b).is_err()),
|
||||
("splat4d tracks", "volumetric-conformance/vectors/morph.w4ds", 0x02, |b| wai::splat4d::Splat4dClipData::from_bytes(b).is_err()),
|
||||
];
|
||||
for (what, rel, kind, refused) in cases {
|
||||
let original = vector(rel);
|
||||
let bytes = if sections(&original).iter().any(|(k, _)| *k == kind) {
|
||||
declaring_too_many(&original, kind)
|
||||
} else {
|
||||
// The vector carries no such section (the audio scene's assets are optional):
|
||||
// add one, as the last section.
|
||||
let mut with = original.clone();
|
||||
let n = u16::from_le_bytes([with[4], with[5]]);
|
||||
with[4..6].copy_from_slice(&(n + 1).to_le_bytes());
|
||||
let secs = sections(&original);
|
||||
let end: usize = secs.iter().map(|(_, b)| b.len().div_ceil(8) * 8).sum();
|
||||
let mut entry = vec![kind];
|
||||
entry.extend_from_slice(&(end as u32).to_le_bytes());
|
||||
entry.extend_from_slice(&4u32.to_le_bytes());
|
||||
let table_end = 6 + 9 * n as usize;
|
||||
with.splice(table_end..table_end, entry);
|
||||
let body_start = table_end + 9;
|
||||
with.truncate(body_start + end);
|
||||
with.resize(body_start + end, 0);
|
||||
with.extend_from_slice(&u32::MAX.to_le_bytes());
|
||||
with
|
||||
};
|
||||
LARGEST.store(0, Relaxed);
|
||||
assert!(refused(&bytes), "{what}: 2^32 - 1 entries in four bytes were accepted");
|
||||
let largest = LARGEST.load(Relaxed);
|
||||
assert!(largest <= 8 << 20, "{what}: the reader asked for {largest} bytes at once");
|
||||
}
|
||||
}
|
||||
165
wai/wai-rs/tests/extension_duration_limits.rs
Normal file
165
wai/wai-rs/tests/extension_duration_limits.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
//! Each extension format's reader holds its declared duration to the reader limits it
|
||||
//! shares with SPEC §2 (`wai::limits`): samples, counting every channel, for a score,
|
||||
//! a haptic signal and an audio scene's stereo floor; frames for a splat4d clip, a film
|
||||
//! and a reel's shots together. Rendering allocates in proportion to the declaration,
|
||||
//! and nothing bounded it: a few bytes could ask a renderer for more memory than any
|
||||
//! machine has.
|
||||
//!
|
||||
//! For each, a committed vector's contract is rewritten to declare exactly the limit,
|
||||
//! which must still read, and one past it, which must be refused by name
|
||||
//! (`duration_too_long`). A film whose last tick passes 2^64 - 1 is refused too: its
|
||||
//! tick arithmetic used to wrap in release builds and panic in debug ones.
|
||||
#![cfg(all(
|
||||
feature = "world",
|
||||
feature = "audio_scene",
|
||||
feature = "film",
|
||||
feature = "score",
|
||||
feature = "haptic",
|
||||
feature = "splat4d"
|
||||
))]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use wai::limits::{RENDER_MAX_FRAMES, RENDER_MAX_SAMPLES};
|
||||
|
||||
fn vector(rel: &str) -> Vec<u8> {
|
||||
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join(rel);
|
||||
std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()))
|
||||
}
|
||||
|
||||
/// The sections of a container in the layout these formats share: a magic, a `u16`
|
||||
/// count, a table of `(kind u8, offset u32, length u32)`, then the bodies.
|
||||
fn sections(b: &[u8]) -> Vec<(u8, Vec<u8>)> {
|
||||
let n = u16::from_le_bytes([b[4], b[5]]) as usize;
|
||||
let body = &b[6 + 9 * n..];
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let e = &b[6 + 9 * i..6 + 9 * (i + 1)];
|
||||
let off = u32::from_le_bytes(e[1..5].try_into().unwrap()) as usize;
|
||||
let len = u32::from_le_bytes(e[5..9].try_into().unwrap()) as usize;
|
||||
(e[0], body[off..off + len].to_vec())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build(magic: &[u8], secs: &[(u8, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut out = magic[..4].to_vec();
|
||||
out.extend_from_slice(&(secs.len() as u16).to_le_bytes());
|
||||
let mut off = 0u32;
|
||||
for (k, body) in secs {
|
||||
out.push(*k);
|
||||
out.extend_from_slice(&off.to_le_bytes());
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
off += body.len().div_ceil(8) as u32 * 8;
|
||||
}
|
||||
for (_, body) in secs {
|
||||
out.extend_from_slice(body);
|
||||
out.resize(out.len() + (body.len().div_ceil(8) * 8 - body.len()), 0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The container with its contract (section 0x01) rewritten by `edit`.
|
||||
fn with_contract(b: &[u8], edit: impl Fn(&mut serde_json::Map<String, serde_json::Value>)) -> Vec<u8> {
|
||||
let mut secs = sections(b);
|
||||
let c = secs.iter_mut().find(|(k, _)| *k == 0x01).expect("a contract section");
|
||||
let mut v: serde_json::Value = serde_json::from_slice(&c.1).expect("the contract parses");
|
||||
edit(v.as_object_mut().unwrap());
|
||||
c.1 = serde_json::to_vec(&v).unwrap();
|
||||
build(b, &secs)
|
||||
}
|
||||
|
||||
fn set(key: &'static str, value: u64) -> impl Fn(&mut serde_json::Map<String, serde_json::Value>) {
|
||||
move |o| {
|
||||
o.insert(key.into(), value.into());
|
||||
}
|
||||
}
|
||||
|
||||
fn channels(b: &[u8]) -> u64 {
|
||||
let secs = sections(b);
|
||||
let c = &secs.iter().find(|(k, _)| *k == 0x01).unwrap().1;
|
||||
let v: serde_json::Value = serde_json::from_slice(c).unwrap();
|
||||
v["channels"].as_u64().unwrap_or(1).max(1)
|
||||
}
|
||||
|
||||
fn refused<E: std::fmt::Debug>(what: &str, r: Result<impl Sized, E>) {
|
||||
let e = format!("{:?}", r.err().unwrap_or_else(|| panic!("{what}: past the limit, and read")));
|
||||
assert!(e.contains("duration_too_long:"), "{what}: refused for another reason: {e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_formats_are_held_to_the_sample_limit_counting_every_channel() {
|
||||
let score = vector("score-conformance/vectors/arp.wscr");
|
||||
let at = RENDER_MAX_SAMPLES / channels(&score);
|
||||
wai::score::Score::from_bytes(&with_contract(&score, set("duration", at))).expect("a score at the limit reads");
|
||||
refused("score", wai::score::Score::from_bytes(&with_contract(&score, set("duration", at + 1))));
|
||||
|
||||
let haptic = vector("haptic-conformance/vectors/ramp.whap");
|
||||
let at = RENDER_MAX_SAMPLES / channels(&haptic);
|
||||
wai::haptic::HapticSignal::from_bytes(&with_contract(&haptic, set("duration", at))).expect("a signal at the limit reads");
|
||||
refused("haptic", wai::haptic::HapticSignal::from_bytes(&with_contract(&haptic, set("duration", at + 1))));
|
||||
|
||||
// A scene's floor is stereo: two samples for every one of `duration`.
|
||||
let scene = vector("wai-web/demo/audio/moving.wscn");
|
||||
let at = RENDER_MAX_SAMPLES / 2;
|
||||
wai::audio_scene::SpatialScene::from_bytes(&with_contract(&scene, set("duration", at))).expect("a scene at the limit reads");
|
||||
refused("audio scene", wai::audio_scene::SpatialScene::from_bytes(&with_contract(&scene, set("duration", at + 1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_formats_are_held_to_the_frame_limit() {
|
||||
let clip = vector("volumetric-conformance/vectors/morph.w4ds");
|
||||
wai::splat4d::Splat4dClipData::from_bytes(&with_contract(&clip, set("n_frames", RENDER_MAX_FRAMES)))
|
||||
.expect("a clip at the limit reads");
|
||||
refused("splat4d", wai::splat4d::Splat4dClipData::from_bytes(&with_contract(&clip, set("n_frames", RENDER_MAX_FRAMES + 1))));
|
||||
|
||||
let film = vector("film-conformance/vectors/rise.wflm");
|
||||
wai::film::Film::from_bytes(&with_contract(&film, set("frames", RENDER_MAX_FRAMES))).expect("a film at the limit reads");
|
||||
refused("film", wai::film::Film::from_bytes(&with_contract(&film, set("frames", RENDER_MAX_FRAMES + 1))));
|
||||
|
||||
// The reel: the first shot's frames (the first u64 after the shot count) set so the
|
||||
// shots together are exactly at the limit, then one past it.
|
||||
let reel = vector("film-conformance/vectors/coverage.wrel");
|
||||
let secs = sections(&reel);
|
||||
let shots = &secs.iter().find(|(k, _)| *k == 0x03).expect("a shots section").1;
|
||||
let n = u32::from_le_bytes(shots[..4].try_into().unwrap()) as usize;
|
||||
let mut others = 0u64;
|
||||
let mut p = 4;
|
||||
let mut firsts = Vec::new();
|
||||
for i in 0..n {
|
||||
let frames = u64::from_le_bytes(shots[p..p + 8].try_into().unwrap());
|
||||
firsts.push(p);
|
||||
if i > 0 {
|
||||
others += frames;
|
||||
}
|
||||
let keys = u32::from_le_bytes(shots[p + 24..p + 28].try_into().unwrap()) as usize;
|
||||
p += 24 + 4 + keys * 72;
|
||||
}
|
||||
let with_first = |frames: u64| {
|
||||
let mut s = secs.clone();
|
||||
let body = &mut s.iter_mut().find(|(k, _)| *k == 0x03).unwrap().1;
|
||||
body[firsts[0]..firsts[0] + 8].copy_from_slice(&frames.to_le_bytes());
|
||||
build(&reel, &s)
|
||||
};
|
||||
wai::film::Reel::from_bytes(&with_first(RENDER_MAX_FRAMES - others)).expect("a reel at the limit reads");
|
||||
refused("reel", wai::film::Reel::from_bytes(&with_first(RENDER_MAX_FRAMES - others + 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_film_whose_last_tick_passes_u64_is_refused() {
|
||||
let film = vector("film-conformance/vectors/rise.wflm");
|
||||
let past = with_contract(&film, |o| {
|
||||
o.insert("frames".into(), 3u64.into());
|
||||
o.insert("start_tick".into(), (u64::MAX - 1).into());
|
||||
o.insert("ticks_per_frame".into(), 1u64.into());
|
||||
});
|
||||
let e = format!("{:?}", wai::film::Film::from_bytes(&past).err().expect("refused"));
|
||||
assert!(e.contains("last tick passes"), "{e}");
|
||||
let at = with_contract(&film, |o| {
|
||||
o.insert("frames".into(), 2u64.into());
|
||||
o.insert("start_tick".into(), (u64::MAX - 1).into());
|
||||
o.insert("ticks_per_frame".into(), 1u64.into());
|
||||
});
|
||||
let f = wai::film::Film::from_bytes(&at).expect("a schedule ending at 2^64 - 1 reads");
|
||||
assert_eq!(f.frame_tick(1), u64::MAX);
|
||||
}
|
||||
357
wai/wai-rs/tests/media_decode_bounds.rs
Normal file
357
wai/wai-rs/tests/media_decode_bounds.rs
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
//! The image, audio and video decoders hold a payload to the reader limits of SPEC §2:
|
||||
//! a picture past the limits, an audio payload that declares or decodes to more samples
|
||||
//! than the limit, and a video payload past them are refused by name, from what the
|
||||
//! payload declares wherever it declares it, and one at a limit is not refused for it.
|
||||
//!
|
||||
//! - Pictures: `check_dimensions` at and one past each limit; real PNG, JPEG XL and
|
||||
//! AVIF payloads one pixel past the side limit; PNG, JPEG and AVIF headers that
|
||||
//! declare more pixels than the limit with sides within it. Each decoder decodes only
|
||||
//! the format its capability names, and JPEG XL decodes to three channels whatever
|
||||
//! the source holds. The writers refuse what a reader may refuse.
|
||||
//! - Audio: a FLAC stream whose STREAMINFO declares 2^36 - 1 samples is refused from the
|
||||
//! declaration (it used to ask the allocator for 256 GiB up front, which aborts the
|
||||
//! process wherever the allocator refuses that); the declared count is
|
||||
//! taken over every channel; a 24-bit stream decodes to [-1, 1]; both decoders refuse
|
||||
//! output past a limit the caller gives, and not at it.
|
||||
//! - Video: the framing header past each limit; more temporal units than the header
|
||||
//! declares; a frame larger than it declares; the whole-output budget, at and one
|
||||
//! past it; monochrome and 10-bit 4:4:4 streams, which the decoder used to panic on
|
||||
//! or misread.
|
||||
//!
|
||||
//! Exercises the codec wrappers, so it requires the `codecs` feature.
|
||||
#![cfg(feature = "codecs")]
|
||||
|
||||
use wai::codecs::audio::{
|
||||
flac_decode, flac_decode_within, flac_encode, opus_decode_within, opus_encode,
|
||||
AUDIO_DECODED_MAX_SAMPLES,
|
||||
};
|
||||
use wai::codecs::image::{
|
||||
avif_decode, avif_encode, check_dimensions, jpeg_decode, jpeg_encode, jxl_decode,
|
||||
jxl_encode, png_decode, png_encode, IMAGE_MAX_PIXELS, IMAGE_MAX_SIDE,
|
||||
};
|
||||
use wai::codecs::video::{
|
||||
av1_decode, av1_decode_each, av1_decode_within, av1_encode, av1_header,
|
||||
VIDEO_MAX_TEMPORAL_UNITS,
|
||||
};
|
||||
|
||||
fn why<T: std::fmt::Debug, E: std::fmt::Debug>(r: Result<T, E>) -> String {
|
||||
format!("{:?}", r.expect_err("the payload must be refused"))
|
||||
}
|
||||
|
||||
fn grey(h: u32, w: u32) -> Vec<u8> {
|
||||
(0..h * w * 3).map(|i| (i % 251) as u8).collect()
|
||||
}
|
||||
|
||||
// ---- pictures ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn picture_limits_hold_at_and_one_past_each_bound() {
|
||||
let side = IMAGE_MAX_SIDE as u64;
|
||||
assert_eq!(IMAGE_MAX_PIXELS, 1 << 27);
|
||||
for (w, h) in [(side, 1), (1, side), (16_384, 8_192), (side, IMAGE_MAX_PIXELS / side)] {
|
||||
check_dimensions(w, h).unwrap_or_else(|e| panic!("{w} × {h} is within the limits: {e:?}"));
|
||||
}
|
||||
for (w, h) in [(side + 1, 1), (1, side + 1), (16_384, 8_193), (side, IMAGE_MAX_PIXELS / side + 1)] {
|
||||
let e = why(check_dimensions(w, h));
|
||||
assert!(e.contains("image_too_large:"), "{w} × {h}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_and_jxl_one_pixel_past_the_side_limit_are_refused() {
|
||||
// Made with the libraries themselves: the reference writers refuse such a picture.
|
||||
let w = IMAGE_MAX_SIDE + 1;
|
||||
let rgb = grey(1, w);
|
||||
let mut png = Vec::new();
|
||||
image::ImageEncoder::write_image(
|
||||
image::codecs::png::PngEncoder::new(&mut png), &rgb, w, 1, image::ExtendedColorType::Rgb8,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(why(png_decode(&png)).contains("image_too_large:"));
|
||||
let mut enc = jpegxl_rs::encoder_builder().lossless(true).uses_original_profile(true).build().unwrap();
|
||||
let jxl = enc.encode::<u8, u8>(&rgb, w, 1).unwrap().data;
|
||||
assert!(why(jxl_decode(&jxl)).contains("image_too_large:"));
|
||||
// At the side limit both decode.
|
||||
let rgb = grey(1, IMAGE_MAX_SIDE);
|
||||
let (out, h, w) = png_decode(&png_encode(&rgb, 1, IMAGE_MAX_SIDE).unwrap()).unwrap();
|
||||
assert_eq!((out.len(), h, w), (rgb.len(), 1, IMAGE_MAX_SIDE));
|
||||
let (out, h, w) = jxl_decode(&jxl_encode(&rgb, 1, IMAGE_MAX_SIDE, None).unwrap()).unwrap();
|
||||
assert_eq!((out, h, w), (rgb, 1, IMAGE_MAX_SIDE));
|
||||
}
|
||||
|
||||
/// CRC-32 (ISO 3309), for PNG chunks.
|
||||
fn crc32(bytes: &[u8]) -> u32 {
|
||||
let mut c = 0xFFFF_FFFFu32;
|
||||
for &b in bytes {
|
||||
c ^= b as u32;
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_and_jpeg_headers_past_the_pixel_limit_are_refused_before_decoding() {
|
||||
// 16 384 × 8 193: each side within the limit, one row of pixels past it. Only the
|
||||
// header says so; the picture data is an 8 × 8's, so a decoder that read it first
|
||||
// would fail for another reason.
|
||||
let rgb = grey(8, 8);
|
||||
let mut png = png_encode(&rgb, 8, 8).unwrap();
|
||||
assert_eq!(&png[12..16], b"IHDR");
|
||||
png[16..20].copy_from_slice(&16_384u32.to_be_bytes());
|
||||
png[20..24].copy_from_slice(&8_193u32.to_be_bytes());
|
||||
let crc = crc32(&png[12..29]);
|
||||
png[29..33].copy_from_slice(&crc.to_be_bytes());
|
||||
assert!(why(png_decode(&png)).contains("image_too_large:"));
|
||||
|
||||
let mut jpg = jpeg_encode(&rgb, 8, 8, 90).unwrap();
|
||||
let sof = jpg.windows(2).position(|m| m == [0xFF, 0xC0]).expect("a baseline SOF0");
|
||||
jpg[sof + 5..sof + 7].copy_from_slice(&8_193u16.to_be_bytes());
|
||||
jpg[sof + 7..sof + 9].copy_from_slice(&16_384u16.to_be_bytes());
|
||||
assert!(why(jpeg_decode(&jpg)).contains("image_too_large:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avif_declarations_past_the_limits_are_refused_and_a_small_one_decodes() {
|
||||
let rgb = grey(16, 16);
|
||||
let avif = avif_encode(&rgb, 16, 16, 80).unwrap();
|
||||
let (out, h, w) = avif_decode(&avif).unwrap();
|
||||
assert_eq!((out.len(), h, w), (rgb.len(), 16, 16));
|
||||
let ispe = avif.windows(4).position(|b| b == b"ispe").expect("an ispe property");
|
||||
for (w, h) in [(IMAGE_MAX_SIDE + 1, 1u32), (16_384, 8_193)] {
|
||||
let mut a = avif.clone();
|
||||
a[ispe + 8..ispe + 12].copy_from_slice(&w.to_be_bytes());
|
||||
a[ispe + 12..ispe + 16].copy_from_slice(&h.to_be_bytes());
|
||||
let e = why(avif_decode(&a));
|
||||
assert!(e.contains("image_too_large:"), "{w} × {h}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_image_decoder_decodes_only_its_own_format() {
|
||||
let rgb = grey(8, 8);
|
||||
let png = png_encode(&rgb, 8, 8).unwrap();
|
||||
let jpg = jpeg_encode(&rgb, 8, 8, 90).unwrap();
|
||||
assert!(png_decode(&jpg).is_err(), "png_decode read a JPEG");
|
||||
assert!(jpeg_decode(&png).is_err(), "jpeg_decode read a PNG");
|
||||
assert!(png_decode(&png).is_ok() && jpeg_decode(&jpg).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_writers_refuse_what_a_reader_may_refuse() {
|
||||
let w = IMAGE_MAX_SIDE + 1;
|
||||
let rgb = grey(1, w);
|
||||
assert!(why(png_encode(&rgb, 1, w)).contains("image_too_large:"));
|
||||
assert!(why(jpeg_encode(&rgb, 1, w, 90)).contains("image_too_large:"));
|
||||
assert!(why(avif_encode(&rgb, 1, w, 80)).contains("image_too_large:"));
|
||||
assert!(why(jxl_encode(&rgb, 1, w, None)).contains("image_too_large:"));
|
||||
let long = vec![0f32; AUDIO_DECODED_MAX_SAMPLES + 1];
|
||||
assert!(why(flac_encode(&long, 48_000)).contains("audio_decoded_too_long:"));
|
||||
assert!(why(opus_encode(&long, 48_000, 64_000)).contains("audio_decoded_too_long:"));
|
||||
drop(long);
|
||||
let frame = grey(1, w);
|
||||
assert!(why(av1_encode(&[frame], 1, w, 30, 1, false, 60)).contains("video_too_large:"));
|
||||
}
|
||||
|
||||
// ---- audio ---------------------------------------------------------------------
|
||||
|
||||
fn tone(n: usize, amp: f32) -> Vec<f32> {
|
||||
(0..n).map(|i| amp * (i as f32 * 0.05).sin()).collect()
|
||||
}
|
||||
|
||||
/// Rewrites a FLAC stream's STREAMINFO: its channel count and its 36-bit total.
|
||||
fn declare(flac: &[u8], channels: u8, total: u64) -> Vec<u8> {
|
||||
let mut f = flac.to_vec();
|
||||
assert_eq!(&f[..4], b"fLaC");
|
||||
let si = 8; // after the marker and the STREAMINFO block header
|
||||
// Byte 12 of STREAMINFO: the low bit of the sample rate, three bits of channels - 1,
|
||||
// the high bit of bits per sample - 1. Byte 13: four bits of bps - 1, then the
|
||||
// total's top four bits; bytes 14..18 the rest of it.
|
||||
f[si + 12] = (f[si + 12] & 0xF1) | ((channels - 1) << 1);
|
||||
f[si + 13] = (f[si + 13] & 0xF0) | ((total >> 32) as u8 & 0x0F);
|
||||
f[si + 14..si + 18].copy_from_slice(&(total as u32).to_be_bytes());
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flac_stream_that_declares_too_many_samples_is_refused_from_the_declaration() {
|
||||
let flac = flac_encode(&tone(4_096, 0.5), 48_000).unwrap();
|
||||
let max = AUDIO_DECODED_MAX_SAMPLES as u64;
|
||||
// 2^36 - 1, the largest total STREAMINFO can carry: the reader used to reserve that
|
||||
// many samples, 256 GiB, before decoding one. Now it refuses, having allocated
|
||||
// nothing for it.
|
||||
for (ch, total) in [(1, (1u64 << 36) - 1), (1, max + 1), (2, max / 2 + 1)] {
|
||||
let e = why(flac_decode(&declare(&flac, ch, total)));
|
||||
assert!(e.contains("audio_decoded_too_long:") && e.contains("declares"), "{ch} × {total}: {e}");
|
||||
}
|
||||
// At the limit the declaration is not refused (the stream then decodes what it holds).
|
||||
let at = declare(&flac, 1, max);
|
||||
let (out, _) = flac_decode(&at).unwrap();
|
||||
assert_eq!(out.len(), 4_096);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_decodes_a_24_bit_stream_to_unit_scale() {
|
||||
use flac_bound::{FlacEncoder, WriteWrapper};
|
||||
let x = tone(4_800, 0.5);
|
||||
let ints: Vec<i32> = x.iter().map(|&s| (s * 8_388_607.0).round() as i32).collect();
|
||||
let mut bytes = Vec::new();
|
||||
{
|
||||
let mut ww = WriteWrapper(&mut bytes);
|
||||
let mut enc = FlacEncoder::new().unwrap().channels(1).bits_per_sample(24)
|
||||
.sample_rate(48_000).init_write(&mut ww).unwrap();
|
||||
enc.process_interleaved(&ints, ints.len() as u32).unwrap();
|
||||
enc.finish().unwrap();
|
||||
}
|
||||
let (out, _) = flac_decode(&bytes).unwrap();
|
||||
let peak = out.iter().fold(0f32, |m, &v| m.max(v.abs()));
|
||||
assert!((0.49..=0.51).contains(&peak), "a 0.5 tone decoded to a peak of {peak}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_audio_decoders_refuse_output_past_the_callers_limit_and_not_at_it() {
|
||||
let x = tone(9_600, 0.5);
|
||||
let flac = flac_encode(&x, 48_000).unwrap();
|
||||
// A stream that declares its total is refused from the declaration; one whose total
|
||||
// is 0 (unknown) is refused from the count as samples decode.
|
||||
assert!(why(flac_decode_within(&declare(&flac, 1, 9_600), 9_599)).contains("declares"));
|
||||
let unknown = declare(&flac, 1, 0);
|
||||
assert!(why(flac_decode_within(&unknown, 9_599)).contains("decodes to"));
|
||||
assert_eq!(flac_decode_within(&unknown, 9_600).unwrap().0.len(), 9_600);
|
||||
|
||||
let opus = opus_encode(&x, 48_000, 64_000).unwrap();
|
||||
let n = opus_decode_within(&opus, usize::MAX).unwrap().0.len();
|
||||
assert!(n > 9_000, "decoded {n} samples of 9 600");
|
||||
assert_eq!(opus_decode_within(&opus, n).unwrap().0.len(), n);
|
||||
assert!(why(opus_decode_within(&opus, n - 1)).contains("audio_decoded_too_long:"));
|
||||
}
|
||||
|
||||
// ---- video ---------------------------------------------------------------------
|
||||
|
||||
fn framing(n: u32, h: u32, w: u32, units: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for v in [n, h, w, 30, 1] {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
for u in units {
|
||||
out.extend_from_slice(&(u.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(u);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The temporal units of an AV1 payload, after its framing header.
|
||||
fn units_of(payload: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut at = 20;
|
||||
let mut out = Vec::new();
|
||||
while at < payload.len() {
|
||||
let n = u32::from_le_bytes(payload[at..at + 4].try_into().unwrap()) as usize;
|
||||
out.push(payload[at + 4..at + 4 + n].to_vec());
|
||||
at += 4 + n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_av1_framing_header_is_held_to_the_limits() {
|
||||
let n = VIDEO_MAX_TEMPORAL_UNITS;
|
||||
let side = IMAGE_MAX_SIDE;
|
||||
assert!(av1_header(&framing(n, 2_048, side, &[])).is_ok(), "at every limit");
|
||||
for (n, h, w) in [(n + 1, 64, 64), (1, 1, side + 1), (1, 8_193, 16_384)] {
|
||||
let e = why(av1_header(&framing(n, h, w, &[])));
|
||||
assert!(e.contains("video_too_large:"), "{n} × {w}×{h}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn av1_refuses_more_units_than_declared_and_frames_larger_than_declared() {
|
||||
let clip: Vec<Vec<u8>> = (0..3).map(|_| grey(64, 64)).collect();
|
||||
let payload = av1_encode(&clip, 64, 64, 30, 1, false, 60).unwrap();
|
||||
let units = units_of(&payload);
|
||||
assert_eq!(units.len(), 3, "one temporal unit a frame");
|
||||
assert_eq!(av1_decode(&payload).unwrap().0.len(), 3);
|
||||
let e = why(av1_decode(&framing(2, 64, 64, &units)));
|
||||
assert!(e.contains("more than the 2 temporal units"), "{e}");
|
||||
// A frame with more pixels than the header declares never reaches a picture: dav1d
|
||||
// is held to h × w pixels (the test below). One with as many pixels, but wider than
|
||||
// the header says, is decoded and then refused by its shape.
|
||||
assert!(av1_decode(&framing(3, 32, 32, &units)).is_err());
|
||||
let wide = av1_unit(128, 16, rav1e::color::ChromaSampling::Cs420, 8, [100, 128, 128]);
|
||||
let e = why(av1_decode(&framing(1, 32, 64, &[wide])));
|
||||
assert!(e.contains("128×16 frame in a payload whose frames are at most 64×32"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_whole_output_budget_refuses_from_the_header_and_streaming_does_not_need_one() {
|
||||
let clip: Vec<Vec<u8>> = (0..4).map(|_| grey(64, 64)).collect();
|
||||
let payload = av1_encode(&clip, 64, 64, 30, 1, false, 60).unwrap();
|
||||
let need = 4 * 64 * 64 * 3;
|
||||
assert_eq!(av1_decode_within(&payload, need).unwrap().0.len(), 4);
|
||||
assert!(why(av1_decode_within(&payload, need - 1)).contains("video_decoded_too_large:"));
|
||||
let mut seen = 0;
|
||||
let hd = av1_decode_each(&payload, |rgb, w, h| {
|
||||
assert_eq!((rgb.len(), w, h), (64 * 64 * 3, 64, 64));
|
||||
seen += 1;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((seen, hd.n), (4, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sequence_header_larger_than_the_framing_header_is_refused_before_it_allocates() {
|
||||
// A 256×256 stream in a framing header that says 16×16: dav1d is held to 16×16
|
||||
// pixels, so it refuses the stream's sequence header instead of allocating its
|
||||
// frames and leaving the size check to the decoded picture.
|
||||
let unit = av1_unit(256, 256, rav1e::color::ChromaSampling::Cs420, 8, [100, 128, 128]);
|
||||
let e = why(av1_decode(&framing(1, 16, 16, &[unit.clone()])));
|
||||
assert!(!e.contains("256×256 frame"), "the frame was decoded before it was refused: {e}");
|
||||
assert!(av1_decode(&framing(1, 256, 256, &[unit])).is_ok());
|
||||
}
|
||||
|
||||
/// One AV1 temporal unit of a `w`×`h` picture in the given layout and depth, every
|
||||
/// sample of plane p equal to `fill[p]`, made with rav1e.
|
||||
fn av1_unit(w: usize, h: usize, cs: rav1e::color::ChromaSampling, depth: usize, fill: [u16; 3]) -> Vec<u8> {
|
||||
use rav1e::{Config, EncoderConfig, EncoderStatus};
|
||||
let mut cfg = EncoderConfig::with_speed_preset(10);
|
||||
cfg.width = w;
|
||||
cfg.height = h;
|
||||
cfg.chroma_sampling = cs;
|
||||
cfg.bit_depth = depth;
|
||||
cfg.still_picture = true;
|
||||
cfg.quantizer = 0;
|
||||
cfg.min_quantizer = 0;
|
||||
let mut ctx: rav1e::Context<u16> = Config::new().with_encoder_config(cfg).new_context().unwrap();
|
||||
let mut f = ctx.new_frame();
|
||||
let planes = if cs == rav1e::color::ChromaSampling::Cs400 { 1 } else { 3 };
|
||||
for (p, plane) in f.planes.iter_mut().enumerate().take(planes) {
|
||||
for v in plane.data_origin_mut().iter_mut() {
|
||||
*v = fill[p];
|
||||
}
|
||||
}
|
||||
ctx.send_frame(f).unwrap();
|
||||
ctx.flush();
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
match ctx.receive_packet() {
|
||||
Ok(p) => out.extend_from_slice(&p.data),
|
||||
Err(EncoderStatus::Encoded) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn av1_decodes_monochrome_and_10_bit_4_4_4() {
|
||||
use rav1e::color::ChromaSampling::{Cs400, Cs444};
|
||||
// Monochrome, 8 bits: Y = 100 everywhere, so every RGB sample is 100.
|
||||
let mono = framing(1, 16, 16, &[av1_unit(16, 16, Cs400, 8, [100, 0, 0])]);
|
||||
let (frames, ..) = av1_decode(&mono).unwrap();
|
||||
assert!(frames[0].iter().all(|&v| v == 100), "monochrome: {:?}", &frames[0][..6]);
|
||||
// 10-bit 4:4:4: Y = 400, Cb = Cr = 512 (neutral), so every sample is 400 / 4 = 100.
|
||||
let deep = framing(1, 16, 16, &[av1_unit(16, 16, Cs444, 10, [400, 512, 512])]);
|
||||
let (frames, ..) = av1_decode(&deep).unwrap();
|
||||
assert!(frames[0].iter().all(|&v| v == 100), "10-bit 4:4:4: {:?}", &frames[0][..6]);
|
||||
}
|
||||
Loading…
Reference in a new issue