OUT-2: WebM export (VP9 intra encoder + alpha, Opus), WAV/AIFF audio-only outputs

- effectcraft-vp9enc (L0): VP9 profile 0 key-frame encoder from the VP9 bitstream spec
  (8x8 intra blocks, DC/V/H/TM, 4x4 DCT/ADST, lossless WHT at quality 100, tile columns);
  round-trips exactly through FilmCraft's VP9 decoder and ffmpeg.
- export: WebM muxer (EBML/Matroska spec) with VP9, VP9 alpha in BlockAdditional
  (AlphaMode 1), Opus (CodecDelay = pre-skip); WAV and AIFF outputs.
- OutputFormat WebM/Wav/Aiff in the Render Queue, renderQueue.add formats, CLI --format.
- Tests: FilmCraft decode-back (frames, size, pixels, audio RMS), ffprobe/ffmpeg oracles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 22:28:15 -04:00
parent 730d5a784a
commit 406706e3d5
22 changed files with 1911 additions and 20 deletions

6
Cargo.lock generated
View File

@ -1226,11 +1226,13 @@ dependencies = [
"effectcraft-raster",
"effectcraft-render",
"effectcraft-time",
"effectcraft-vp9enc",
"filmcraft-aac",
"filmcraft-h264enc",
"filmcraft-isobmff",
"filmcraft-opus",
"filmcraft-prores",
"filmcraft-vp9",
"gif",
"image",
"log",
@ -1485,6 +1487,10 @@ dependencies = [
"web-time",
]
[[package]]
name = "effectcraft-vp9enc"
version = "0.1.0"
[[package]]
name = "effectcraft-web"
version = "0.1.0"

View File

@ -29,6 +29,7 @@ effectcraft-export = { path = "crates/export" }
effectcraft-expr = { path = "crates/expr" }
effectcraft-lottie = { path = "crates/lottie" }
effectcraft-psd = { path = "crates/psd" }
effectcraft-vp9enc = { path = "crates/vp9enc" }
effectcraft-svg = { path = "crates/svg" }
effectcraft-engine = { path = "crates/engine" }
effectcraft-host = { path = "crates/host" }
@ -71,6 +72,7 @@ filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf2
filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
# decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency)
filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
# gpu (the same wgpu eframe / egui-wgpu use)
wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] }
@ -140,6 +142,9 @@ opt-level = 3
[profile.dev.package.effectcraft-export]
opt-level = 3
[profile.dev.package.effectcraft-vp9enc]
opt-level = 3
# FilmCraft codec hot loops (same settings as FilmCraft's own workspace).
[profile.dev.package.filmcraft-h264]
opt-level = 3

View File

@ -9,7 +9,7 @@
//! effectcraft-cli get <comp> <layer> <path> [--time S] read a property
//! effectcraft-cli set <comp> <layer> <path> <value> [--time S] [--expression E]
//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png]
//! effectcraft-cli render [--comp C] --out FILE [--format h264|prores|png|jpeg|tiff|exr|gif] [--start S] [--end S]
//! effectcraft-cli render [--comp C] --out FILE [--format h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff] [--start S] [--end S]
//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue

View File

@ -131,7 +131,7 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bool> {
let mut any = false;
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|prores|png|jpeg|tiff|exr|gif"))?;
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
om.set_format(f);
any = true;
}
@ -256,7 +256,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?;
let mut it = RenderQueueItem::new(0, cid);
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", "format: h264|prores|png|jpeg|tiff|exr|gif"))?;
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
it.output = OutputModule::for_format(f);
}
apply_settings(&mut it.settings, p, "renderQueue.add")?;
@ -434,7 +434,7 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
let n = it.extra_outputs.len() + 2;
let mut om = it.output.clone();
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad("render.addOutputModule", "format: h264|prores|png|jpeg|tiff|exr|gif"))?;
let f = OutputFormat::from_name(f).ok_or_else(|| bad("render.addOutputModule", "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
om = OutputModule::for_format(f);
}
if str_p(p, "output").is_none() && str_p(p, "path").is_none() {
@ -509,7 +509,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add to Render Queue",
["Composition"],
Some("Cmd+M"),
"{comp?: id|name, format?: h264|prores|png|jpeg|tiff|exr|gif, output?: path|template, quality?: best|draft|1-100 (jpeg), resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}",
"{comp?: id|name, format?: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}",
can_add,
add
),

View File

@ -159,6 +159,6 @@ fn without_exporter() {
s.execute("renderQueue.add", json!({})).unwrap();
assert!(!s.is_enabled("renderQueue.render"));
let f = s.execute("renderQueue.formats", json!({})).unwrap();
assert_eq!(f["formats"].as_array().unwrap().len(), 7);
assert_eq!(f["formats"].as_array().unwrap().len(), 10);
assert_eq!(f["formats"][0]["available"], false);
}

View File

@ -1,6 +1,6 @@
[package]
name = "effectcraft-export"
description = "Render Queue jobs and output modules: H.264 MP4 (+AAC) and ProRes MOV (+PCM) via FilmCraft's pure-Rust encoders, PNG/JPEG/TIFF/EXR sequences, animated GIF"
description = "Render Queue jobs and output modules: H.264 MP4 (+AAC) and ProRes MOV (+PCM) via FilmCraft's pure-Rust encoders, WebM (VP9 + Opus), PNG/JPEG/TIFF/EXR sequences, animated GIF, WAV/AIFF"
version.workspace = true
edition.workspace = true
license.workspace = true
@ -19,6 +19,7 @@ filmcraft-prores = { workspace = true }
filmcraft-aac = { workspace = true }
filmcraft-isobmff = { workspace = true }
effectcraft-opusenc = { workspace = true }
effectcraft-vp9enc = { workspace = true }
image = { workspace = true, features = ["png", "jpeg", "tiff", "exr"] }
gif = { workspace = true }
rayon = { workspace = true }
@ -30,3 +31,4 @@ log = { workspace = true }
effectcraft-media = { workspace = true }
effectcraft-color = { workspace = true }
filmcraft-opus = { workspace = true }
filmcraft-vp9 = { workspace = true }

View File

@ -7,6 +7,8 @@
//! | PNG / JPEG / TIFF sequence | one file per frame | 8-bit | PNG, TIFF | — |
//! | OpenEXR sequence | one file per frame | 32-bit float, linear light, premultiplied | yes | — |
//! | Animated GIF | GIF89a | 256-colour palette per frame (NeuQuant) | 1-bit | — |
//! | WebM | WebM (Matroska) | VP9 profile 0 intra frames (`effectcraft-vp9enc`), 8-bit 4:2:0 | VP9 alpha (BlockAdditional) | Opus (`effectcraft-opusenc`), 48 kHz stereo |
//! | WAV / AIFF | RIFF WAVE / AIFF | — | — | 16-bit PCM stereo (audio only) |
//!
//! Frames are rendered in parallel batches (rayon; one batch ≈ one frame per core) and handed to
//! the encoder in order. Progress is reported after each batch through a callback that can cancel
@ -18,6 +20,7 @@
mod encode;
mod out;
mod webm;
use web_time::Instant;
@ -131,6 +134,9 @@ pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result<
let mut report = match job.output.format {
OutputFormat::H264 | OutputFormat::ProRes => encode::movie(job, comp, w, h, &mut st)?,
OutputFormat::Gif => gif_export(job, comp, w, h, &mut st)?,
OutputFormat::WebM => webm::webm(job, comp, w, h, &mut st)?,
OutputFormat::Wav => webm::audio_file(job, comp, false, &mut st)?,
OutputFormat::Aiff => webm::audio_file(job, comp, true, &mut st)?,
f if f.is_sequence() => sequence(job, comp, w, h, &mut st)?,
f => return Err(ExportError::Unsupported(f.label().into())),
};

368
crates/export/src/webm.rs Normal file
View File

@ -0,0 +1,368 @@
//! WebM output (VP9 video, optional VP9 alpha, Opus audio) and audio-only WAV / AIFF.
//!
//! The WebM container is written from the Matroska / WebM specifications (IETF RFC 8794 EBML,
//! the Matroska element registry and the WebM container guidelines): EBML header, Segment
//! (size patched at the end), Info, Tracks and one Cluster per render batch. Video frames are
//! SimpleBlocks; with alpha (Channels: RGB + Alpha) each frame is a BlockGroup whose
//! BlockAdditional (BlockAddID 1) carries a second VP9 frame coding the alpha channel as luma,
//! with `AlphaMode` 1 on the track, as WebM defines. VP9 comes from `effectcraft-vp9enc` (every
//! frame a key frame), Opus from `effectcraft-opusenc` (CELT, 48 kHz stereo, 20 ms packets,
//! `CodecDelay` = pre-skip). WAV (RIFF, 16-bit PCM) and AIFF (big-endian 16-bit, 80-bit
//! extended sample rate) follow their published file layouts.
use std::io::{Seek, SeekFrom, Write};
use effectcraft_project::Comp;
use effectcraft_project::render_queue::Channels;
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use rayon::prelude::*;
use crate::{Job, Report, Result, State, batch_size, io, render_frame, rgba8, wants_audio};
const OPUS_RATE: u32 = 48_000;
const OPUS_BITRATE: u32 = 192_000;
// Element ids (with their length markers).
const EBML: u32 = 0x1A45_DFA3;
const SEGMENT: u32 = 0x1853_8067;
const INFO: u32 = 0x1549_A966;
const TRACKS: u32 = 0x1654_AE6B;
const TRACK_ENTRY: u32 = 0xAE;
const CLUSTER: u32 = 0x1F43_B675;
fn id_bytes(id: u32) -> Vec<u8> {
let b = id.to_be_bytes();
let skip = b.iter().position(|x| *x != 0).unwrap_or(3);
b[skip..].to_vec()
}
/// EBML variable-size integer for an element data size (shortest form).
fn vint(n: u64) -> Vec<u8> {
for len in 1..=8u32 {
let max = (1u64 << (7 * len)) - 2;
if n <= max {
let marked = n | (1u64 << (7 * len));
return marked.to_be_bytes()[8 - len as usize..].to_vec();
}
}
let mut v = vec![0x01];
v.extend_from_slice(&n.to_be_bytes()[1..]);
v
}
fn el(out: &mut Vec<u8>, id: u32, data: &[u8]) {
out.extend(id_bytes(id));
out.extend(vint(data.len() as u64));
out.extend_from_slice(data);
}
fn el_uint(out: &mut Vec<u8>, id: u32, v: u64) {
let b = v.to_be_bytes();
let skip = b.iter().position(|x| *x != 0).unwrap_or(7);
el(out, id, &b[skip..]);
}
fn el_float(out: &mut Vec<u8>, id: u32, v: f64) {
el(out, id, &v.to_be_bytes());
}
fn el_str(out: &mut Vec<u8>, id: u32, s: &str) {
el(out, id, s.as_bytes());
}
/// A block payload: track number, timestamp relative to the cluster, flags, frame.
fn block(track: u64, rel: i16, flags: u8, data: &[u8]) -> Vec<u8> {
let mut b = vint(track);
b.extend_from_slice(&rel.to_be_bytes());
b.push(flags);
b.extend_from_slice(data);
b
}
struct Frame {
ms: i64,
track: u64,
data: Vec<u8>,
alpha: Option<Vec<u8>>,
}
fn cluster(frames: &mut [Frame]) -> Vec<u8> {
frames.sort_by_key(|f| (f.ms, f.track));
let base = frames.first().map_or(0, |f| f.ms);
let mut body = vec![];
el_uint(&mut body, 0xE7, base.max(0) as u64);
for f in frames.iter() {
let rel = (f.ms - base).clamp(i16::MIN as i64, i16::MAX as i64) as i16;
match &f.alpha {
None => el(&mut body, 0xA3, &block(f.track, rel, 0x80, &f.data)),
Some(a) => {
let mut g = vec![];
el(&mut g, 0xA1, &block(f.track, rel, 0x00, &f.data));
let mut more = vec![];
el_uint(&mut more, 0xEE, 1);
el(&mut more, 0xA5, a);
let mut adds = vec![];
el(&mut adds, 0xA6, &more);
el(&mut g, 0x75A1, &adds);
el(&mut body, 0xA0, &g);
}
}
}
let mut out = vec![];
el(&mut out, CLUSTER, &body);
out
}
pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
let rate = job.settings.rate(comp);
let alpha = job.output.channels == Channels::Rgba;
let quality = job.output.quality.clamp(1, 100);
let cfg = effectcraft_vp9enc::EncoderConfig { width: w, height: h, quality, full_range: false };
let with_audio = wants_audio(job);
let mut opus = with_audio.then(|| effectcraft_opusenc::OpusEncoder::new(2, OPUS_BITRATE));
let total = st.total;
let frame_ms = |k: u64| -> i64 { ((k as i128 * 1000 * rate.den as i128 + rate.num as i128 / 2) / rate.num as i128) as i64 };
let duration_ms = frame_ms(total) as f64;
let mut file = crate::out::create(job.sink, job.path)?;
// EBML header.
let mut head = vec![];
let mut eb = vec![];
el_uint(&mut eb, 0x4286, 1);
el_uint(&mut eb, 0x42F7, 1);
el_uint(&mut eb, 0x42F2, 4);
el_uint(&mut eb, 0x42F3, 8);
el_str(&mut eb, 0x4282, "webm");
el_uint(&mut eb, 0x4287, 4);
el_uint(&mut eb, 0x4285, 2);
el(&mut head, EBML, &eb);
// Segment with an 8-byte size patched at the end.
head.extend(id_bytes(SEGMENT));
let size_pos = head.len() as u64;
head.extend_from_slice(&[0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]);
let seg_start = head.len() as u64;
let mut info = vec![];
el_uint(&mut info, 0x2AD7B1, 1_000_000);
el_float(&mut info, 0x4489, duration_ms);
el_str(&mut info, 0x4D80, "EffectCraft");
el_str(&mut info, 0x5741, "EffectCraft");
el(&mut head, INFO, &info);
let mut tracks = vec![];
let mut v = vec![];
el_uint(&mut v, 0xD7, 1);
el_uint(&mut v, 0x73C5, 1);
el_uint(&mut v, 0x83, 1);
el_uint(&mut v, 0x9C, 0);
el_str(&mut v, 0x86, "V_VP9");
el_uint(&mut v, 0x23E383, (1_000_000_000u128 * rate.den as u128 / rate.num.max(1) as u128) as u64);
if alpha {
el_uint(&mut v, 0x55EE, 1);
}
let mut video = vec![];
el_uint(&mut video, 0xB0, w as u64);
el_uint(&mut video, 0xBA, h as u64);
if alpha {
el_uint(&mut video, 0x53C0, 1);
}
el(&mut v, 0xE0, &video);
el(&mut tracks, TRACK_ENTRY, &v);
if let Some(o) = &opus {
let mut a = vec![];
el_uint(&mut a, 0xD7, 2);
el_uint(&mut a, 0x73C5, 2);
el_uint(&mut a, 0x83, 2);
el_uint(&mut a, 0x9C, 0);
el_str(&mut a, 0x86, "A_OPUS");
el(&mut a, 0x63A2, &o.opus_head());
el_uint(&mut a, 0x56AA, o.pre_skip() as u64 * 1_000_000_000 / OPUS_RATE as u64);
el_uint(&mut a, 0x56BB, 80_000_000);
let mut au = vec![];
el_float(&mut au, 0xB5, OPUS_RATE as f64);
el_uint(&mut au, 0x9F, 2);
el(&mut a, 0xE1, &au);
el(&mut tracks, TRACK_ENTRY, &a);
}
el(&mut head, TRACKS, &tracks);
file.write_all(&head).map_err(io)?;
let mut seg_len = head.len() as u64 - seg_start;
// Audio: mixed in blocks, encoded in 20 ms packets.
let (span_start, span_end) = job.settings.span(comp);
let mut cursor = span_start.to_units_floor(OPUS_RATE as i64);
let mut pcm_left: Vec<f32> = vec![];
let mut packets = 0u64;
let mut audio_until = |until: Tick, frames: &mut Vec<Frame>, finish: bool| {
let Some(enc) = opus.as_mut() else { return };
let end = until.to_units_floor(OPUS_RATE as i64);
if end > cursor {
let n = (end - cursor) as usize;
let start = Tick(((cursor as i128 * TICKS_PER_SECOND as i128) / OPUS_RATE as i128) as i64);
pcm_left.extend(effectcraft_render::audio::mix_comp(job.project, job.footage, job.expr, job.comp, start, n, OPUS_RATE));
cursor = end;
}
let fs = effectcraft_opusenc::OpusEncoder::FRAME_SIZE * 2;
if finish && !pcm_left.is_empty() {
// Pad the last packet (pre-skip + padding cover the encoder delay).
let pad = (fs - pcm_left.len() % fs) % fs + fs;
pcm_left.extend(std::iter::repeat_n(0.0, pad));
}
while pcm_left.len() >= fs {
let pkt = enc.encode_float(&pcm_left[..fs]);
pcm_left.drain(..fs);
frames.push(Frame { ms: packets as i64 * 20, track: 2, data: pkt, alpha: None });
packets += 1;
}
};
let batch = batch_size();
let mut i = 0;
while i < total {
let end = (i + batch).min(total);
let encoded: Vec<(Vec<u8>, Option<Vec<u8>>)> = (i..end)
.into_par_iter()
.map(|k| {
let px = rgba8(&render_frame(job, comp, k), comp, if alpha { Channels::Rgba } else { Channels::Rgb }, w, h);
let mut enc = effectcraft_vp9enc::Vp9Encoder::new(cfg.clone());
let (y, u, vv) = effectcraft_vp9enc::rgba_to_yuv420(&px, w, h, false);
let color = enc.encode_yuv420(&y, &u, &vv);
let a = alpha.then(|| {
let mut ea = effectcraft_vp9enc::Vp9Encoder::new(cfg.clone());
let (y, u, vv) = effectcraft_vp9enc::alpha_to_yuv420(&px, w, h);
ea.encode_yuv420(&y, &u, &vv)
});
(color, a)
})
.collect();
let mut frames: Vec<Frame> =
encoded.into_iter().enumerate().map(|(j, (data, alpha))| Frame { ms: frame_ms(i + j as u64), track: 1, data, alpha }).collect();
let t_end = if end >= total { span_end } else { job.settings.frame_time(comp, end) };
audio_until(t_end, &mut frames, end >= total);
let c = cluster(&mut frames);
file.write_all(&c).map_err(io)?;
seg_len += c.len() as u64;
st.advance(end - i)?;
i = end;
}
if total == 0 {
let mut frames = vec![];
audio_until(span_end, &mut frames, true);
if !frames.is_empty() {
let c = cluster(&mut frames);
file.write_all(&c).map_err(io)?;
seg_len += c.len() as u64;
}
}
// Patch the Segment size.
let mut size = [0u8; 8];
size[0] = 0x01;
size[1..].copy_from_slice(&seg_len.to_be_bytes()[1..]);
file.seek(SeekFrom::Start(size_pos)).map_err(io)?;
file.write_all(&size).map_err(io)?;
file.seek(SeekFrom::End(0)).map_err(io)?;
let bytes = file.finish()?;
Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio })
}
// ---------------------------------------------------------------- audio-only outputs
fn ieee_extended(v: f64) -> [u8; 10] {
// 80-bit IEEE 754 extended precision (AIFF COMM sample rate).
let mut out = [0u8; 10];
if v <= 0.0 {
return out;
}
let e = v.log2().floor() as i32;
let mant = (v / 2f64.powi(e) * (1u64 << 63) as f64) as u64;
out[..2].copy_from_slice(&((e + 16383) as u16).to_be_bytes());
out[2..].copy_from_slice(&mant.to_be_bytes());
out
}
/// WAV or AIFF: the comp's audio over the render span, 16-bit stereo.
pub(crate) fn audio_file(job: &Job, comp: &Comp, aiff: bool, st: &mut State) -> Result<Report> {
let sr = job.output.audio_sample_rate.clamp(8_000, 192_000);
let (span_start, span_end) = job.settings.span(comp);
let start = span_start.to_units_floor(sr as i64);
let end = span_end.to_units_floor(sr as i64);
let n = (end - start).max(0) as usize;
let mut pcm = Vec::with_capacity(n * 4);
// Mix in one-second blocks, reporting progress in frames.
let block = sr as usize;
let mut done = 0usize;
let total_frames = st.total.max(1);
let mut reported = 0u64;
while done < n {
let k = block.min(n - done);
let t = Tick((((start + done as i64) as i128 * TICKS_PER_SECOND as i128) / sr as i128) as i64);
for s in effectcraft_render::audio::mix_comp(job.project, job.footage, job.expr, job.comp, t, k, sr) {
let v = (s.clamp(-1.0, 1.0) * 32767.0).round() as i16;
pcm.extend_from_slice(&if aiff { v.to_be_bytes() } else { v.to_le_bytes() });
}
done += k;
let now = (done as u64 * total_frames / n.max(1) as u64).min(total_frames);
if now > reported && st.total > 0 {
st.advance(now - reported)?;
reported = now;
}
}
if reported < st.total {
st.advance(st.total - reported)?;
}
let mut out = vec![];
let frames = (pcm.len() / 4) as u32;
if aiff {
let mut comm = vec![];
comm.extend_from_slice(&2u16.to_be_bytes());
comm.extend_from_slice(&frames.to_be_bytes());
comm.extend_from_slice(&16u16.to_be_bytes());
comm.extend_from_slice(&ieee_extended(sr as f64));
let ssnd_len = 8 + pcm.len();
out.extend_from_slice(b"FORM");
out.extend_from_slice(&((4 + 8 + comm.len() + 8 + ssnd_len + ssnd_len % 2) as u32).to_be_bytes());
out.extend_from_slice(b"AIFF");
out.extend_from_slice(b"COMM");
out.extend_from_slice(&(comm.len() as u32).to_be_bytes());
out.extend_from_slice(&comm);
out.extend_from_slice(b"SSND");
out.extend_from_slice(&(ssnd_len as u32).to_be_bytes());
out.extend_from_slice(&[0; 8]);
out.extend_from_slice(&pcm);
if ssnd_len % 2 == 1 {
out.push(0);
}
} else {
out.extend_from_slice(b"RIFF");
out.extend_from_slice(&((36 + pcm.len()) as u32).to_le_bytes());
out.extend_from_slice(b"WAVEfmt ");
out.extend_from_slice(&16u32.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes());
out.extend_from_slice(&2u16.to_le_bytes());
out.extend_from_slice(&sr.to_le_bytes());
out.extend_from_slice(&(sr * 4).to_le_bytes());
out.extend_from_slice(&4u16.to_le_bytes());
out.extend_from_slice(&16u16.to_le_bytes());
out.extend_from_slice(b"data");
out.extend_from_slice(&(pcm.len() as u32).to_le_bytes());
out.extend_from_slice(&pcm);
}
let mut file = crate::out::create(job.sink, job.path)?;
file.write_all(&out).map_err(io)?;
let bytes = file.finish()?;
Ok(Report { path: job.path.to_string(), frames: 0, width: 0, height: 0, seconds: 0.0, bytes, audio: true })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vints_and_ids() {
assert_eq!(vint(0), vec![0x80]);
assert_eq!(vint(126), vec![0xFE]);
assert_eq!(vint(127), vec![0x40, 0x7F]);
assert_eq!(id_bytes(0xA3), vec![0xA3]);
assert_eq!(id_bytes(EBML), vec![0x1A, 0x45, 0xDF, 0xA3]);
assert_eq!(ieee_extended(44100.0), [0x40, 0x0E, 0xAC, 0x44, 0, 0, 0, 0, 0, 0]);
assert_eq!(ieee_extended(48000.0)[..4], [0x40, 0x0E, 0xBB, 0x80]);
}
}

