Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ PNG output is 8-bit RGB with fast compression. The Y4M writer takes range, chrom
Nothing in a browser may block, so the player uses only the parts of the decoder that do not wait:

- `GpuContext::from_adapter` creates the device on an adapter that can draw on the canvas. `GpuContext::new`, which blocks, does not exist in the browser build.
- `Decoder::encode_rgb_to_texture` records the decode and the colour conversion into the player's command encoder and puts the picture into the canvas's texture: by a copy where the canvas allows it and is a multiple of 64 pixels wide, and otherwise by a draw (`draw.wgsl`: one triangle over the canvas, which has the size of the picture, so a fragment reads one word; decisions D40 and D42). One submission per frame, and the picture never leaves the GPU.
- `Decoder::record_rgb_to_texture` records the decode and the colour conversion into the player's command encoder and puts the picture into the canvas's texture: by a copy where the canvas allows it and is a multiple of 64 pixels wide, and otherwise by a draw (`draw.wgsl`: one triangle over the canvas, which has the size of the picture, so a fragment reads one word; decisions D40 and D42). One submission per frame, and the picture never leaves the GPU.
- Error scopes cannot be waited for, so in the browser build `with_error_scope` just runs its work. The player catches what the browser reports instead (`on_uncaptured_error`) and shows the first error on the page with the next frame. Pictures wider or taller than the browser can draw (`max_texture_dimension_2d`, 8192 by default) are refused when the file is loaded.
- The methods that read a frame back or time it wait for the GPU, and are not for the browser. The decoder makes its read-back buffers only when a decode first reads back, so the player has none: 43.5 MiB fewer at 4K.
- In a browser, dropping a wgpu buffer only lets go of the JavaScript object, and the GPU memory waits for the garbage collector. `Decoder` therefore destroys its large buffers when it is dropped, and the player drops the old stream before it loads the next file: about 110 MiB for a 4K stream.
Expand Down Expand Up @@ -1452,7 +1452,7 @@ The figures of the prototype's runs above are marked as not kept.
**Context.** A frame that lost packets is decoded in part once every block of the coarsest level and more than 90 percent of its blocks have arrived, the share upstream's decoder uses (D58). Losses on Wi-Fi come in bursts, and a burst takes far more than a tenth of the frame it falls on: in emberwave-stream, with blackouts of 4 ms five times a second at 200 Mbit/s, 60 of 1194 1080p frames were skipped. Decoded on the CPU, the benchmark scene's first picture at 150 kB keeps a luma PSNR of 31.8 dB against the source with 20 % of its packets lost in a burst and 29.2 dB with half lost, against 41.3 dB whole; the previous picture, which a skip leaves on screen, is 30.9 dB from it (#123). Which a person prefers, a part of the picture blurred or a frame repeated, is a matter of the stream and the eye.

**Decision.**
- `gpu::Decoder::set_partial_share` sets the share for `is_ready(true)`, from 0 to 1, 0.9 by default: below 0 counts as 0, above 1 as 1, and NaN sets the default again. The decoder keeps it until it is set again; `FrameAssembler`'s own `is_ready` keeps upstream's share.
- `gpu::Decoder::set_partial_share` sets the share for `is_ready(true)`, from 0 to 1, 0.9 by default: below 0 counts as 0, above 1 as 1, and NaN sets the default again. The decoder keeps it until it is set again; `FrameAssembler`'s own `is_ready` keeps upstream's share. (Superseded by D70: a share outside 0 to 1, or NaN, is an error, as in the C API and the browser player.)
- The browser player's `set_partial_share` sets it for the stream open, which `is_ready(true)` and `draw_assembled` follow; the C API's `emberwave_decoder_set_partial_share` sets it for `emberwave_decoder_is_ready`. Both refuse a share outside 0 to 1, NaN included, and change nothing then.
- The demo page plays its packets with a share chosen beside the loss, 90 %, 70 % or 50 % (`?partial=0.7`).
- The default stays: a client that sets nothing decodes as before.
Expand All @@ -1475,7 +1475,7 @@ The queues wgpu makes itself have room for 4096 command buffers.
- **Making takes a turn.** Making a context on such a queue, and a decoder or an encoder on one, takes a turn and submits wgpu's writes at once, so that no context holds a command buffer between calls.
- **A failed call submits too.** A call that fails after it has written through the queue submits those writes as its turn ends.
- **One context made at a time.** The C API makes the contexts on a client's queue one at a time. From the making of its device until its first turn, a context holds a command buffer outside any turn. A second review found that three contexts made at once, beside a call that waited for room, hung a queue of 20.
- **The caller's own work.** A caller that records emberwave's work into its own encoder, with `Decoder::encode_rgb` and the like, takes a turn for its submission with `QueueTurns::take`.
- **The caller's own work.** A caller that records emberwave's work into its own encoder, with `Decoder::encode_rgb` and the like (`record_rgb` since D70), takes a turn for its submission with `QueueTurns::take`.
- **One set of turns per queue.** Contexts made on one `MTLCommandQueue` share their turns. The C API finds them by the queue's address while anything made on that queue lives; any client that makes devices on its own queue can use `GpuContext::with_queue_turns`.
- **No turns on wgpu's queues.** They cannot run out of command buffers, and turns would cost the threads that share one context up to a tenth of their throughput.
- **Docs.** The C API gives the measured counts and the room a queue needs: 20 command buffers besides those the caller holds uncommitted. It also says that nothing of emberwave may be called inside a completion handler of a command buffer of that queue.
Expand Down Expand Up @@ -1791,3 +1791,38 @@ The last frame's detail, always, gains everything on a still picture and loses u
**Consequence.**
- Every C caller changes. It sets `struct_size`, gives planes their `size`, calls the Metal submits by their new names, and checks the new statuses. The C examples, the Swift example, the benchmark app and the tests have been changed.
- From now on, a minor version can add a member to a struct without breaking a program, as the C API must from 1.0 on.

### 2026-10-31 — D70: The Rust API before 1.0

**Context.** The same audit (#152) found `emberwave-core`'s API hard to grow, and harder to use than it had to be:
- No enum or struct was `#[non_exhaustive]`, though six releases since 0.1 broke a `match` or a struct literal.
- `Decoder::encode_rgb` recorded a decode, while `Encoder::encode_rgb` encoded a picture.
- `GpuOptions::default()` read an environment variable.
- `set_partial_share` clamped a share that the C API and the browser player refuse.
- Items for tests were public.
- Calls that wait for the GPU were built for the browser, where they cannot work.

**Decision.**
- **`#[non_exhaustive]`** on the enums that grow: `GpuError`, `EncodeError`, `ColorError`, `bitstream::Error`, `bitstream::Header`, `TexturePicture`, `TimedOutput`, `ColorMatrix` and `RgbOrder`.
- **The same on the structs that grow.**
- Callers read the timings, the frames and their planes.
- Callers make `GpuOptions`, `ColorParams` and `Signalling` from their `Default` and change them member by member. `ColorParams::new` takes its three members. `Timings` has a `Default` for that too.
- The one-bit signalling enums, `Component` and `Band` stay exhaustive, as the format fixes them.
- **Names.** The decoder's calls that record into the caller's encoder are `record_rgb`, `record_rgb_to_texture` and `record_to_plane_textures`.
- **No hidden input.** `GpuOptions::default()` is the defaults alone. `GpuOptions::from_env()` reads `EMBERWAVE_BACKEND`, for the command line tool and the C API's `NULL` options.
- **One rule for the share.** `set_partial_share` returns a `Result`. A share outside 0 to 1, or NaN, is `GpuError::InvalidArgument`, a new variant, as in the C API and the browser player.
- **One name each.** `pwv1::HEADER_BYTES` is now `FILE_HEADER_BYTES`, apart from the 8 bytes of a frame's header.
- **Hidden from the docs, and outside the API**, which the crate's docs say:
- `shader_sources` and `Decoder::read_band`;
- the CPU decoder's dequantiser and transform modules;
- the block parser's structures;
- the colour module's constants, siting taps and forward matrix;
- `GpuOptions`' `strict_webgpu`, `plane_stores` and `shared_uploads`.
- **Not in the browser build.** The calls that wait for the GPU are not built for wasm32: the decoder's `decode`, `decode_rgb`, `time_frame`, `decode_assembled` and its forms, and `read_band`, and every encode of the GPU encoder. Their helpers may go unused there, which the GPU module allows on wasm32 only.
- **`wgpu` re-exported** as `emberwave_core::wgpu`: the version emberwave is built with.

**Not done.** About thirty public items take or return wgpu's types, so a new major version of wgpu breaks emberwave's Rust API. Two answers remain for 1.0: follow wgpu's major versions, or split a crate without wgpu types from the interop (#152).

**Consequence.**
- Rust callers change: a `match` on those enums gains a wildcard arm, and a struct literal of those types becomes a default changed member by member. The recording calls take their new names, and `set_partial_share`'s result is handled.
- emberwave's own crates and tests have changed with it, and so does emberwave-stream when it moves to the next release.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to emberwave. The format follows [Keep a Changelog](https://

### Added

- `emberwave_core::wgpu` is the wgpu that emberwave is built with, for sharing a device or a texture with it (decision D70).
- `EMBERWAVE_VERSION`, with `EMBERWAVE_VERSION_MAJOR`, `_MINOR` and `_PATCH`, in the C header, and `emberwave_version_number()` in the library, so that a program can tell the library it runs with from the header it was built with (decision D69, [#152](https://github.com/with-developer/emberwave/issues/152)).
- The C API's statuses `EMBERWAVE_STATUS_UNSUPPORTED`, for a call the system lacks, and `EMBERWAVE_STATUS_BUFFER_TOO_SMALL`, for an output array, a plane or an encode's budget that is too small. Later versions may add more: treat any status but `EMBERWAVE_STATUS_OK` as a failure.

Expand All @@ -19,6 +20,17 @@ All notable changes to emberwave. The format follows [Keep a Changelog](https://
- `emberwave_decoder_submit_texture` and `emberwave_decoder_submit_plane_textures`, which only Metal has, are now `emberwave_decoder_submit_texture_metal` and `emberwave_decoder_submit_plane_textures_metal`.
- `emberwave_vulkan_instance_extensions` takes the capacity of `names` apart from the count it stores, as `emberwave_frame_packets` does.
- The calls on the caller's Metal, Vulkan and Direct3D objects are provisional: until a client has used them, a minor version may change them.
- In Rust, the enums and structs that grow are `#[non_exhaustive]` (decision D70):
- the enums `GpuError`, `EncodeError`, `ColorError`, `bitstream::Error`, `bitstream::Header`, `TexturePicture`, `TimedOutput`, `ColorMatrix` and `RgbOrder`;
- the structs `GpuOptions`, `ColorParams`, `Signalling`, `Timings`, `StageTimings`, `EncodeTimings`, `EncodeStageTimings`, `Frame`, `RgbFrame` and `FramePlane`.

A `match` on them needs a wildcard arm, and a pattern of those structs needs `..`. `GpuOptions`, `ColorParams`, `Signalling` and `Timings` are made from their `Default` and changed member by member; `ColorParams::new` takes its three members. The others, the frames, their planes and the stage timings, are what emberwave's calls give: other crates read them and no longer make them.
- `Decoder::encode_rgb`, `encode_rgb_to_texture` and `encode_to_plane_textures`, which record a decode into the caller's encoder, are now `record_rgb`, `record_rgb_to_texture` and `record_to_plane_textures`.
- `GpuOptions::default()` no longer reads `EMBERWAVE_BACKEND`; `GpuOptions::from_env()` does.
- `Decoder::set_partial_share` returns a `Result`. A share outside 0 to 1, or NaN, is `GpuError::InvalidArgument`, a new variant, as in the C API and the browser player; it was clamped before.
- `bitstream::pwv1::HEADER_BYTES` is now `FILE_HEADER_BYTES`.
- The calls that wait for the GPU are not built for wasm32, where they could not work.
- Items there for emberwave's own tests are hidden from the docs and not part of the API: `shader_sources`, `Decoder::read_band`, the CPU decoder's dequantiser and transform, the block parser's structures, the colour module's constants and siting helpers, and three of `GpuOptions`' members.

## [0.16.0] - 2026-10-31

Expand Down
10 changes: 6 additions & 4 deletions crates/emberwave-cli/src/bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,10 +197,12 @@ pub fn run(args: &Args) -> Result<()> {
decoder.decode_rgb(frame, &color)?;
}
}
Ok(Timings {
prepare: start.elapsed(),
gpu_wall: Duration::ZERO,
stages: None,
Ok({
let mut timings = Timings::default();
timings.prepare = start.elapsed();
timings.gpu_wall = Duration::ZERO;
timings.stages = None;
timings
})
} else {
Ok(decoder.time_frame(frame, output)?)
Expand Down
14 changes: 8 additions & 6 deletions crates/emberwave-cli/src/encode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,14 @@ pub fn run(args: &Args) -> Result<()> {
YcbcrTransform::Bt2020,
),
};
let signalling = Signalling {
color_primaries: primaries,
transfer_function: transfer,
ycbcr_transform: transform,
ycbcr_range: header.range,
chroma_siting: header.siting,
let signalling = {
let mut signalling = Signalling::default();
signalling.color_primaries = primaries;
signalling.transfer_function = transfer;
signalling.ycbcr_transform = transform;
signalling.ycbcr_range = header.range;
signalling.chroma_siting = header.siting;
signalling
};
let width = u32::try_from(header.width)?;
let height = u32::try_from(header.height)?;
Expand Down
4 changes: 4 additions & 0 deletions crates/emberwave-cli/src/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ pub fn run(args: &Args) -> Result<()> {
"frame {index:>6}: {:>9} bytes, no start of frame header first",
frame.len()
),
Ok(_) => out!(
"frame {index:>6}: {:>9} bytes, a header this version does not know",
frame.len()
),
Err(error) => out!("frame {index:>6}: {:>9} bytes, {error}", frame.len()),
}
}
Expand Down
7 changes: 4 additions & 3 deletions crates/emberwave-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,9 +378,10 @@ fn decode_to_png_converts_the_decoded_planes_correctly() {

// The same conversion done on the CPU from the Y4M the same decoder wrote.
let (width, height, [y, cb, cr]) = first_frame(&dir.join(format!("{VECTOR}.y4m")));
let params = ColorParams {
matrix: ColorMatrix::Bt709,
..ColorParams::default()
let params = {
let mut params = ColorParams::default();
params.matrix = ColorMatrix::Bt709;
params
};
let expected = yuv_to_rgb8(
[&y, &cb, &cr],
Expand Down
3 changes: 3 additions & 0 deletions crates/emberwave-core/src/bitstream/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ fn block8_plane_bytes(control_word: u16, qscale: u8) -> usize {
/// Reads outside the block return 0. Callers check sizes first, and this
/// keeps a malformed block from ever causing a panic.
#[derive(Debug, Clone, Copy)]
#[doc(hidden)]
pub struct BlockBytes<'a> {
words: &'a [u32],
}
Expand Down Expand Up @@ -77,6 +78,7 @@ impl<'a> BlockBytes<'a> {

/// An 8x8 block that carries data (its `ballot` bit is set).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[doc(hidden)]
pub struct ActiveBlock8 {
/// Two bits per subblock: bit-planes on top of `base_planes`.
pub control_word: u16,
Expand All @@ -99,6 +101,7 @@ impl ActiveBlock8 {

/// Where the parts of a block are, worked out from its header and tables.
#[derive(Debug, Clone, PartialEq, Eq)]
#[doc(hidden)]
pub struct BlockStructure {
header: BlockHeader,
blocks8: [Option<ActiveBlock8>; BLOCK8_PER_BLOCK],
Expand Down
1 change: 1 addition & 0 deletions crates/emberwave-core/src/bitstream/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use super::header::ChromaSubsampling;
///
/// Parsing untrusted input never panics; it returns one of these instead.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum Error {
/// Width or height is outside `1..=16384`, or a 4:2:0 picture has an odd
/// dimension.
Expand Down
1 change: 1 addition & 0 deletions crates/emberwave-core/src/bitstream/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ impl SequenceHeader {

/// A parsed 8 byte header, told apart by its `extended` bit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Header {
/// Header of a coded 32x32 block.
Block(BlockHeader),
Expand Down
10 changes: 5 additions & 5 deletions crates/emberwave-core/src/bitstream/pwv1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use super::header::{SequenceHeader, read_words};
pub const MAGIC: [u8; 4] = *b"PWV1";

/// Size of the PWV1 header in bytes.
pub const HEADER_BYTES: usize = 24;
pub const FILE_HEADER_BYTES: usize = 24;

/// The only header version there is.
pub const HEADER_VERSION: u8 = 1;
Expand Down Expand Up @@ -46,7 +46,7 @@ impl Pwv1Header {
/// Parses the header at the start of `bytes`.
pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
let header = bytes
.first_chunk::<HEADER_BYTES>()
.first_chunk::<FILE_HEADER_BYTES>()
.ok_or(Error::TruncatedFile)?;
if header[..MAGIC.len()] != MAGIC {
return Err(Error::BadMagic);
Expand Down Expand Up @@ -76,8 +76,8 @@ impl Pwv1Header {
/// The 24 header bytes, the inverse of [`parse`](Self::parse). The
/// reserved fields of the stream description (`sequence`,
/// `total_blocks`, `code`) are written as they are; a file has them zero.
pub fn to_bytes(&self) -> [u8; HEADER_BYTES] {
let mut bytes = [0u8; HEADER_BYTES];
pub fn to_bytes(&self) -> [u8; FILE_HEADER_BYTES] {
let mut bytes = [0u8; FILE_HEADER_BYTES];
bytes[..MAGIC.len()].copy_from_slice(&MAGIC);
let (word0, word1) = self.stream.stream_words();
bytes[STREAM_OFFSET..STREAM_OFFSET + 4].copy_from_slice(&word0.to_le_bytes());
Expand Down Expand Up @@ -105,7 +105,7 @@ impl<'a> Pwv1File<'a> {
let header = Pwv1Header::parse(bytes)?;
Ok(Self {
header,
frames: &bytes[HEADER_BYTES..],
frames: &bytes[FILE_HEADER_BYTES..],
})
}

Expand Down
Loading
Loading