View File

@ -363,3 +363,95 @@ fn movies_carry_audio() {
assert!(!effectcraft_media::probe(&path).expect("probe").has_audio);
}
}
#[test]
fn webm_vp9_opus_roundtrip() {
let d = out_dir("webm");
let wav = d.join("tone.wav");
write_tone(&wav, 2.0);
let (p, cid) = project(Some(&wav));
let pool = MediaPool::new();
let om = OutputModule { quality: 90, audio: AudioOutput::Auto, ..OutputModule::for_format(OutputFormat::WebM) };
let path = d.join("comp.webm");
let r = run(&p, cid, &pool, &om, &path);
assert!(r.audio && r.bytes > 200, "{r:?}");
// Decoded back through FilmCraft (Matroska demuxer, VP9 and Opus decoders).
let f = decode_movie(&path, FRAMES, 0.08);
assert!(f.has_audio);
let s = MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
let l = rms(s.iter().step_by(2).copied());
assert!((l - 0.3536).abs() < 0.06, "left RMS {l}");
ffmpeg_check(&path, 0.08);
if let Some(s) = ffprobe(&path) {
assert!(s.contains("vp9|64|48|yuv420p"), "{s}");
assert!(s.contains("opus"), "{s}");
}
}
#[test]
fn webm_with_alpha() {
let d = out_dir("webm-alpha");
let (p, cid) = project(None);
// Transparent outside the red solid.
let om = OutputModule { channels: Channels::Rgba, ..OutputModule::for_format(OutputFormat::WebM) };
let path = d.join("alpha.webm");
run(&p, cid, &NoFootage, &om, &path);
let f = effectcraft_media::probe(&path).expect("probe our output");
assert_eq!((f.width, f.height), (W, H));
let bytes = std::fs::read(&path).unwrap();
// AlphaMode element (0x53C0) and BlockAdditions (0x75A1).
assert!(bytes.windows(3).any(|w| w == [0x53, 0xC0, 0x81]), "AlphaMode 1");
assert!(bytes.windows(2).any(|w| w == [0x75, 0xA1]), "BlockAdditions");
// ffmpeg's libvpx decoder reads the alpha channel.
let png = path.with_extension("alpha.png");
let ok = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y", "-c:v", "libvpx-vp9", "-i"])
.arg(&path)
.args(["-vf", "select=eq(n\\,8)", "-frames:v", "1", "-pix_fmt", "rgba"])
.arg(&png)
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
let img = image::open(&png).unwrap().to_rgba8();
assert!(img.get_pixel(2, 2)[3] < 20, "corner transparent");
assert!(img.get_pixel(W / 2, H / 2)[3] > 235, "centre opaque");
} else {
eprintln!("ffmpeg with libvpx not available: skipping alpha oracle");
}
}
#[test]
fn wav_and_aiff_audio_only() {
let d = out_dir("audio-only");
let wav = d.join("tone.wav");
write_tone(&wav, 2.0);
let (p, cid) = project(Some(&wav));
let pool = MediaPool::new();
for (fmt, name, magic) in [(OutputFormat::Wav, "out.wav", &b"RIFF"[..]), (OutputFormat::Aiff, "out.aif", &b"FORM"[..])] {
let om = OutputModule::for_format(fmt);
let path = d.join(name);
let r = run(&p, cid, &pool, &om, &path);
assert!(r.audio);
let bytes = std::fs::read(&path).unwrap();
assert_eq!(&bytes[..4], magic);
// 1.2 s of 48 kHz 16-bit stereo.
assert!((bytes.len() as i64 - 57_600 * 4).abs() < 200, "{name}: {}", bytes.len());
let l = if fmt == OutputFormat::Wav {
// Decoded by FilmCraft.
let f = effectcraft_media::probe(&path).expect("probe audio");
assert!(f.has_audio && !f.has_video, "{name}");
let s = MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
rms(s.iter().step_by(2).copied())
} else {
// AIFF: big-endian samples after the SSND chunk's offset/block size.
let at = bytes.windows(4).position(|w| w == b"SSND").expect("SSND") + 16;
let left = bytes[at..].chunks_exact(4).skip(14_400).take(24_000).map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32767.0);
rms(left)
};
assert!((l - 0.3536).abs() < 0.01, "{name}: left RMS {l}");
if let Some(s) = ffprobe(&path) {
assert!(s.contains("pcm_s16"), "{s}");
}
}
}

View File

@ -0,0 +1,149 @@
//! The VP9 intra encoder (`effectcraft-vp9enc`) round-trips through FilmCraft's VP9 decoder (and
//! ffmpeg as an external oracle when installed): sizes, lossless exactness, PSNR.
use effectcraft_vp9enc::{EncoderConfig, Vp9Encoder};
use filmcraft_vp9::{Decoder, Plane};
fn plane_u8(p: &Plane) -> Vec<u8> {
match p {
Plane::U8(v) => v.clone(),
Plane::U16(v) => v.iter().map(|x| *x as u8).collect(),
}
}
/// Synthetic planes: gradients, noise and sharp edges.
fn source(w: usize, h: usize, kind: u32) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let mut s = 99u32 + kind;
let mut rnd = || {
s = s.wrapping_mul(1664525).wrapping_add(1013904223);
(s >> 24) as u8
};
let y: Vec<u8> = (0..w * h)
.map(|i| {
let (x, yy) = (i % w, i / w);
match kind {
0 => ((x * 255) / w.max(1)) as u8 / 2 + ((yy * 255) / h.max(1)) as u8 / 2,
1 => rnd(),
_ => {
if (x / 7 + yy / 5) % 2 == 0 {
30
} else {
220
}
}
}
})
.collect();
let u: Vec<u8> = (0..cw * ch).map(|i| (64 + (i % cw) * 128 / cw.max(1)) as u8).collect();
let v: Vec<u8> = (0..cw * ch).map(|i| (200 - (i / cw) * 100 / ch.max(1)) as u8).collect();
(y, u, v)
}
fn decode(frame: &[u8]) -> filmcraft_vp9::Picture {
let mut d = Decoder::new();
let mut pics = d.decode(frame, 0).expect("decode");
assert_eq!(pics.len(), 1);
pics.remove(0)
}
fn crop(p: &[u8], stride: usize, w: usize, h: usize) -> Vec<u8> {
(0..h).flat_map(|y| p[y * stride..y * stride + w].to_vec()).collect()
}
fn psnr(a: &[u8], b: &[u8]) -> f64 {
let mse: f64 = a.iter().zip(b).map(|(x, y)| (*x as f64 - *y as f64).powi(2)).sum::<f64>() / a.len() as f64;
if mse == 0.0 { 99.0 } else { 10.0 * (255.0f64 * 255.0 / mse).log10() }
}
#[test]
fn lossless_is_exact_for_odd_sizes() {
for (w, h) in [(1, 1), (33, 17), (64, 64), (200, 120), (7, 70)] {
for kind in 0..3 {
let (y, u, v) = source(w, h, kind);
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 100, full_range: false });
let f = e.encode_yuv420(&y, &u, &v);
let p = decode(&f);
assert_eq!((p.width, p.height), (w as u32, h as u32));
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
assert_eq!(crop(&plane_u8(&p.y), p.y_stride, w, h), y, "{w}x{h} kind {kind}: luma");
assert_eq!(crop(&plane_u8(&p.u), p.uv_stride, cw, ch), u, "{w}x{h} kind {kind}: u");
assert_eq!(crop(&plane_u8(&p.v), p.uv_stride, cw, ch), v, "{w}x{h} kind {kind}: v");
}
}
}
#[test]
fn lossy_quality_and_size() {
let (w, h) = (200, 120);
for kind in [0, 2] {
let (y, u, v) = source(w, h, kind);
let mut last = 0usize;
for q in [30u8, 60, 80, 95] {
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: q, full_range: false });
let f = e.encode_yuv420(&y, &u, &v);
let p = decode(&f);
let got = crop(&plane_u8(&p.y), p.y_stride, w, h);
let db = psnr(&got, &y);
let min = match q {
30 => 20.0,
60 => 28.0,
80 => 32.0,
_ => 38.0,
};
assert!(db > min, "kind {kind} q{q}: PSNR {db:.1} dB, {} bytes", f.len());
assert!(f.len() >= last, "higher quality, more bytes");
last = f.len();
}
}
}
#[test]
fn wide_frames_use_tile_columns() {
// 4160 px > 64 superblocks: two tile columns are required.
let (w, h) = (4160, 16);
let (y, u, v) = source(w, h, 0);
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 90, full_range: false });
let f = e.encode_yuv420(&y, &u, &v);
let p = decode(&f);
assert!(psnr(&crop(&plane_u8(&p.y), p.y_stride, w, h), &y) > 35.0);
}
#[test]
fn ffmpeg_decodes_our_stream() {
let (w, h) = (96, 64);
let (y, u, v) = source(w, h, 0);
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 100, full_range: false });
let frame = e.encode_yuv420(&y, &u, &v);
// IVF container (one frame).
let mut ivf = Vec::new();
ivf.extend_from_slice(b"DKIF");
ivf.extend_from_slice(&0u16.to_le_bytes());
ivf.extend_from_slice(&32u16.to_le_bytes());
ivf.extend_from_slice(b"VP90");
ivf.extend_from_slice(&(w as u16).to_le_bytes());
ivf.extend_from_slice(&(h as u16).to_le_bytes());
ivf.extend_from_slice(&30u32.to_le_bytes());
ivf.extend_from_slice(&1u32.to_le_bytes());
ivf.extend_from_slice(&1u32.to_le_bytes());
ivf.extend_from_slice(&0u32.to_le_bytes());
ivf.extend_from_slice(&(frame.len() as u32).to_le_bytes());
ivf.extend_from_slice(&0u64.to_le_bytes());
ivf.extend_from_slice(&frame);
let dir = std::env::temp_dir().join(format!("effectcraft-vp9-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("a.ivf");
std::fs::write(&path, &ivf).unwrap();
let out = std::process::Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-i"])
.arg(&path)
.args(["-f", "rawvideo", "-pix_fmt", "yuv420p", "-"])
.output();
let Ok(out) = out else {
eprintln!("ffmpeg not found: skipping oracle");
return;
};
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert_eq!(&out.stdout[..w * h], &y[..], "ffmpeg luma matches (lossless)");
assert_eq!(&out.stdout[w * h..w * h + u.len()], &u[..]);
}

View File

@ -161,3 +161,26 @@ fn svg_footage_and_shapes_from_vector_layer() {
assert!(d3 < 0.03, "{name}: ×3 mean diff {d3}");
}
}
#[test]
fn svg_footage_continuously_rasterizes() {
let path = tmp("cr.svg");
std::fs::write(&path, SVG).unwrap();
let doc = effectcraft_svg::parse(SVG).unwrap();
let (w, h) = doc.pixel_size();
let mut s = effectcraft_host::session();
let item = s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "C", "width": w, "height": h, "frameRate": 30, "duration": 1})).unwrap();
let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
for (k, v) in [("transform/anchor", json!([0, 0])), ("transform/position", json!([0, 0])), ("transform/scale", json!([300, 300]))] {
s.execute("prop.set", json!({"layer": lid, "path": k, "value": v})).unwrap();
}
let cid = s.active_comp_id().unwrap();
let t = effectcraft_time::Tick::ZERO;
let want = effectcraft_svg::rasterize(&doc, w, h, 3.0);
let soft = mean_diff(&s.render(cid, t, RenderOpts::default()), &want);
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "collapse", "value": true})).unwrap();
let sharp = mean_diff(&s.render(cid, t, RenderOpts::default()), &want);
assert!(sharp < 0.004, "continuously rasterised diff {sharp}");
assert!(sharp < soft, "sharper than the upscaled pixels ({sharp} vs {soft})");
}

View File

@ -392,4 +392,3 @@ mod tests {
}
}
}

View File

@ -136,17 +136,26 @@ pub enum OutputFormat {
ExrSequence,
/// Animated GIF.
Gif,
/// WebM: VP9 video (alpha with RGB + Alpha) + Opus audio.
WebM,
/// Audio only: WAV (16-bit PCM).
Wav,
/// Audio only: AIFF (16-bit PCM).
Aiff,
}
impl OutputFormat {
pub const ALL: [OutputFormat; 7] = [
pub const ALL: [OutputFormat; 10] = [
OutputFormat::H264,
OutputFormat::ProRes,
OutputFormat::WebM,
OutputFormat::PngSequence,
OutputFormat::JpegSequence,
OutputFormat::TiffSequence,
OutputFormat::ExrSequence,
OutputFormat::Gif,
OutputFormat::Wav,
OutputFormat::Aiff,
];
pub fn label(self) -> &'static str {
@ -158,6 +167,9 @@ impl OutputFormat {
OutputFormat::TiffSequence => "TIFF Sequence",
OutputFormat::ExrSequence => "OpenEXR Sequence",
OutputFormat::Gif => "Animated GIF",
OutputFormat::WebM => "WebM (VP9 + Opus)",
OutputFormat::Wav => "WAV",
OutputFormat::Aiff => "AIFF",
}
}
pub fn extension(self) -> &'static str {
@ -169,19 +181,26 @@ impl OutputFormat {
OutputFormat::TiffSequence => "tif",
OutputFormat::ExrSequence => "exr",
OutputFormat::Gif => "gif",
OutputFormat::WebM => "webm",
OutputFormat::Wav => "wav",
OutputFormat::Aiff => "aif",
}
}
pub fn is_sequence(self) -> bool {
matches!(self, OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence)
}
pub fn is_movie(self) -> bool {
matches!(self, OutputFormat::H264 | OutputFormat::ProRes)
matches!(self, OutputFormat::H264 | OutputFormat::ProRes | OutputFormat::WebM)
}
/// Audio-only outputs (no video is rendered).
pub fn is_audio_only(self) -> bool {
matches!(self, OutputFormat::Wav | OutputFormat::Aiff)
}
pub fn supports_alpha(self) -> bool {
!matches!(self, OutputFormat::H264 | OutputFormat::JpegSequence)
!matches!(self, OutputFormat::H264 | OutputFormat::JpegSequence | OutputFormat::Wav | OutputFormat::Aiff)
}
pub fn supports_audio(self) -> bool {
self.is_movie()
self.is_movie() || self.is_audio_only()
}
/// The written frame size for a rendered size: H.264 needs even dimensions (rounded down,
/// at least 2).
@ -201,6 +220,9 @@ impl OutputFormat {
"tif" | "tiff" | "tiffsequence" => OutputFormat::TiffSequence,
"exr" | "openexr" | "openexrsequence" => OutputFormat::ExrSequence,
"gif" | "animatedgif" => OutputFormat::Gif,
"webm" | "vp9" | "webmvp9opus" => OutputFormat::WebM,
"wav" | "wave" => OutputFormat::Wav,
"aif" | "aiff" | "aifc" => OutputFormat::Aiff,
_ => return None,
})
}

View File

@ -108,7 +108,7 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String,
v.push((mark(o.bitrate_kbps == kbps, &format!("Bitrate: {} Mbps", kbps / 1000)), json!({"bitrate": kbps})));
}
}
OutputFormat::JpegSequence => {
OutputFormat::JpegSequence | OutputFormat::WebM => {
v.push(("-".into(), Value::Null));
for q in [60u8, 80, 90, 100] {
v.push((mark(o.quality == q, &format!("Quality: {q}")), json!({"quality": q})));

12
crates/vp9enc/Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[package]
name = "effectcraft-vp9enc"
description = "VP9 intra-frame encoder (profile 0, 8-bit 4:2:0) for WebM export, from the VP9 Bitstream & Decoding Process Specification"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]

15
crates/vp9enc/README.md Normal file
View File

@ -0,0 +1,15 @@
# effectcraft-vp9enc
A small VP9 encoder for WebM export: every frame is a key frame (profile 0, 8-bit 4:2:0,
BT.709), coded with 8×8 intra blocks (DC / V / H / TM prediction chosen per block), 4×4
DCT/ADST transforms with default probabilities, or the Walsh–Hadamard transform at quality 100
(mathematically lossless).
Written from the **VP9 Bitstream & Decoding Process Specification, version 0.6 (31 March
2016)**: the encoder emits the uncompressed and compressed headers (§6.2, §6.3), partition, mode
info and token syntax (§6.4) and reconstructs with the specification's intra prediction (§8.5.1)
and integer inverse transforms (§8.7) so it stays in step with any conformant decoder. The
boolean encoder is the inverse of the §9.2 decoder in the form given in RFC 6386 §7.3. Spec
tables (§10) were transcribed from FilmCraft's first-party decoder `filmcraft-vp9` (MIT OR
Apache-2.0), which is also the decoder the tests round-trip through; ffmpeg is used only as an
external test oracle. No libvpx or other third-party encoder/decoder source was consulted.

193
crates/vp9enc/src/bool.rs Normal file
View File

@ -0,0 +1,193 @@
//! Boolean (arithmetic) encoder: the inverse of the VP9 boolean decoder (spec §9.2), written as
//! the classic carry-propagating range coder described in RFC 6386 §7.3 (VP8 and VP9 share the
//! bool coder).
pub struct BoolEncoder {
out: Vec<u8>,
range: u32,
bottom: u32,
bit_count: i32,
}
impl Default for BoolEncoder {
fn default() -> Self {
BoolEncoder::new()
}
}
impl BoolEncoder {
pub fn new() -> Self {
BoolEncoder { out: Vec::new(), range: 255, bottom: 0, bit_count: 24 }
}
fn add_one(&mut self) {
let mut i = self.out.len();
while i > 0 {
i -= 1;
if self.out[i] == 255 {
self.out[i] = 0;
} else {
self.out[i] += 1;
return;
}
}
}
/// Write `bit` with probability `prob`/256 of it being 0.
#[inline]
pub fn write(&mut self, bit: bool, prob: u8) {
let split = 1 + (((self.range - 1) * prob as u32) >> 8);
if bit {
self.bottom = self.bottom.wrapping_add(split);
self.range -= split;
} else {
self.range = split;
}
while self.range < 128 {
self.range <<= 1;
if self.bottom & (1 << 31) != 0 {
self.add_one();
}
self.bottom <<= 1;
self.bit_count -= 1;
if self.bit_count == 0 {
self.out.push((self.bottom >> 24) as u8);
self.bottom &= (1 << 24) - 1;
self.bit_count = 8;
}
}
}
pub fn literal(&mut self, v: u32, bits: u32) {
for i in (0..bits).rev() {
self.write((v >> i) & 1 != 0, 128);
}
}
/// Write `value` with a tree (leaves stored negated, as in the decoder's tables).
pub fn tree(&mut self, tree: &[i8], probs: &[u8], value: u8) {
// Find the path to the leaf.
fn path(tree: &[i8], node: usize, value: u8, out: &mut Vec<(usize, bool)>) -> bool {
for b in 0..2 {
let t = tree[node + b];
out.push((node >> 1, b == 1));
if t <= 0 {
if (-t) as u8 == value {
return true;
}
} else if path(tree, t as usize, value, out) {
return true;
}
out.pop();
}
false
}
let mut p = Vec::with_capacity(8);
path(tree, 0, value, &mut p);
for (i, bit) in p {
self.write(bit, probs[i]);
}
}
/// Flush (RFC 6386 §7.3) and return the bytes.
pub fn finish(mut self) -> Vec<u8> {
let mut c = self.bit_count;
let mut v = self.bottom;
if v & (1u32 << (32 - c)) != 0 {
self.add_one();
}
v <<= c & 7;
c >>= 3;
while {
c -= 1;
c >= 0
} {
v <<= 8;
}
for _ in 0..4 {
self.out.push((v >> 24) as u8);
v <<= 8;
}
self.out
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A straightforward decoder per VP9 spec §9.2.
struct Dec<'a> {
d: &'a [u8],
pos: usize,
value: u64,
bits: i32,
range: u32,
}
impl<'a> Dec<'a> {
fn new(d: &'a [u8]) -> Self {
let mut x = Dec { d, pos: 0, value: 0, bits: 0, range: 255 };
x.fill();
x
}
fn fill(&mut self) {
while self.bits <= 48 {
let b = if self.pos < self.d.len() { self.d[self.pos] } else { 0 };
self.pos += 1;
self.value |= (b as u64) << (56 - self.bits);
self.bits += 8;
}
}
fn read(&mut self, p: u8) -> bool {
let split = 1 + (((self.range - 1) * p as u32) >> 8);
let big = (split as u64) << 56;
let bit = if self.value >= big {
self.range -= split;
self.value -= big;
true
} else {
self.range = split;
false
};
while self.range < 128 {
self.range <<= 1;
self.value <<= 1;
self.bits -= 1;
}
self.fill();
bit
}
}
#[test]
fn round_trip() {
let mut e = BoolEncoder::new();
let mut seq = vec![];
let mut s = 12345u32;
for i in 0..20000 {
s = s.wrapping_mul(1103515245).wrapping_add(12345);
let p = ((s >> 8) % 255 + 1) as u8;
let bit = (s >> 20) % 256 >= p as u32 || i % 97 == 0;
seq.push((bit, p));
e.write(bit, p);
}
let d = e.finish();
let mut dec = Dec::new(&d);
for (i, (b, p)) in seq.into_iter().enumerate() {
assert_eq!(dec.read(p), b, "bit {i}");
}
}
#[test]
fn all_ones_carry() {
let mut e = BoolEncoder::new();
for _ in 0..5000 {
e.write(true, 1);
}
let d = e.finish();
let mut dec = Dec::new(&d);
for _ in 0..5000 {
assert!(dec.read(1));
}
}
}

857
crates/vp9enc/src/lib.rs Normal file
View File

@ -0,0 +1,857 @@
//! A VP9 intra-frame encoder (profile 0, 8-bit 4:2:0), written from the *VP9 Bitstream &
//! Decoding Process Specification* v0.6 by mirroring its decoding process in reverse (see
//! README.md). Every frame is a key frame: one tile row (tile columns as the frame width
//! requires), 64×64 superblocks split to 8×8 blocks, 4×4 transforms (the Walsh–Hadamard
//! transform at quality 100, which is lossless), intra modes DC / V / H / TM chosen per block by
//! prediction error, default probabilities (no probability updates). The encoder reconstructs
//! exactly what a decoder reconstructs (the specification's integer inverse transforms and
//! intra edge rules), so prediction never drifts.
mod bool;
mod tables;
use bool::BoolEncoder;
use tables::*;
const DC_PRED: u8 = 0;
const V_PRED: u8 = 1;
const H_PRED: u8 = 2;
const TM_PRED: u8 = 9;
const MODES: [u8; 4] = [DC_PRED, V_PRED, H_PRED, TM_PRED];
const DCT_DCT: u8 = 0;
const ADST_DCT: u8 = 1;
const DCT_ADST: u8 = 2;
const ADST_ADST: u8 = 3;
/// mode2txfm_map for the modes used here.
fn mode_tx_type(mode: u8) -> u8 {
match mode {
V_PRED => ADST_DCT,
H_PRED => DCT_ADST,
TM_PRED => ADST_ADST,
_ => DCT_DCT,
}
}
const PARTITION_TREE: [i8; 6] = [0, 2, -1, 4, -2, -3];
const INTRA_MODE_TREE: [i8; 18] = [0, 2, -9, 4, -1, 6, 8, 12, -2, 10, -4, -5, -3, 14, -8, 16, -6, -7];
/// extra_bits[token] = (cat, numExtra, base).
const EXTRA_BITS: [(u8, u8, u16); 11] =
[(0, 0, 0), (0, 0, 1), (0, 0, 2), (0, 0, 3), (0, 0, 4), (1, 1, 5), (2, 2, 7), (3, 3, 11), (4, 4, 19), (5, 5, 35), (6, 14, 67)];
const CAT_PROBS: [&[u8]; 7] = [
&[],
&[159],
&[165, 145],
&[173, 148, 140],
&[176, 155, 140, 135],
&[180, 157, 141, 134, 130],
&[254, 254, 254, 252, 249, 243, 230, 196, 177, 153, 140, 133, 130, 129],
];
/// Encoder settings.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EncoderConfig {
pub width: u32,
pub height: u32,
/// 1..=100; 100 is lossless.
pub quality: u8,
/// Full-range (0–255) rather than limited (16–235) YUV; only signalled.
pub full_range: bool,
}
/// Probabilities of the full token tree (9.3.2) for model probability `p`.
fn pareto(p: u8) -> [u8; 8] {
let p = p.max(1) as usize;
let x = (p - 1) / 2;
let mut t = [0u8; 8];
for n in 0..8 {
t[n] = if p & 1 == 1 { PARETO_TABLE[x * 8 + n] } else { ((PARETO_TABLE[x * 8 + n] as u32 + PARETO_TABLE[(x + 1) * 8 + n] as u32) >> 1) as u8 };
}
t
}
fn scan(tx_type: u8) -> &'static [u16; 16] {
match tx_type {
ADST_DCT => &ROW_SCAN_4X4,
DCT_ADST => &COL_SCAN_4X4,
_ => &DEFAULT_SCAN_4X4,
}
}
/// Coefficient context neighbours (9.3.2) of each scan position.
fn neighbors(tx_type: u8) -> [(usize, usize); 16] {
let sc = scan(tx_type);
let mut nb = [(0, 0); 16];
for (c, &pos) in sc.iter().enumerate().skip(1) {
let pos = pos as usize;
let (i, j) = (pos / 4, pos % 4);
nb[c] = if i > 0 && j > 0 {
let a = (i - 1) * 4 + j;
let b = i * 4 + j - 1;
match tx_type {
DCT_ADST => (a, a),
ADST_DCT => (b, b),
_ => (a, b),
}
} else if i > 0 {
((i - 1) * 4 + j, (i - 1) * 4 + j)
} else {
(j - 1, j - 1)
};
}
nb
}
// ---------------------------------------------------------------- transforms (8.7)
fn r14(v: i32) -> i32 {
(v + (1 << 13)) >> 14
}
fn idct4(x: [i32; 4]) -> [i32; 4] {
let v0 = r14(x[0] * 11585 - x[2] * 11585);
let v1 = r14(x[0] * 11585 + x[2] * 11585);
let v2 = r14(x[1] * 6270 - x[3] * 15137);
let v3 = r14(x[1] * 15137 + x[3] * 6270);
[v1 + v3, v0 + v2, v0 - v2, v1 - v3]
}
fn iadst4(x: [i32; 4]) -> [i32; 4] {
let (s19, s29, s39, s49) = (5283i64, 9929i64, 13377i64, 15212i64);
let x: [i64; 4] = x.map(|v| v as i64);
let s0 = s19 * x[0];
let s1 = s29 * x[0];
let s2 = s39 * x[1];
let s3 = s49 * x[2];
let s4 = s19 * x[2];
let s5 = s29 * x[3];
let s6 = s49 * x[3];
let s7 = s39 * (x[0] - x[2] + x[3]);
let x0 = s0 + s3 + s5;
let x1 = s1 - s4 - s6;
let x2 = s7;
let x3 = s2;
let r = |v: i64| ((v + (1 << 13)) >> 14) as i32;
[r(x0 + x3), r(x1 + x3), r(x2), r(x0 + x1 - x3)]
}
/// The decoder's 4×4 inverse transform of dequantised coefficients (row-major) to a residual.
fn inverse4(coefs: &[i32; 16], tx_type: u8) -> [i32; 16] {
let row_adst = matches!(tx_type, DCT_ADST | ADST_ADST);
let col_adst = matches!(tx_type, ADST_DCT | ADST_ADST);
let mut t = [[0i32; 4]; 4];
for i in 0..4 {
let r = [coefs[i * 4], coefs[i * 4 + 1], coefs[i * 4 + 2], coefs[i * 4 + 3]];
t[i] = if row_adst { iadst4(r) } else { idct4(r) };
}
let mut out = [0i32; 16];
for j in 0..4 {
let c = [t[0][j], t[1][j], t[2][j], t[3][j]];
let c = if col_adst { iadst4(c) } else { idct4(c) };
for i in 0..4 {
out[i * 4 + j] = (c[i] + 8) >> 4;
}
}
out
}
/// Inverse Walsh–Hadamard (8.7.1.10) on one line.
fn iwht(x: [i32; 4], shift: u32) -> [i32; 4] {
let mut a = x[0] >> shift;
let mut c = x[1] >> shift;
let mut d = x[2] >> shift;
let mut b = x[3] >> shift;
a += c;
d -= b;
let e = (a - d) >> 1;
b = e - b;
c = e - c;
a -= b;
d += c;
[a, b, c, d]
}
/// The exact inverse of [`iwht`] (with shift 0): inputs that produce outputs `y`.
fn fwht(y: [i32; 4]) -> [i32; 4] {
let (aa, bb, cc, dd) = (y[0], y[1], y[2], y[3]);
let d1 = dd - cc;
let a1 = aa + bb;
let e = (a1 - d1) >> 1;
let b0 = e - bb;
let c0 = e - cc;
let a0 = a1 - c0;
let d0 = d1 + b0;
[a0, c0, d0, b0]
}
fn inverse_wht(coefs: &[i32; 16]) -> [i32; 16] {
let mut t = [[0i32; 4]; 4];
for i in 0..4 {
t[i] = iwht([coefs[i * 4], coefs[i * 4 + 1], coefs[i * 4 + 2], coefs[i * 4 + 3]], 2);
}
let mut out = [0i32; 16];
for j in 0..4 {
let r = iwht([t[0][j], t[1][j], t[2][j], t[3][j]], 0);
for i in 0..4 {
out[i * 4 + j] = r[i];
}
}
out
}
/// Lossless forward transform: levels (dequantised by 4) whose inverse is exactly `res`.
fn forward_wht(res: &[i32; 16]) -> [i32; 16] {
let mut t = [[0i32; 4]; 4];
for j in 0..4 {
let col = fwht([res[j], res[4 + j], res[8 + j], res[12 + j]]);
for i in 0..4 {
t[i][j] = col[i];
}
}
let mut lv = [0i32; 16];
for i in 0..4 {
let r = fwht(t[i]);
lv[i * 4..i * 4 + 4].copy_from_slice(&r);
}
lv
}
/// Forward transform matrices: the inverses of the decoder's (linear part of the) inverse
/// transforms, so `inverse4(forward(res)) ≈ res`.
fn forward_matrices() -> &'static [[[f64; 16]; 16]; 4] {
use std::sync::OnceLock;
static M: OnceLock<[[[f64; 16]; 16]; 4]> = OnceLock::new();
M.get_or_init(|| {
let mut out = [[[0.0; 16]; 16]; 4];
for (ty, o) in out.iter_mut().enumerate() {
// Columns of the inverse: response to a scaled unit coefficient.
let k = 1 << 16;
let mut inv = [[0.0f64; 16]; 16];
for c in 0..16 {
let mut e = [0i32; 16];
e[c] = k;
let r = inverse4(&e, ty as u8);
for p in 0..16 {
inv[p][c] = r[p] as f64 / k as f64;
}
}
*o = invert16(inv);
}
out
})
}
fn invert16(m: [[f64; 16]; 16]) -> [[f64; 16]; 16] {
let mut a = m;
let mut inv = [[0.0; 16]; 16];
for (i, r) in inv.iter_mut().enumerate() {
r[i] = 1.0;
}
for col in 0..16 {
let piv = (col..16).max_by(|&x, &y| a[x][col].abs().total_cmp(&a[y][col].abs())).unwrap_or(col);
a.swap(col, piv);
inv.swap(col, piv);
let d = a[col][col];
for j in 0..16 {
a[col][j] /= d;
inv[col][j] /= d;
}
for r in 0..16 {
if r != col {
let f = a[r][col];
if f != 0.0 {
for j in 0..16 {
a[r][j] -= f * a[col][j];
inv[r][j] -= f * inv[col][j];
}
}
}
}
}
inv
}
// ---------------------------------------------------------------- planes and prediction
struct Plane {
w: usize,
/// Last valid column / row for prediction edges (the 8-aligned mode-info grid).
max_x: usize,
max_y: usize,
src: Vec<u8>,
rec: Vec<u8>,
}
impl Plane {
fn new(data: &[u8], vw: usize, vh: usize, w: usize, h: usize, max_x: usize, max_y: usize) -> Plane {
let mut src = vec![0u8; w * h];
for y in 0..h {
let sy = y.min(vh.saturating_sub(1));
for x in 0..w {
let sx = x.min(vw.saturating_sub(1));
src[y * w + x] = data.get(sy * vw + sx).copied().unwrap_or(128);
}
}
Plane { w, max_x, max_y, src, rec: vec![0; w * h] }
}
}
/// Intra prediction (8.5.1) of a square block for DC / V / H / TM.
fn predict(p: &Plane, x: usize, y: usize, log2: usize, mode: u8, have_left: bool, have_above: bool) -> [i32; 64] {
let size = 1usize << log2;
let base = 128i32;
let buf = &p.rec;
let w = p.w;
let mut above = [0i32; 8];
let mut left = [0i32; 8];
if have_above {
for i in 0..size {
above[i] = buf[(y - 1) * w + (x + i).min(p.max_x)] as i32;
}
} else {
above[..size].fill(base - 1);
}
let top_left = if have_above && have_left {
buf[(y - 1) * w + (x - 1).min(p.max_x)] as i32
} else if have_above {
base + 1
} else {
base - 1
};
if have_left {
for i in 0..size {
left[i] = buf[(y + i).min(p.max_y) * w + x - 1] as i32;
}
} else {
left[..size].fill(base + 1);
}
let mut out = [0i32; 64];
for i in 0..size {
for j in 0..size {
out[i * size + j] = match mode {
V_PRED => above[j],
H_PRED => left[i],
TM_PRED => (above[j] + left[i] - top_left).clamp(0, 255),
_ => 0,
};
}
}
if mode == DC_PRED {
let v = match (have_left, have_above) {
(true, true) => (left[..size].iter().sum::<i32>() + above[..size].iter().sum::<i32>() + size as i32) >> (log2 + 1),
(true, false) => (left[..size].iter().sum::<i32>() + (1 << (log2 - 1))) >> log2,
(false, true) => (above[..size].iter().sum::<i32>() + (1 << (log2 - 1))) >> log2,
(false, false) => base,
};
out[..size * size].fill(v);
}
out
}
// ---------------------------------------------------------------- the encoder
pub struct Vp9Encoder {
cfg: EncoderConfig,
qidx: u8,
lossless: bool,
}
/// Per-tile coding state.
struct Tile<'a> {
enc: &'a Vp9Encoder,
bc: BoolEncoder,
planes: &'a mut [Plane; 3],
mi_cols: usize,
mi_rows: usize,
col_start: usize,
above_nz: [Vec<u8>; 3],
left_nz: [Vec<u8>; 3],
above_part: Vec<u8>,
left_part: Vec<u8>,
/// y mode of every 8×8 block (for mode contexts).
modes: &'a mut [u8],
}
impl Vp9Encoder {
pub fn new(cfg: EncoderConfig) -> Self {
let q = cfg.quality.clamp(1, 100);
let lossless = q >= 100;
// Quality → base_q_idx: 99 ≈ 3, 90 ≈ 30, 50 ≈ 150, 1 ≈ 255.
let qidx = if lossless { 0 } else { ((100 - q as i32) * 255 / 85).clamp(1, 255) as u8 };
Vp9Encoder { cfg, qidx, lossless }
}
pub fn config(&self) -> &EncoderConfig {
&self.cfg
}
/// Encode one key frame from planar 8-bit 4:2:0 (`y`: width×height, `u`/`v`:
/// ((w+1)/2)×((h+1)/2), tightly packed).
pub fn encode_yuv420(&mut self, y: &[u8], u: &[u8], v: &[u8]) -> Vec<u8> {
let (w, h) = (self.cfg.width.max(1) as usize, self.cfg.height.max(1) as usize);
let mi_cols = w.div_ceil(8);
let mi_rows = h.div_ceil(8);
let sb_cols = mi_cols.div_ceil(8);
let sb_rows = mi_rows.div_ceil(8);
let (pw, ph) = (sb_cols * 64, sb_rows * 64);
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let mut planes = [
Plane::new(y, w, h, pw, ph, mi_cols * 8 - 1, mi_rows * 8 - 1),
Plane::new(u, cw, ch, pw / 2, ph / 2, mi_cols * 4 - 1, mi_rows * 4 - 1),
Plane::new(v, cw, ch, pw / 2, ph / 2, mi_cols * 4 - 1, mi_rows * 4 - 1),
];
// Tile columns: as few as the width allows (tile_info, 6.2.13).
let mut min_log2 = 0;
while (64usize << min_log2) < sb_cols {
min_log2 += 1;
}
let mut max_log2 = 1;
while (sb_cols >> max_log2) >= 4 {
max_log2 += 1;
}
max_log2 -= 1;
let tile_cols = 1usize << min_log2;
let mut modes = vec![0u8; mi_cols * mi_rows];
let mut tiles = vec![];
for t in 0..tile_cols {
let start = (((t * sb_cols) >> min_log2) * 8).min(mi_cols);
let end = ((((t + 1) * sb_cols) >> min_log2) * 8).min(mi_cols);
let mut tile = Tile {
enc: self,
bc: BoolEncoder::new(),
planes: &mut planes,
mi_cols,
mi_rows,
col_start: start,
above_nz: [vec![0; mi_cols * 2 + 16], vec![0; mi_cols * 2 + 16], vec![0; mi_cols * 2 + 16]],
left_nz: [vec![0; 16], vec![0; 16], vec![0; 16]],
above_part: vec![0; mi_cols + 8],
left_part: vec![0; 8],
modes: &mut modes,
};
tile.bc.write(false, 128);
for r in (0..mi_rows).step_by(8) {
for p in 0..3 {
tile.left_nz[p].fill(0);
}
tile.left_part.fill(0);
for c in (start..end).step_by(8) {
tile.partition(r, c, 12);
}
}
tiles.push(tile.bc.finish());
}
// Compressed header.
let mut ch_enc = BoolEncoder::new();
ch_enc.write(false, 128);
if !self.lossless {
ch_enc.literal(0, 2); // tx_mode ONLY_4X4
}
ch_enc.literal(0, 1); // no coefficient probability updates (TX_4X4)
for _ in 0..3 {
ch_enc.write(false, 252); // skip probabilities unchanged
}
let compressed = ch_enc.finish();
// Uncompressed header.
let mut bw = BitWriter::default();
bw.put(2, 2); // frame_marker
bw.put(0, 1); // profile low bit
bw.put(0, 1); // profile high bit
bw.put(0, 1); // show_existing_frame
bw.put(0, 1); // frame_type = KEY_FRAME
bw.put(1, 1); // show_frame
bw.put(0, 1); // error_resilient_mode
bw.put(0x49, 8);
bw.put(0x83, 8);
bw.put(0x42, 8);
bw.put(2, 3); // color_space CS_BT_709
bw.put(self.cfg.full_range as u32, 1);
bw.put(w as u32 - 1, 16);
bw.put(h as u32 - 1, 16);
bw.put(0, 1); // render_and_frame_size_different
bw.put(0, 1); // refresh_frame_context
bw.put(1, 1); // frame_parallel_decoding_mode
bw.put(0, 2); // frame_context_idx
bw.put(0, 6); // loop_filter_level
bw.put(0, 3); // sharpness
bw.put(0, 1); // mode_ref_delta_enabled
bw.put(self.qidx as u32, 8);
bw.put(0, 1); // delta_q_y_dc
bw.put(0, 1); // delta_q_uv_dc
bw.put(0, 1); // delta_q_uv_ac
bw.put(0, 1); // segmentation_enabled
if min_log2 < max_log2 {
bw.put(0, 1); // no extra tile columns
}
bw.put(0, 1); // tile_rows_log2 = 0
bw.put(compressed.len() as u32, 16);
let mut out = bw.finish();
out.extend_from_slice(&compressed);
let n = tiles.len();
for (i, t) in tiles.into_iter().enumerate() {
if i + 1 < n {
out.extend_from_slice(&(t.len() as u32).to_be_bytes());
}
out.extend_from_slice(&t);
}
out
}
}
#[derive(Default)]
struct BitWriter {
out: Vec<u8>,
acc: u32,
n: u32,
}
impl BitWriter {
fn put(&mut self, v: u32, bits: u32) {
for i in (0..bits).rev() {
self.acc = (self.acc << 1) | ((v >> i) & 1);
self.n += 1;
if self.n == 8 {
self.out.push(self.acc as u8);
self.acc = 0;
self.n = 0;
}
}
}
fn finish(mut self) -> Vec<u8> {
if self.n > 0 {
self.out.push((self.acc << (8 - self.n)) as u8);
}
self.out
}
}
const BLOCK_8X8: u8 = 3;
const NUM_8X8: [usize; 13] = [1, 1, 1, 1, 1, 2, 2, 2, 4, 4, 4, 8, 8];
const MI_WIDTH_LOG2: [usize; 13] = [0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3];
impl Tile<'_> {
fn partition(&mut self, r: usize, c: usize, bsize: u8) {
if r >= self.mi_rows || c >= self.mi_cols {
return;
}
let num8 = NUM_8X8[bsize as usize];
let half = num8 >> 1;
let has_rows = (r + half) < self.mi_rows;
let has_cols = (c + half) < self.mi_cols;
let bsl = MI_WIDTH_LOG2[bsize as usize];
let boffset = 3 - bsl;
let mut above = 0u8;
let mut left = 0u8;
for i in 0..num8 {
above |= self.above_part[c + i];
left |= self.left_part[(r & 7) + i];
}
let above = (above >> boffset) & 1;
let left = (left >> boffset) & 1;
let ctx = bsl * 4 + left as usize * 2 + above as usize;
let probs = &KF_PARTITION_PROBS[ctx * 3..ctx * 3 + 3];
if bsize == BLOCK_8X8 {
// PARTITION_NONE.
self.bc.write(false, probs[0]);
self.block(r, c);
self.above_part[c] = 15 >> 1;
self.left_part[r & 7] = 15 >> 1;
return;
}
// PARTITION_SPLIT.
if has_rows && has_cols {
self.bc.tree(&PARTITION_TREE, probs, 3);
} else if has_cols {
self.bc.write(true, probs[1]);
} else if has_rows {
self.bc.write(true, probs[2]);
}
let sub = bsize - 3;
self.partition(r, c, sub);
self.partition(r, c + half, sub);
self.partition(r + half, c, sub);
self.partition(r + half, c + half, sub);
}
fn block(&mut self, r: usize, c: usize) {
let avail_u = r > 0;
let avail_l = c > self.col_start;
// Mode decision on the 8×8 luma block and the 4×4 chroma blocks.
let y_mode = self.choose(0, c * 8, r * 8, 3, avail_l, avail_u);
let uv_mode = {
let mut best = (i64::MAX, DC_PRED);
for m in MODES {
let cost: i64 = (1..3).map(|p| self.cost(p, c * 4, r * 4, 2, m, avail_l, avail_u)).sum();
if cost < best.0 {
best = (cost, m);
}
}
best.1
};
// skip = 0 (context: neighbours' skip, always 0 here); tx_size implied (4×4).
self.bc.write(false, DEFAULT_SKIP_PROB[0]);
let above_mode = if avail_u { self.modes[(r - 1) * self.mi_cols + c] } else { DC_PRED };
let left_mode = if avail_l { self.modes[r * self.mi_cols + c - 1] } else { DC_PRED };
let o = (above_mode as usize * 10 + left_mode as usize) * 9;
self.bc.tree(&INTRA_MODE_TREE, &KF_Y_MODE_PROBS[o..o + 9], y_mode);
let o = y_mode as usize * 9;
self.bc.tree(&INTRA_MODE_TREE, &KF_UV_MODE_PROBS[o..o + 9], uv_mode);
self.modes[r * self.mi_cols + c] = y_mode;
// Residual: luma 2×2 transform blocks, then one 4×4 block per chroma plane.
for (dy, dx) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
let tx = if self.enc.lossless { DCT_DCT } else { mode_tx_type(y_mode) };
self.tx_block(0, c * 8 + dx * 4, r * 8 + dy * 4, y_mode, tx, avail_l || dx > 0, avail_u || dy > 0);
}
for p in 1..3 {
self.tx_block(p, c * 4, r * 4, uv_mode, DCT_DCT, avail_l, avail_u);
}
}
fn cost(&self, plane: usize, x: usize, y: usize, log2: usize, mode: u8, have_left: bool, have_above: bool) -> i64 {
let p = &self.planes[plane];
let pred = predict(p, x, y, log2, mode, have_left, have_above);
let size = 1 << log2;
let mut s = 0i64;
for i in 0..size {
for j in 0..size {
s += (p.src[(y + i) * p.w + x + j] as i32 - pred[i * size + j]).abs() as i64;
}
}
s
}
fn choose(&self, plane: usize, x: usize, y: usize, log2: usize, have_left: bool, have_above: bool) -> u8 {
let mut best = (i64::MAX, DC_PRED);
for m in MODES {
let cost = self.cost(plane, x, y, log2, m, have_left, have_above);
if cost < best.0 {
best = (cost, m);
}
}
best.1
}
#[allow(clippy::too_many_arguments)]
fn tx_block(&mut self, plane: usize, x: usize, y: usize, mode: u8, tx_type: u8, have_left: bool, have_above: bool) {
let ss = (plane > 0) as usize;
let max_x = (self.mi_cols * 8) >> ss;
let max_y = (self.mi_rows * 8) >> ss;
let (x4, y4) = (x >> 2, y >> 2);
let ly = y4 & (15 >> ss);
if x >= max_x || y >= max_y {
self.above_nz[plane][x4] = 0;
self.left_nz[plane][ly] = 0;
return;
}
let p = &self.planes[plane];
let pred = predict(p, x, y, 2, mode, have_left, have_above);
let mut res = [0i32; 16];
for i in 0..4 {
for j in 0..4 {
res[i * 4 + j] = p.src[(y + i) * p.w + x + j] as i32 - pred[i * 4 + j];
}
}
let (dcq, acq) = (DC_QLOOKUP[self.enc.qidx as usize], AC_QLOOKUP[self.enc.qidx as usize]);
// Quantised levels (raster order).
let levels: [i32; 16] = if self.enc.lossless {
forward_wht(&res)
} else {
let f = &forward_matrices()[tx_type as usize];
let mut lv = [0i32; 16];
for k in 0..16 {
let coef: f64 = (0..16).map(|q| f[k][q] * res[q] as f64).sum();
let qs = if k == 0 { dcq } else { acq } as f64;
let a = coef.abs() / qs;
// Rounding with a small dead zone for AC coefficients.
let l = if k == 0 { (a + 0.5).floor() } else { (a + 0.38).floor() };
lv[k] = if coef < 0.0 { -(l as i32) } else { l as i32 };
}
lv
};
let sc = scan(tx_type);
let eob = (0..16).rev().find(|&i| levels[sc[i] as usize] != 0).map_or(0, |i| i + 1);
// Tokens.
let ctx0 = (self.above_nz[plane][x4] + self.left_nz[plane][ly]) as usize;
self.tokens(plane, tx_type, &levels, eob, ctx0);
// Reconstruct exactly as the decoder does.
let mut deq = [0i32; 16];
for k in 0..16 {
deq[k] = levels[k] * if k == 0 { dcq } else { acq };
}
let resid = if eob == 0 {
[0i32; 16]
} else if self.enc.lossless {
inverse_wht(&deq)
} else {
inverse4(&deq, tx_type)
};
let p = &mut self.planes[plane];
for i in 0..4 {
for j in 0..4 {
p.rec[(y + i) * p.w + x + j] = (pred[i * 4 + j] + resid[i * 4 + j]).clamp(0, 255) as u8;
}
}
let nz = (eob > 0) as u8;
self.above_nz[plane][x4] = nz;
self.left_nz[plane][ly] = nz;
}
fn tokens(&mut self, plane: usize, tx_type: u8, levels: &[i32; 16], eob: usize, ctx0: usize) {
let sc = scan(tx_type);
let nb = neighbors(tx_type);
let ptype = (plane > 0) as usize;
// coef_probs[TX_4X4][ptype][intra]: 6 bands × 6 contexts × 3.
let base = (ptype * 2) * 6 * 6 * 3;
let mut tc = [0u8; 16];
let mut check_eob = true;
let mut c = 0;
while c < 16 {
let pos = sc[c] as usize;
let band = COEFBAND_4X4[c] as usize;
let ctx = if c == 0 { ctx0 } else { ((1 + tc[nb[c].0] + tc[nb[c].1]) >> 1) as usize };
let o = base + (band * 6 + ctx) * 3;
let p = [DEFAULT_COEF_PROBS[o], DEFAULT_COEF_PROBS[o + 1], DEFAULT_COEF_PROBS[o + 2]];
if check_eob {
let more = c < eob;
self.bc.write(more, p[0]);
if !more {
break;
}
}
let v = levels[pos].unsigned_abs();
if v == 0 {
self.bc.write(false, p[1]);
tc[pos] = 0;
check_eob = false;
c += 1;
continue;
}
self.bc.write(true, p[1]);
check_eob = true;
let token = match v {
1 => 1,
2 => 2,
3 => 3,
4 => 4,
5..=6 => 5,
7..=10 => 6,
11..=18 => 7,
19..=34 => 8,
35..=66 => 9,
_ => 10,
};
if token == 1 {
self.bc.write(false, p[2]);
} else {
self.bc.write(true, p[2]);
let pp = pareto(p[2]);
let path: &[(usize, bool)] = match token {
2 => &[(0, false), (1, false)],
3 => &[(0, false), (1, true), (2, false)],
4 => &[(0, false), (1, true), (2, true)],
5 => &[(0, true), (3, false), (4, false)],
6 => &[(0, true), (3, false), (4, true)],
7 => &[(0, true), (3, true), (5, false), (6, false)],
8 => &[(0, true), (3, true), (5, false), (6, true)],
9 => &[(0, true), (3, true), (5, true), (7, false)],
_ => &[(0, true), (3, true), (5, true), (7, true)],
};
for &(n, b) in path {
self.bc.write(b, pp[n]);
}
let (cat, num_extra, base_v) = EXTRA_BITS[token];
let extra = (v - base_v as u32).min((1 << num_extra) - 1);
let cp = CAT_PROBS[cat as usize];
for e in 0..num_extra as usize {
self.bc.write((extra >> (num_extra as usize - 1 - e)) & 1 != 0, cp[e]);
}
}
tc[pos] = ENERGY_CLASS[token];
self.bc.write(levels[pos] < 0, 128);
c += 1;
}
}
}
// ---------------------------------------------------------------- colour conversion
/// BT.709 RGB(A) 8-bit → planar YUV 4:2:0 (limited or full range).
pub fn rgba_to_yuv420(rgba: &[u8], w: u32, h: u32, full_range: bool) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let (w, h) = (w as usize, h as usize);
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let mut y = vec![0u8; w * h];
let mut u = vec![0u8; cw * ch];
let mut v = vec![0u8; cw * ch];
let (ys, yo, cs) = if full_range { (255.0, 0.0, 255.0) } else { (219.0, 16.0, 224.0) };
let px = |x: usize, yy: usize| -> [f32; 3] {
let i = (yy.min(h - 1) * w + x.min(w - 1)) * 4;
[rgba[i] as f32 / 255.0, rgba[i + 1] as f32 / 255.0, rgba[i + 2] as f32 / 255.0]
};
let luma = |c: [f32; 3]| 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
for yy in 0..h {
for x in 0..w {
y[yy * w + x] = (luma(px(x, yy)) * ys + yo).round().clamp(0.0, 255.0) as u8;
}
}
for cy in 0..ch {
for cx in 0..cw {
let mut acc = [0.0f32; 3];
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
let c = px(cx * 2 + dx, cy * 2 + dy);
for k in 0..3 {
acc[k] += c[k] / 4.0;
}
}
let l = luma(acc);
let cb = (acc[2] - l) / 1.8556;
let cr = (acc[0] - l) / 1.5748;
u[cy * cw + cx] = (cb * cs + 128.0).round().clamp(0.0, 255.0) as u8;
v[cy * cw + cx] = (cr * cs + 128.0).round().clamp(0.0, 255.0) as u8;
}
}
(y, u, v)
}
/// The alpha channel as a luma plane (full range) with neutral chroma — the second VP9 stream of
/// WebM alpha.
pub fn alpha_to_yuv420(rgba: &[u8], w: u32, h: u32) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let (w, h) = (w as usize, h as usize);
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let y: Vec<u8> = rgba.chunks_exact(4).take(w * h).map(|p| p[3]).collect();
(y, vec![128; cw * ch], vec![128; cw * ch])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wht_is_exactly_invertible() {
let mut s = 7u32;
for _ in 0..2000 {
let mut r = [0i32; 16];
for v in &mut r {
s = s.wrapping_mul(1664525).wrapping_add(1013904223);
*v = (s >> 16) as i32 % 511 - 255;
}
let lv = forward_wht(&r);
let deq = lv.map(|l| l * 4);
assert_eq!(inverse_wht(&deq), r);
}
}
#[test]
fn forward_transforms_invert_the_decoder() {
for ty in 0..4u8 {
let f = &forward_matrices()[ty as usize];
let r: [i32; 16] = std::array::from_fn(|i| (i as i32 * 37 % 61) - 30);
let c: [i32; 16] = std::array::from_fn(|k| (0..16).map(|q| f[k][q] * r[q] as f64).sum::<f64>().round() as i32);
let back = inverse4(&c, ty);
for i in 0..16 {
assert!((back[i] - r[i]).abs() <= 1, "type {ty}: {back:?} vs {r:?}");
}
}
}
}

130
crates/vp9enc/src/tables.rs Normal file
View File

@ -0,0 +1,130 @@
//! Tables from the VP9 Bitstream & Decoding Process Specification v0.6 (section 10), as
//! transcribed in FilmCraft's first-party decoder `filmcraft-vp9` (MIT OR Apache-2.0, the same
//! licence as this crate).
#![allow(clippy::unreadable_literal)]
pub const DC_QLOOKUP: [i32; 256] = [
4, 8, 8, 9, 10, 11, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 23, 24, 25, 26, 26, 27, 28, 29, 30, 31, 32, 32, 33, 34, 35, 36, 37, 38, 38, 39, 40,
41, 42, 43, 43, 44, 45, 46, 47, 48, 48, 49, 50, 51, 52, 53, 53, 54, 55, 56, 57, 57, 58, 59, 60, 61, 62, 62, 63, 64, 65, 66, 66, 67, 68, 69, 70, 70, 71, 72,
73, 74, 74, 75, 76, 77, 78, 78, 79, 80, 81, 81, 82, 83, 84, 85, 85, 87, 88, 90, 92, 93, 95, 96, 98, 99, 101, 102, 104, 105, 107, 108, 110, 111, 113, 114,
116, 117, 118, 120, 121, 123, 125, 127, 129, 131, 134, 136, 138, 140, 142, 144, 146, 148, 150, 152, 154, 156, 158, 161, 164, 166, 169, 172, 174, 177, 180,
182, 185, 187, 190, 192, 195, 199, 202, 205, 208, 211, 214, 217, 220, 223, 226, 230, 233, 237, 240, 243, 247, 250, 253, 257, 261, 265, 269, 272, 276, 280,
284, 288, 292, 296, 300, 304, 309, 313, 317, 322, 326, 330, 335, 340, 344, 349, 354, 359, 364, 369, 374, 379, 384, 389, 395, 400, 406, 411, 417, 423, 429,
435, 441, 447, 454, 461, 467, 475, 482, 489, 497, 505, 513, 522, 530, 539, 549, 559, 569, 579, 590, 602, 614, 626, 640, 654, 668, 684, 700, 717, 736, 755,
775, 796, 819, 843, 869, 896, 925, 955, 988, 1022, 1058, 1098, 1139, 1184, 1232, 1282, 1336,
];
pub const AC_QLOOKUP: [i32; 256] = [
4, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45,
46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84,
85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 104, 106, 108, 110, 112, 114, 116, 118, 120, 122, 124, 126, 128, 130, 132, 134,
136, 138, 140, 142, 144, 146, 148, 150, 152, 155, 158, 161, 164, 167, 170, 173, 176, 179, 182, 185, 188, 191, 194, 197, 200, 203, 207, 211, 215, 219, 223,
227, 231, 235, 239, 243, 247, 251, 255, 260, 265, 270, 275, 280, 285, 290, 295, 300, 305, 311, 317, 323, 329, 335, 341, 347, 353, 359, 366, 373, 380, 387,
394, 401, 408, 416, 424, 432, 440, 448, 456, 465, 474, 483, 492, 501, 510, 520, 530, 540, 550, 560, 571, 582, 593, 604, 615, 627, 639, 651, 663, 676, 689,
702, 715, 729, 743, 757, 771, 786, 801, 816, 832, 848, 864, 881, 898, 915, 933, 951, 969, 988, 1007, 1026, 1046, 1066, 1087, 1108, 1129, 1151, 1173, 1196,
1219, 1243, 1267, 1292, 1317, 1343, 1369, 1396, 1423, 1451, 1479, 1508, 1537, 1567, 1597, 1628, 1660, 1692, 1725, 1759, 1793, 1828,
];
pub const DEFAULT_SCAN_4X4: [u16; 16] = [0, 4, 1, 5, 8, 2, 12, 9, 3, 6, 13, 10, 7, 14, 11, 15];
pub const COL_SCAN_4X4: [u16; 16] = [0, 4, 8, 1, 12, 5, 9, 2, 13, 6, 10, 3, 7, 14, 11, 15];
pub const ROW_SCAN_4X4: [u16; 16] = [0, 1, 4, 2, 5, 3, 6, 8, 9, 7, 12, 10, 13, 11, 14, 15];
pub const COEFBAND_4X4: [u8; 16] = [0, 1, 1, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 5, 5, 5];
pub const ENERGY_CLASS: [u8; 12] = [0, 1, 2, 3, 3, 4, 4, 5, 5, 5, 5, 5];
pub const PARETO_TABLE: [u8; 1024] = [
3, 86, 128, 6, 86, 23, 88, 29, 9, 86, 129, 17, 88, 61, 94, 76, 15, 87, 129, 28, 89, 93, 100, 110, 20, 88, 130, 38, 91, 118, 106, 136, 26, 89, 131, 48, 92,
139, 111, 156, 31, 90, 131, 58, 94, 156, 117, 171, 37, 90, 132, 66, 95, 171, 122, 184, 42, 91, 132, 75, 97, 183, 127, 194, 47, 92, 133, 83, 98, 193, 132,
202, 52, 93, 133, 90, 100, 201, 137, 208, 57, 94, 134, 98, 101, 208, 142, 214, 62, 94, 135, 105, 103, 214, 146, 218, 66, 95, 135, 111, 104, 219, 151, 222,
71, 96, 136, 117, 106, 224, 155, 225, 76, 97, 136, 123, 107, 227, 159, 228, 80, 98, 137, 129, 109, 231, 162, 231, 84, 98, 138, 134, 110, 234, 166, 233, 89,
99, 138, 140, 112, 236, 170, 235, 93, 100, 139, 145, 113, 238, 173, 236, 97, 101, 140, 149, 115, 240, 176, 238, 101, 102, 140, 154, 116, 242, 179, 239,
105, 103, 141, 158, 118, 243, 182, 240, 109, 104, 141, 162, 119, 244, 185, 241, 113, 104, 142, 166, 120, 245, 187, 242, 116, 105, 143, 170, 122, 246, 190,
243, 120, 106, 143, 173, 123, 247, 192, 244, 123, 107, 144, 177, 125, 248, 195, 244, 127, 108, 145, 180, 126, 249, 197, 245, 130, 109, 145, 183, 128, 249,
199, 245, 134, 110, 146, 186, 129, 250, 201, 246, 137, 111, 147, 189, 131, 251, 203, 246, 140, 112, 147, 192, 132, 251, 205, 247, 143, 113, 148, 194, 133,
251, 207, 247, 146, 114, 149, 197, 135, 252, 208, 248, 149, 115, 149, 199, 136, 252, 210, 248, 152, 115, 150, 201, 138, 252, 211, 248, 155, 116, 151, 204,
139, 253, 213, 249, 158, 117, 151, 206, 140, 253, 214, 249, 161, 118, 152, 208, 142, 253, 216, 249, 163, 119, 153, 210, 143, 253, 217, 249, 166, 120, 153,
212, 144, 254, 218, 250, 168, 121, 154, 213, 146, 254, 220, 250, 171, 122, 155, 215, 147, 254, 221, 250, 173, 123, 155, 217, 148, 254, 222, 250, 176, 124,
156, 218, 150, 254, 223, 250, 178, 125, 157, 220, 151, 254, 224, 251, 180, 126, 157, 221, 152, 254, 225, 251, 183, 127, 158, 222, 153, 254, 226, 251, 185,
128, 159, 224, 155, 255, 227, 251, 187, 129, 160, 225, 156, 255, 228, 251, 189, 131, 160, 226, 157, 255, 228, 251, 191, 132, 161, 227, 159, 255, 229, 251,
193, 133, 162, 228, 160, 255, 230, 252, 195, 134, 163, 230, 161, 255, 231, 252, 197, 135, 163, 231, 162, 255, 231, 252, 199, 136, 164, 232, 163, 255, 232,
252, 201, 137, 165, 233, 165, 255, 233, 252, 202, 138, 166, 233, 166, 255, 233, 252, 204, 139, 166, 234, 167, 255, 234, 252, 206, 140, 167, 235, 168, 255,
235, 252, 207, 141, 168, 236, 169, 255, 235, 252, 209, 142, 169, 237, 171, 255, 236, 252, 210, 144, 169, 237, 172, 255, 236, 252, 212, 145, 170, 238, 173,
255, 237, 252, 214, 146, 171, 239, 174, 255, 237, 253, 215, 147, 172, 240, 175, 255, 238, 253, 216, 148, 173, 240, 176, 255, 238, 253, 218, 149, 173, 241,
177, 255, 239, 253, 219, 150, 174, 241, 179, 255, 239, 253, 220, 152, 175, 242, 180, 255, 240, 253, 222, 153, 176, 242, 181, 255, 240, 253, 223, 154, 177,
243, 182, 255, 240, 253, 224, 155, 178, 244, 183, 255, 241, 253, 225, 156, 178, 244, 184, 255, 241, 253, 226, 158, 179, 244, 185, 255, 242, 253, 228, 159,
180, 245, 186, 255, 242, 253, 229, 160, 181, 245, 187, 255, 242, 253, 230, 161, 182, 246, 188, 255, 243, 253, 231, 163, 183, 246, 189, 255, 243, 253, 232,
164, 184, 247, 190, 255, 243, 253, 233, 165, 185, 247, 191, 255, 244, 253, 234, 166, 185, 247, 192, 255, 244, 253, 235, 168, 186, 248, 193, 255, 244, 253,
236, 169, 187, 248, 194, 255, 244, 253, 236, 170, 188, 248, 195, 255, 245, 253, 237, 171, 189, 249, 196, 255, 245, 254, 238, 173, 190, 249, 197, 255, 245,
254, 239, 174, 191, 249, 198, 255, 245, 254, 240, 175, 192, 249, 199, 255, 246, 254, 240, 177, 193, 250, 200, 255, 246, 254, 241, 178, 194, 250, 201, 255,
246, 254, 242, 179, 195, 250, 202, 255, 246, 254, 242, 181, 196, 250, 203, 255, 247, 254, 243, 182, 197, 251, 204, 255, 247, 254, 244, 184, 198, 251, 205,
255, 247, 254, 244, 185, 199, 251, 206, 255, 247, 254, 245, 186, 200, 251, 207, 255, 247, 254, 246, 188, 201, 252, 207, 255, 248, 254, 246, 189, 202, 252,
208, 255, 248, 254, 247, 191, 203, 252, 209, 255, 248, 254, 247, 192, 204, 252, 210, 255, 248, 254, 248, 194, 205, 252, 211, 255, 248, 254, 248, 195, 206,
252, 212, 255, 249, 254, 249, 197, 207, 253, 213, 255, 249, 254, 249, 198, 208, 253, 214, 255, 249, 254, 250, 200, 210, 253, 215, 255, 249, 254, 250, 201,
211, 253, 215, 255, 249, 254, 250, 203, 212, 253, 216, 255, 249, 254, 251, 204, 213, 253, 217, 255, 250, 254, 251, 206, 214, 254, 218, 255, 250, 254, 252,
207, 216, 254, 219, 255, 250, 254, 252, 209, 217, 254, 220, 255, 250, 254, 252, 211, 218, 254, 221, 255, 250, 254, 253, 213, 219, 254, 222, 255, 250, 254,
253, 214, 221, 254, 223, 255, 250, 254, 253, 216, 222, 254, 224, 255, 251, 254, 253, 218, 224, 254, 225, 255, 251, 254, 254, 220, 225, 254, 225, 255, 251,
254, 254, 222, 227, 255, 226, 255, 251, 254, 254, 224, 228, 255, 227, 255, 251, 254, 254, 226, 230, 255, 228, 255, 251, 254, 255, 228, 231, 255, 230, 255,
251, 254, 255, 230, 233, 255, 231, 255, 252, 254, 255, 232, 235, 255, 232, 255, 252, 254, 255, 235, 237, 255, 233, 255, 252, 254, 255, 238, 240, 255, 235,
255, 252, 255, 255, 241, 243, 255, 236, 255, 252, 254, 255, 246, 247, 255, 239, 255, 253, 255,
];
pub const KF_PARTITION_PROBS: [u8; 48] = [
158, 97, 94, 93, 24, 99, 85, 119, 44, 62, 59, 67, 149, 53, 53, 94, 20, 48, 83, 53, 24, 52, 18, 18, 150, 40, 39, 78, 12, 26, 67, 33, 11, 24, 7, 5, 174, 35,
49, 68, 11, 27, 57, 15, 9, 12, 3, 3,
];
pub const KF_Y_MODE_PROBS: [u8; 900] = [
137, 30, 42, 148, 151, 207, 70, 52, 91, 92, 45, 102, 136, 116, 180, 74, 90, 100, 73, 32, 19, 187, 222, 215, 46, 34, 100, 91, 30, 32, 116, 121, 186, 93, 86,
94, 72, 35, 36, 149, 68, 206, 68, 63, 105, 73, 31, 28, 138, 57, 124, 55, 122, 151, 67, 23, 21, 140, 126, 197, 40, 37, 171, 86, 27, 28, 128, 154, 212, 45,
43, 53, 74, 32, 27, 107, 86, 160, 63, 134, 102, 59, 67, 44, 140, 161, 202, 78, 67, 119, 63, 36, 126, 146, 123, 158, 60, 90, 96, 43, 46, 168, 134, 107, 128,
69, 142, 92, 44, 29, 68, 159, 201, 177, 50, 57, 77, 58, 38, 76, 114, 97, 172, 78, 133, 92, 46, 41, 76, 140, 63, 184, 69, 112, 57, 38, 32, 85, 140, 46, 112,
54, 151, 133, 39, 27, 61, 131, 110, 175, 44, 75, 136, 52, 30, 74, 113, 130, 175, 51, 64, 58, 47, 35, 80, 100, 74, 143, 64, 163, 74, 36, 61, 116, 114, 128,
162, 80, 125, 82, 82, 26, 26, 171, 208, 204, 44, 32, 105, 55, 44, 68, 166, 179, 192, 57, 57, 108, 42, 26, 11, 199, 241, 228, 23, 15, 85, 68, 42, 19, 131,
160, 199, 55, 52, 83, 58, 50, 25, 139, 115, 232, 39, 52, 118, 50, 35, 33, 153, 104, 162, 64, 59, 131, 44, 24, 16, 150, 177, 202, 33, 19, 156, 55, 27, 12,
153, 203, 218, 26, 27, 49, 53, 49, 21, 110, 116, 168, 59, 80, 76, 38, 72, 19, 168, 203, 212, 50, 50, 107, 103, 26, 36, 129, 132, 201, 83, 80, 93, 59, 38,
83, 112, 103, 162, 98, 136, 90, 62, 30, 23, 158, 200, 207, 59, 57, 50, 67, 30, 29, 84, 86, 191, 102, 91, 59, 60, 32, 33, 112, 71, 220, 64, 89, 104, 53, 26,
34, 130, 56, 149, 84, 120, 103, 53, 21, 23, 133, 109, 210, 56, 77, 172, 77, 19, 29, 112, 142, 228, 55, 66, 36, 61, 29, 29, 93, 97, 165, 83, 175, 162, 47,
47, 43, 114, 137, 181, 100, 99, 95, 69, 23, 29, 128, 83, 199, 46, 44, 101, 53, 40, 55, 139, 69, 183, 61, 80, 110, 40, 29, 19, 161, 180, 207, 43, 24, 91,
60, 34, 19, 105, 61, 198, 53, 64, 89, 52, 31, 22, 158, 40, 209, 58, 62, 89, 44, 31, 29, 147, 46, 158, 56, 102, 198, 35, 19, 12, 135, 87, 209, 41, 45, 167,
55, 25, 21, 118, 95, 215, 38, 39, 66, 51, 38, 25, 113, 58, 164, 70, 93, 97, 47, 54, 34, 146, 108, 203, 72, 103, 151, 64, 19, 37, 156, 66, 138, 49, 95, 133,
46, 27, 80, 150, 55, 124, 55, 121, 135, 36, 23, 27, 165, 149, 166, 54, 64, 118, 53, 21, 36, 131, 63, 163, 60, 109, 81, 40, 26, 35, 154, 40, 185, 51, 97,
123, 35, 19, 34, 179, 19, 97, 48, 129, 124, 36, 20, 26, 136, 62, 164, 33, 77, 154, 45, 18, 32, 130, 90, 157, 40, 79, 91, 45, 26, 28, 129, 45, 129, 49, 147,
123, 38, 44, 51, 136, 74, 162, 57, 97, 121, 75, 17, 22, 136, 138, 185, 32, 34, 166, 56, 39, 58, 133, 117, 173, 48, 53, 187, 35, 21, 12, 161, 212, 207, 20,
23, 145, 56, 29, 19, 117, 109, 181, 55, 68, 112, 47, 29, 17, 153, 64, 220, 59, 51, 114, 46, 16, 24, 136, 76, 147, 41, 64, 172, 34, 17, 11, 108, 152, 187,
13, 15, 209, 51, 24, 14, 115, 133, 209, 32, 26, 104, 55, 30, 18, 122, 79, 179, 44, 88, 116, 37, 49, 25, 129, 168, 164, 41, 54, 148, 82, 22, 32, 127, 143,
213, 39, 41, 70, 62, 44, 61, 123, 105, 189, 48, 57, 64, 47, 25, 17, 175, 222, 220, 24, 30, 86, 68, 36, 17, 106, 102, 206, 59, 74, 74, 57, 39, 23, 151, 68,
216, 55, 63, 58, 49, 30, 35, 141, 70, 168, 82, 40, 115, 51, 25, 15, 136, 129, 202, 38, 35, 139, 68, 26, 16, 111, 141, 215, 29, 28, 28, 59, 39, 19, 114, 75,
180, 77, 104, 42, 40, 61, 26, 126, 152, 206, 61, 59, 93, 78, 23, 39, 111, 117, 170, 74, 124, 94, 48, 34, 86, 101, 92, 146, 78, 179, 134, 47, 22, 24, 138,
187, 178, 68, 69, 59, 56, 25, 33, 105, 112, 187, 95, 177, 129, 48, 31, 27, 114, 63, 183, 82, 116, 56, 43, 28, 37, 121, 63, 123, 61, 192, 169, 42, 17, 24,
109, 97, 177, 56, 76, 122, 58, 18, 28, 105, 139, 182, 70, 92, 63, 46, 23, 32, 74, 86, 150, 67, 183, 88, 36, 38, 48, 92, 122, 165, 88, 137, 91, 65, 70, 60,
155, 159, 199, 61, 60, 81, 44, 78, 115, 132, 119, 173, 71, 112, 93, 39, 38, 21, 184, 227, 206, 42, 32, 64, 58, 47, 36, 124, 137, 193, 80, 82, 78, 49, 50,
35, 144, 95, 205, 63, 78, 59, 41, 53, 52, 148, 71, 142, 65, 128, 51, 40, 36, 28, 143, 143, 202, 40, 55, 137, 52, 34, 29, 129, 183, 227, 42, 35, 43, 42, 44,
44, 104, 105, 164, 64, 130, 80, 43, 81, 53, 140, 169, 204, 68, 84, 72,
];
pub const KF_UV_MODE_PROBS: [u8; 90] = [
144, 11, 54, 157, 195, 130, 46, 58, 108, 118, 15, 123, 148, 131, 101, 44, 93, 131, 113, 12, 23, 188, 226, 142, 26, 32, 125, 120, 11, 50, 123, 163, 135, 64,
77, 103, 113, 9, 36, 155, 111, 157, 32, 44, 161, 116, 9, 55, 176, 76, 96, 37, 61, 149, 115, 9, 28, 141, 161, 167, 21, 25, 193, 120, 12, 32, 145, 195, 142,
32, 38, 86, 116, 12, 64, 120, 140, 125, 49, 115, 121, 102, 19, 66, 162, 182, 122, 35, 59, 128,
];
pub const DEFAULT_SKIP_PROB: [u8; 3] = [192, 128, 64];
pub const DEFAULT_COEF_PROBS: [u8; 432] = [
195, 29, 183, 84, 49, 136, 8, 42, 71, 0, 0, 0, 0, 0, 0, 0, 0, 0, 31, 107, 169, 35, 99, 159, 17, 82, 140, 8, 66, 114, 2, 44, 76, 1, 19, 32, 40, 132, 201,
29, 114, 187, 13, 91, 157, 7, 75, 127, 3, 58, 95, 1, 28, 47, 69, 142, 221, 42, 122, 201, 15, 91, 159, 6, 67, 121, 1, 42, 77, 1, 17, 31, 102, 148, 228, 67,
117, 204, 17, 82, 154, 6, 59, 114, 2, 39, 75, 1, 15, 29, 156, 57, 233, 119, 57, 212, 58, 48, 163, 29, 40, 124, 12, 30, 81, 3, 12, 31, 191, 107, 226, 124,
117, 204, 25, 99, 155, 0, 0, 0, 0, 0, 0, 0, 0, 0, 29, 148, 210, 37, 126, 194, 8, 93, 157, 2, 68, 118, 1, 39, 69, 1, 17, 33, 41, 151, 213, 27, 123, 193, 3,
82, 144, 1, 58, 105, 1, 32, 60, 1, 13, 26, 59, 159, 220, 23, 126, 198, 4, 88, 151, 1, 66, 114, 1, 38, 71, 1, 18, 34, 114, 136, 232, 51, 114, 207, 11, 83,
155, 3, 56, 105, 1, 33, 65, 1, 17, 34, 149, 65, 234, 121, 57, 215, 61, 49, 166, 28, 36, 114, 12, 25, 76, 3, 16, 42, 214, 49, 220, 132, 63, 188, 42, 65,
137, 0, 0, 0, 0, 0, 0, 0, 0, 0, 85, 137, 221, 104, 131, 216, 49, 111, 192, 21, 87, 155, 2, 49, 87, 1, 16, 28, 89, 163, 230, 90, 137, 220, 29, 100, 183, 10,
70, 135, 2, 42, 81, 1, 17, 33, 108, 167, 237, 55, 133, 222, 15, 97, 179, 4, 72, 135, 1, 45, 85, 1, 19, 38, 124, 146, 240, 66, 124, 224, 17, 88, 175, 4, 58,
122, 1, 36, 75, 1, 18, 37, 141, 79, 241, 126, 70, 227, 66, 58, 182, 30, 44, 136, 12, 34, 96, 2, 20, 47, 229, 99, 249, 143, 111, 235, 46, 109, 192, 0, 0, 0,
0, 0, 0, 0, 0, 0, 82, 158, 236, 94, 146, 224, 25, 117, 191, 9, 87, 149, 3, 56, 99, 1, 33, 57, 83, 167, 237, 68, 145, 222, 10, 103, 177, 2, 72, 131, 1, 41,
79, 1, 20, 39, 99, 167, 239, 47, 141, 224, 10, 104, 178, 2, 73, 133, 1, 44, 85, 1, 22, 47, 127, 145, 243, 71, 129, 228, 17, 93, 177, 3, 61, 124, 1, 41, 84,
1, 21, 52, 157, 78, 244, 140, 72, 231, 69, 58, 184, 31, 44, 137, 14, 38, 105, 8, 23, 61,
];

View File

@ -16,18 +16,21 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
| L0 | `time` | `Tick` (254 016 000 000 per second), rational frame rates incl. NTSC, SMPTE and drop-frame timecode |
| L0 | `geom` | Vectors, matrices, quaternions, the layer transform (anchor, position, scale, orientation, rotation) |
| L0 | `color` | sRGB and linear, HSL/HSV, luminance, the 38 blend modes, label colors |
| L0 | `vp9enc` | VP9 intra-frame encoder (profile 0, 8-bit 4:2:0; lossless at quality 100) for WebM export, from the VP9 bitstream specification |
| L0 | `opusenc` | Opus (CELT) encoder: RFC 6716 CELT-only fullband 48 kHz 20 ms packets, mono/stereo, and the RFC 7845 `OpusHead`, for WebM export audio |
| L1 | `raster` | Premultiplied float images, sampling, affine and projective warps, blurs, compositing (parallel with rayon) |
| L1 | `keyframe` | Animated values, keyframes with temporal ease and spatial Bezier, roving, hold, velocity |
| L1 | `psd` | Photoshop PSD/PSB reader (layers, groups, masks, blend modes, text, layer effects, adjustment layers; 8/16/32-bit; RGB/CMYK/Gray/Lab) and a minimal writer, from Adobe's published format specification |
| L1 | `path` | Bezier paths, path operators (trim, offset, round corners, zig zag, twist, merge…), stroking, coverage masks |
| L2 | `project` | The document: items, compositions, layers, the property tree, render queue model, `.ecproj` serde |
| L2 | `text` | Fonts, shaping, layout, per-glyph geometry, text animators and selectors |
| L2 | `effects` | The effect registry (241 effects) and their CPU implementations |
| L2 | `svg` | SVG import (W3C SVG 1.1/2 static subset: shapes, paths, transforms, `use`, CSS, gradients) and rasterisation at any scale |
| L2 | `track` | Motion tracking: feature/search region point tracking (pyramid normalized cross-correlation, Lucas–Kanade sub-pixel refinement), confidence, homography/affine/similarity solves |
| L3 | `render` | Evaluation and compositing: sources, masks, effects, transforms, 3D, motion blur, mattes, blending, layer cache, audio mixdown |
| L3 | `media` | Footage decoding (FilmCraft's pure-Rust codecs), image sequences, frame cache |
| L3 | `media` | Footage decoding (FilmCraft's pure-Rust codecs), image sequences, Photoshop and SVG stills, frame cache |
| L3 | `expr` | The expression engine (JavaScript via boa) with the After Effects object model |
| L3 | `export` | Render queue encoding: H.264, ProRes, PNG/JPEG/TIFF/EXR sequences, GIF, audio |
| L3 | `export` | Render queue encoding: H.264, ProRes, WebM (VP9 + alpha, Opus), PNG/JPEG/TIFF/EXR sequences, GIF, WAV/AIFF |
| L3 | `gpu` | The GPU compositor and GPU effects on wgpu compute shaders (Metal, Vulkan, Direct3D 12, WebGPU), checked against the CPU renderer |
| L3 | `lottie` | Lottie JSON / dotLottie import and export (layers, precomps, eased and spatial keyframes, shapes, masks, mattes) with a warnings list for what Lottie cannot express |
| L4 | `engine` | `Session`: project, undo history, editor state, the command registry and menus |
@ -76,6 +79,14 @@ its evaluated inputs, excluding the transform. Static and transform-only layers
editing one layer re-renders only that layer. Effects that read the clock directly are declared in
`effects::TIME_DEPENDENT`, and a test checks every registered effect against that list.
A persistent **disk cache** (`render::disk_cache`, Settings ▸ Media & Disk Cache) backs both
the layer cache and the viewer's RAM preview: layer buffers that were slow to render and every
preview frame are written (LZ4, atomic rename, checksummed) under 128-bit content keys — the
comp's content hash (the comp, what it uses, footage file size and time) plus frame, scale and
view for frames; the layer key salted with the footage fingerprint for layers — so entries
survive restarts and never go stale. LRU eviction keeps it under the size limit; Edit ▸ Purge
empties it, `cache.diskStats` reports it, and the timeline draws disk-only frames in blue.
**Time effects** (Echo, Posterize Time, Timewarp…) read the layer at other times through
`EffectHost::self_at`: the renderer renders the layer's source and masks (plus, optionally, the
effects before it) at that layer time via `Renderer::layer_input`, cached under separate *input*

View File

@ -75,7 +75,7 @@ library groundwork for mask tracking and Warp Stabilizer.
What is left is concentrated in large systems: Roto Brush and Refine Edge, the 3D camera tracker,
Advanced 3D (models, PBR, image-based lighting), the Warp Stabilizer and mask-tracking UI on top of
the new tracking library, a JavaScript scripting object model, SVG/PSD import, WebM, a disk cache,
the new tracking library, a JavaScript scripting object model,
the missing effect categories (3D Channel, Immersive Video, OCIO), native macOS menus, and
wasm threads.
@ -84,9 +84,9 @@ wasm threads.
| Area | Weighted parity | Remaining (agent-hours) | Biggest gaps |
|---|---|---|---|
| Layers | 88% | 4.8 | frame blending, collapse transformations, slip edit |
| Output | 85% | 4.2 | multiple output modules, pre-render, WebM, audio-only |
| Output | 90% | 3.0 | multiple output modules, pre-render (WebM with VP9 alpha + Opus and WAV/AIFF audio-only landed: VP9 is intra-only, Opus CELT-only) |
| Audio | 85% | 0.5 | audio to keyframes |
| Import | 78% | 4.5 | PSD, SVG as shapes, Lottie |
| Import | 88% | 2.0 | AI/EPS/PDF vector footage, PSD smart objects and 3D layers (PSD as footage/composition/retain layer sizes, SVG footage and Create Shapes from Vector Layer landed) |
| Automation | 76% | 3.3 | a JavaScript scripting object model |
| Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) |
| Compositions | 67% | 7.4 | marker dialog, flowchart, Essential Graphics |
@ -98,7 +98,7 @@ wasm threads.
| Interface | 55% | 10.4 | drag-to-dock and floating panels, preferences, native macOS menus (viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Project | 53% | 8.1 | 8/16/32-bit pipeline and colour management, auto-save, proxies, folder moves |
| Masks & roto | 52% | 10.0 | Roto Brush, variable-width mask feather points (mask tracking and Mask Interpolation landed in M6.6) |
| Preview | 55% | 6.1 | GPU 3D and adjustment layers, disk cache (region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
| Preview | 62% | 5.0 | GPU 3D and adjustment layers (persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
| Tracking | ≈ 70% | 4.0 | 3D camera tracker, face tracking, Subspace Warp's mesh warp and rolling-shutter repair (point tracker, mask tracking and Warp Stabilizer landed in M6.x / M12.5) |
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |
@ -125,7 +125,7 @@ Mocha and Cineware are third-party and not counted.
10. Drag-to-dock and floating panels, saved workspaces, native macOS menus.
11. Auto-save, crash recovery, recent projects (in progress).
12. Expression gaps: `sampleImage`, `footage()` (the `sourceText` style API landed in M9.9).
13. Lottie (in progress), WebM, SVG and PSD import.
13. ~~Lottie, WebM, SVG and PSD import~~ (landed; a disk cache too).
14. Puppet and paint tools (in progress).
15. Preferences and a shortcut editor that can rebind (in progress); real Wiggler, Smoother and Motion Sketch; the marker dialog.

View File

@ -23,6 +23,7 @@ const LAYERS: &[(&str, u8)] = &[
("time", 0),
("geom", 0),
("color", 0),
("vp9enc", 0),
("testkit", 0),
("opusenc", 0),
("raster", 1),