From b26fbb6247977158fb7022c303e0bba25333667d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:44:03 -0400 Subject: [PATCH 1/2] M10.2: VP9 inter frames (motion search, MV prediction, loop filter, forward probability updates, rate control) effectcraft-vp9enc gains inter frames: single LAST reference, 16x16/8x8 blocks, candidate + diamond search refined to quarter samples with the regular 8-tap filters, NEARESTMV/NEARMV/ZEROMV/NEWMV with spec MV prediction, skip blocks, the in-loop deblocking filter, per-frame forward probability updates, a key frame interval and target-bitrate rate control. Decoded output matches the encoder's reconstruction exactly through FilmCraft's decoder and ffmpeg. WebM export now encodes sequentially with a key frame every two seconds. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/lib.rs | 2 +- crates/export/src/webm.rs | 72 ++- crates/export/tests/vp9_roundtrip.rs | 181 +++++- crates/vp9enc/Cargo.toml | 2 +- crates/vp9enc/README.md | 52 +- crates/vp9enc/src/bool.rs | 25 - crates/vp9enc/src/lf.rs | 124 ++++ crates/vp9enc/src/lib.rs | 934 ++++++++------------------- crates/vp9enc/src/mc.rs | 207 ++++++ crates/vp9enc/src/probs.rs | 426 ++++++++++++ crates/vp9enc/src/tables.rs | 73 +++ crates/vp9enc/src/tile.rs | 894 +++++++++++++++++++++++++ crates/vp9enc/src/transform.rs | 249 +++++++ docs/parity.md | 2 +- 14 files changed, 2511 insertions(+), 732 deletions(-) create mode 100644 crates/vp9enc/src/lf.rs create mode 100644 crates/vp9enc/src/mc.rs create mode 100644 crates/vp9enc/src/probs.rs create mode 100644 crates/vp9enc/src/tile.rs create mode 100644 crates/vp9enc/src/transform.rs diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 941a7723..c9196625 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -7,7 +7,7 @@ //! | 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 | +//! | WebM | WebM (Matroska) | VP9 profile 0 (`effectcraft-vp9enc`: key + inter frames, a key frame every 2 s, loop filter), 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 diff --git a/crates/export/src/webm.rs b/crates/export/src/webm.rs index eb99cd22..65ca1189 100644 --- a/crates/export/src/webm.rs +++ b/crates/export/src/webm.rs @@ -5,8 +5,10 @@ //! (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, +//! with `AlphaMode` 1 on the track, as WebM defines. VP9 comes from `effectcraft-vp9enc` (key +//! frames every two seconds, inter frames between them: SimpleBlocks carry the key flag, inter +//! BlockGroups a ReferenceBlock; the alpha stream's key frames line up with the colour stream's), +//! 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. @@ -82,6 +84,10 @@ fn block(track: u64, rel: i16, flags: u8, data: &[u8]) -> Vec { struct Frame { ms: i64, track: u64, + /// A key frame (VP9) or any audio packet. + key: bool, + /// Timestamp of the previous video frame (ReferenceBlock of inter frames). + prev_ms: Option, data: Vec, alpha: Option>, } @@ -94,10 +100,15 @@ fn cluster(frames: &mut [Frame]) -> Vec { 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)), + None => el(&mut body, 0xA3, &block(f.track, rel, if f.key { 0x80 } else { 0x00 }, &f.data)), Some(a) => { let mut g = vec![]; el(&mut g, 0xA1, &block(f.track, rel, 0x00, &f.data)); + if let (false, Some(p)) = (f.key, f.prev_ms) { + // ReferenceBlock: a signed timestamp relative to this block (an inter frame). + let d = (p - f.ms).clamp(i16::MIN as i64, -1) as i16; + el(&mut g, 0xFB, &d.to_be_bytes()); + } let mut more = vec![]; el_uint(&mut more, 0xEE, 1); el(&mut more, 0xA5, a); @@ -117,7 +128,19 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re 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 fps = rate.as_f64().max(1.0); + let cfg = effectcraft_vp9enc::EncoderConfig { + width: w, + height: h, + quality, + full_range: false, + keyframe_interval: (fps * 2.0).round().max(1.0) as u32, + target_kbps: None, + frame_rate: fps, + loop_filter: true, + }; + let mut color_enc = effectcraft_vp9enc::Vp9Encoder::new(cfg.clone()); + let mut alpha_enc = alpha.then(|| effectcraft_vp9enc::Vp9Encoder::new(cfg.clone())); let with_audio = wants_audio(job); let mut opus = with_audio.then(|| effectcraft_opusenc::OpusEncoder::new(2, OPUS_BITRATE)); let total = st.total; @@ -209,7 +232,7 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re 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 }); + frames.push(Frame { ms: packets as i64 * 20, track: 2, key: true, prev_ms: None, data: pkt, alpha: None }); packets += 1; } }; @@ -218,23 +241,28 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re let mut i = 0; while i < total { let end = (i + batch).min(total); - let encoded: Vec<(Vec, Option>)> = (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 = - encoded.into_iter().enumerate().map(|(j, (data, alpha))| Frame { ms: frame_ms(i + j as u64), track: 1, data, alpha }).collect(); + // Frames render in parallel; the VP9 streams (inter-coded) encode in order, colour and + // alpha side by side. + let pixels: Vec> = + (i..end).into_par_iter().map(|k| rgba8(&render_frame(job, comp, k), comp, if alpha { Channels::Rgba } else { Channels::Rgb }, w, h)).collect(); + let mut frames: Vec = Vec::with_capacity(pixels.len()); + for (j, px) in pixels.iter().enumerate() { + let (color, a) = rayon::join( + || { + let (y, u, vv) = effectcraft_vp9enc::rgba_to_yuv420(px, w, h, false); + color_enc.encode_frame(&y, &u, &vv, false) + }, + || { + alpha_enc.as_mut().map(|ea| { + let (y, u, vv) = effectcraft_vp9enc::alpha_to_yuv420(px, w, h); + ea.encode_frame(&y, &u, &vv, false).data + }) + }, + ); + let k = i + j as u64; + frames.push(Frame { ms: frame_ms(k), track: 1, key: color.key, prev_ms: (k > 0).then(|| frame_ms(k - 1)), data: color.data, alpha: a }); + } + drop(pixels); 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); diff --git a/crates/export/tests/vp9_roundtrip.rs b/crates/export/tests/vp9_roundtrip.rs index 8156d850..5bf46e0a 100644 --- a/crates/export/tests/vp9_roundtrip.rs +++ b/crates/export/tests/vp9_roundtrip.rs @@ -61,7 +61,7 @@ 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 mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 100, full_range: false, ..Default::default() }); let f = e.encode_yuv420(&y, &u, &v); let p = decode(&f); assert_eq!((p.width, p.height), (w as u32, h as u32)); @@ -80,7 +80,7 @@ fn lossy_quality_and_size() { 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 mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: q, full_range: false, ..Default::default() }); let f = e.encode_yuv420(&y, &u, &v); let p = decode(&f); let got = crop(&plane_u8(&p.y), p.y_stride, w, h); @@ -103,7 +103,7 @@ 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 mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 90, full_range: false, ..Default::default() }); 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); @@ -113,7 +113,7 @@ fn wide_frames_use_tile_columns() { 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 mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 100, full_range: false, ..Default::default() }); let frame = e.encode_yuv420(&y, &u, &v); // IVF container (one frame). let mut ivf = Vec::new(); @@ -147,3 +147,176 @@ fn ffmpeg_decodes_our_stream() { assert_eq!(&out.stdout[..w * h], &y[..], "ffmpeg luma matches (lossless)"); assert_eq!(&out.stdout[w * h..w * h + u.len()], &u[..]); } + +// ---------------------------------------------------------------- inter frames + +/// Frame `t` of a moving sequence: a diagonal gradient with a sinusoidal texture and bright +/// squares, all translating by a fractional amount per frame (`kind` 1 adds noise that changes +/// every frame). +fn moving(w: usize, h: usize, t: usize, kind: u32) -> (Vec, Vec, Vec) { + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + let (dx, dy) = (1.75 * t as f64, 0.6 * t as f64); + let mut s = 1234u32 + t as u32 * 77; + let mut rnd = || { + s = s.wrapping_mul(1664525).wrapping_add(1013904223); + (s >> 28) as f64 + }; + let lum = |x: f64, y: f64| -> f64 { + let g = 40.0 + 120.0 * (x / w as f64) + 50.0 * (y / h as f64); + let tex = 18.0 * ((x * 0.31).sin() * (y * 0.23).cos()); + let sq = if (x - 20.0).rem_euclid(64.0) < 18.0 && (y - 10.0).rem_euclid(48.0) < 14.0 { 45.0 } else { 0.0 }; + g + tex + sq + }; + let mut y = vec![0u8; w * h]; + for yy in 0..h { + for x in 0..w { + let n = if kind == 1 { rnd() - 7.5 } else { 0.0 }; + y[yy * w + x] = (lum(x as f64 - dx, yy as f64 - dy) + n).round().clamp(0.0, 255.0) as u8; + } + } + let u: Vec = (0..cw * ch).map(|i| (100.0 + 40.0 * (((i % cw) as f64 * 2.0 - dx) / w as f64)).round() as u8).collect(); + let v: Vec = (0..cw * ch).map(|i| (150.0 - 30.0 * (((i / cw) as f64 * 2.0 - dy) / h as f64)).round() as u8).collect(); + (y, u, v) +} + +fn planes_of(p: &filmcraft_vp9::Picture, w: usize, h: usize) -> [Vec; 3] { + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + [crop(&plane_u8(&p.y), p.y_stride, w, h), crop(&plane_u8(&p.u), p.uv_stride, cw, ch), crop(&plane_u8(&p.v), p.uv_stride, cw, ch)] +} + +/// Encode `n` frames and decode them with one decoder: every decoded frame must equal the +/// encoder's reconstruction. Returns (total bytes, mean luma PSNR vs the source). +fn run_sequence(cfg: EncoderConfig, n: usize, kind: u32) -> (usize, f64) { + let (w, h) = (cfg.width as usize, cfg.height as usize); + let mut e = Vp9Encoder::new(cfg); + let mut d = Decoder::new(); + let (mut bytes, mut db) = (0usize, 0.0); + for t in 0..n { + let (y, u, v) = moving(w, h, t, kind); + let f = e.encode_frame(&y, &u, &v, false); + bytes += f.data.len(); + let kind_s = if f.key { "key" } else { "inter" }; + let mut pics = d.decode(&f.data, t as i64).unwrap_or_else(|err| panic!("{w}x{h} frame {t} ({kind_s}): {err:?}")); + assert_eq!(pics.len(), 1); + let got = planes_of(&pics.remove(0), w, h); + let rec = e.reconstruction().expect("reconstruction"); + for k in 0..3 { + if got[k] != rec[k] { + let first = got[k].iter().zip(&rec[k]).position(|(a, b)| a != b).unwrap_or(0); + let pw = if k == 0 { w } else { w.div_ceil(2) }; + panic!("{w}x{h} frame {t} ({kind_s}, q{} lf{}): plane {k} differs first at ({}, {})", f.q_idx, f.filter_level, first % pw, first / pw); + } + } + db += psnr(&got[0], &y); + } + (bytes, db / n as f64) +} + +#[test] +fn inter_frames_decode_exactly() { + for (w, h) in [(64, 64), (96, 72), (33, 17), (130, 66)] { + for (q, kind) in [(30u8, 0u32), (70, 1), (92, 0), (100, 0)] { + let cfg = EncoderConfig { width: w as u32, height: h as u32, quality: q, keyframe_interval: 6, ..Default::default() }; + run_sequence(cfg, 8, kind); + } + } +} + +#[test] +fn inter_frames_with_tile_columns_decode_exactly() { + let cfg = EncoderConfig { width: 4160, height: 24, quality: 80, keyframe_interval: 0, ..Default::default() }; + run_sequence(cfg, 3, 0); +} + +#[test] +fn inter_psnr_rises_with_bitrate() { + let (w, h) = (160, 96); + let mut last = (0usize, 0.0f64); + let mut rows = vec![]; + for kbps in [60u32, 150, 400, 1200] { + let cfg = EncoderConfig { width: w, height: h, quality: 60, target_kbps: Some(kbps), frame_rate: 30.0, keyframe_interval: 30, ..Default::default() }; + let (bytes, db) = run_sequence(cfg, 20, 0); + rows.push(format!("{kbps} kbps target: {:.0} kbps actual, {db:.2} dB", bytes as f64 * 8.0 * 30.0 / 20.0 / 1000.0)); + assert!(bytes > last.0 && db > last.1, "{rows:#?}"); + last = (bytes, db); + } + eprintln!("{rows:#?}"); +} + +#[test] +fn inter_frames_are_much_smaller_than_intra() { + let (w, h) = (192u32, 108u32); + let mut report = vec![]; + for q in [50u8, 75, 90] { + let intra = EncoderConfig { width: w, height: h, quality: q, keyframe_interval: 1, ..Default::default() }; + let inter = EncoderConfig { keyframe_interval: 0, ..intra.clone() }; + let (bi, pi) = run_sequence(intra, 24, 0); + let (bp, pp) = run_sequence(inter, 24, 0); + report.push(format!("q{q}: intra {bi} B {pi:.2} dB, inter {bp} B {pp:.2} dB, ratio {:.2}", bi as f64 / bp as f64)); + assert!(pp > pi - 1.0, "{report:#?}"); + assert!(bi as f64 >= 3.0 * bp as f64, "{report:#?}"); + } + eprintln!("{report:#?}"); +} + +#[test] +fn ffmpeg_decodes_inter_frames_like_the_encoder() { + let (w, h) = (96usize, 64usize); + let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 75, keyframe_interval: 5, ..Default::default() }); + let n = 7; + 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(&(n as u32).to_le_bytes()); + ivf.extend_from_slice(&0u32.to_le_bytes()); + let mut recon = vec![]; + for t in 0..n { + let (y, u, v) = moving(w, h, t, 0); + let f = e.encode_frame(&y, &u, &v, false); + ivf.extend_from_slice(&(f.data.len() as u32).to_le_bytes()); + ivf.extend_from_slice(&(t as u64).to_le_bytes()); + ivf.extend_from_slice(&f.data); + recon.push(e.reconstruction().expect("recon")); + } + let dir = std::env::temp_dir().join(format!("effectcraft-vp9-inter-{}", 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)); + let fsz = w * h + 2 * w.div_ceil(2) * h.div_ceil(2); + assert_eq!(out.stdout.len(), fsz * n); + for (t, r) in recon.iter().enumerate() { + let frame = &out.stdout[t * fsz..(t + 1) * fsz]; + assert_eq!(&frame[..w * h], &r[0][..], "ffmpeg luma of frame {t}"); + assert_eq!(&frame[w * h..w * h + r[1].len()], &r[1][..], "ffmpeg U of frame {t}"); + } +} + +/// Encoder speed on 720p (run with `--ignored --nocapture`). +#[test] +#[ignore] +fn bench_720p() { + let (w, h) = (1280usize, 720usize); + let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 80, keyframe_interval: 0, ..Default::default() }); + for t in 0..6 { + let (y, u, v) = moving(w, h, t, 0); + let t0 = std::time::Instant::now(); + let f = e.encode_frame(&y, &u, &v, false); + eprintln!("frame {t} ({}): {} B in {:.0} ms", if f.key { "key" } else { "inter" }, f.data.len(), t0.elapsed().as_secs_f64() * 1000.0); + } +} diff --git a/crates/vp9enc/Cargo.toml b/crates/vp9enc/Cargo.toml index 62144b25..fe2e767c 100644 --- a/crates/vp9enc/Cargo.toml +++ b/crates/vp9enc/Cargo.toml @@ -1,6 +1,6 @@ [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" +description = "VP9 encoder (profile 0, 8-bit 4:2:0; intra + inter frames, loop filter, rate control) for WebM export, from the VP9 Bitstream & Decoding Process Specification" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/vp9enc/README.md b/crates/vp9enc/README.md index c8f781b8..b9f94fae 100644 --- a/crates/vp9enc/README.md +++ b/crates/vp9enc/README.md @@ -1,15 +1,47 @@ # 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). +A small VP9 encoder for WebM export (profile 0, 8-bit 4:2:0, BT.709). + +* **Key frames**: 8×8 intra blocks (DC / V / H / TM prediction chosen per block), 4×4 DCT/ADST + transforms, or the Walsh–Hadamard transform at quality 100 (mathematically lossless). +* **Inter frames**: one reference (the previous frame, LAST), 16×16 or 8×8 blocks chosen by a + cost comparison; motion search from the predicted vectors (NearestMv / NearMv / last frame's + co-located vector) with a whole-sample cross/diamond search refined to half and quarter + samples with the regular 8-tap interpolation filters; NEARESTMV / NEARMV / ZEROMV / NEWMV + chosen by bit cost; blocks with no coefficients worth coding are skipped; 4×4 DCT residual + with a rate–distortion check per transform block. Inter frames are coded error resilient + (`error_resilient_mode` = 1: no dependence on earlier probability contexts or motion vectors), + so `UsePrevFrameMvs` is never needed. No high-precision (1/8) vectors, no compound prediction, + no intra blocks inside inter frames. +* **Loop filter**: the in-loop deblocking filter (§8.8) on every lossy frame; the level is the + best of three trials against the source (sharpness 0, no mode/reference deltas). +* **Probabilities**: per-frame forward updates in the compressed header (`diff_update_prob` / + `update_mv_prob`) of the coefficient, skip, inter-mode, is-inter, reference, partition and + motion-vector probabilities. Tiles are recorded as symbols first, the frame's own counts pick + the updates that pay for their signalling, then the symbols are entropy coded with the updated + probabilities. There is no backward adaptation (`refresh_frame_context` = 0). +* **Rate control**: constant quality (quality → `base_q_idx`) or a target bitrate: a per-frame + q index from a bits ≈ complexity / step model per frame type, key frames getting a larger share + of each key-frame interval, with the running over/undershoot paid back over about a second. +* **Key frame interval** is configurable (1 = every frame a key frame, the old intra-only mode). + +The encoder reconstructs exactly what a decoder reconstructs (inter prediction with the +decoder's vector clamping and edge-clamped reference fetches, integer inverse transforms, the +loop filter) and exposes it (`Vp9Encoder::reconstruction`); the tests require FilmCraft's VP9 +decoder (and ffmpeg, when installed) to output exactly that reconstruction, frame by frame, for +moving synthetic sequences at several sizes and qualities, with tile columns, and lossless. + +On a moving-gradient sequence (192×108, 24 frames) inter coding is 3.9–7× smaller than all-intra +at the same quality setting with equal or better PSNR. 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. +info, motion vector and token syntax (§6.4), derives motion vector candidates with the §6.4 +`find_mv_refs` / `find_best_ref_mvs` processes, and reconstructs with the specification's intra +and inter prediction (§8.5), integer inverse transforms (§8.7) and loop filter (§8.8) 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) are the specification's; the key-frame tables +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 and whose behaviour (loop filter edge rules, +vector candidate order) the encoder was checked against; ffmpeg is used only as an external test +oracle. No libvpx or other third-party encoder/decoder source was consulted. diff --git a/crates/vp9enc/src/bool.rs b/crates/vp9enc/src/bool.rs index 45db92dd..0cd91b0e 100644 --- a/crates/vp9enc/src/bool.rs +++ b/crates/vp9enc/src/bool.rs @@ -64,31 +64,6 @@ impl BoolEncoder { } } - /// 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 { let mut c = self.bit_count; diff --git a/crates/vp9enc/src/lf.rs b/crates/vp9enc/src/lf.rs new file mode 100644 index 00000000..d781144f --- /dev/null +++ b/crates/vp9enc/src/lf.rs @@ -0,0 +1,124 @@ +//! The in-loop deblocking filter (spec 8.8), as the decoder applies it to a frame coded with 4×4 +//! transforms, 8×8 / 16×16 blocks, no segmentation and no reference/mode deltas: superblocks in +//! raster order, for each plane the vertical edges (pass 0) then the horizontal edges (pass 1). +//! Transform edges inside a skipped inter block are left alone; every 4×4 edge uses the 4-tap +//! filter except edges on a 32-sample boundary, which get the 8-tap (flat) filter. + +use crate::{BLOCK_16X16, Mi, Plane}; + +#[derive(Clone, Copy)] +struct Limits { + limit: i32, + blimit: i32, + thresh: i32, +} + +/// Filter the reconstructed planes (Y, U, V, or a prefix of them) in place at `level` (0 = off; +/// sharpness 0). +pub fn filter_frame(planes: &mut [Plane], mi: &[Mi], mi_rows: usize, mi_cols: usize, level: u8) { + if level == 0 { + return; + } + let l = level as i32; + let limit = l.max(1); + let lim = Limits { limit, blimit: 2 * (l + 2) + limit, thresh: l >> 4 }; + for sbr in 0..mi_rows.div_ceil(8) { + for sbc in 0..mi_cols.div_ceil(8) { + for (plane, p) in planes.iter_mut().enumerate() { + for pass in 0..2 { + sb_plane(p, mi, mi_rows, mi_cols, plane, pass, sbr * 8, sbc * 8, lim); + } + } + } + } +} + +#[allow(clippy::too_many_arguments)] +fn sb_plane(p: &mut Plane, mi: &[Mi], mi_rows: usize, mi_cols: usize, plane: usize, pass: usize, row: usize, col: usize, lim: Limits) { + let (sx, sy) = if plane > 0 { (1, 1) } else { (0, 0) }; + let (sub, edge_len) = if pass == 0 { (sx, 64 >> sy) } else { (sy, 64 >> sx) }; + let stride = p.w; + for edge in 0..(16usize >> sub) { + let mut i = 0; + while i < edge_len { + let (x, y) = if pass == 0 { (col * 8 + edge * (4 << sx), row * 8 + (i << sy)) } else { (col * 8 + (i << sx), row * 8 + edge * (4 << sy)) }; + if x >= 8 * mi_cols || y >= 8 * mi_rows || (pass == 0 && x == 0) || (pass == 1 && y == 0) { + i += 8; + continue; + } + let lc = ((x >> 3) >> sx) << sx; + let lr = ((y >> 3) >> sy) << sy; + let m = mi[lr * mi_cols + lc]; + let size = if sub == 0 { m.size } else { m.size.max(BLOCK_16X16) }; + let n8 = if size >= BLOCK_16X16 { 2 } else { 1 }; + let is_block_edge = if pass == 0 { x & (8 * n8 - 1) == 0 } else { y & (8 * n8 - 1) == 0 }; + let tx_ok = !m.inter || !m.skip; + if !is_block_edge && !tx_ok { + i += 8; + continue; + } + let wide = edge % 8 == 0; + let n = if pass == 0 { (8 * mi_rows - y).div_ceil(1 << sy) } else { (8 * mi_cols - x).div_ceil(1 << sx) }.min(8).min(edge_len - i); + // A transform edge is not filtered in the last odd chroma column. + let odd_last = pass == 1 && sx == 1 && mi_cols & 1 == 1 && edge & 1 == 1; + let (px, py) = (x >> sx, y >> sy); + for l in 0..n { + let is_tx_edge = tx_ok && !(odd_last && x + (l << sx) + 8 >= mi_cols * 8); + if !(is_block_edge || is_tx_edge) { + continue; + } + let (pos, step) = if pass == 0 { ((py + l) * stride + px, 1) } else { (py * stride + px + l, stride) }; + filter_lane(&mut p.rec, pos, step, wide, lim); + } + i += 8; + } + } +} + +/// Filter mask, narrow (4-tap) and 8-tap flat filters of spec 8.8.5 across one edge position; +/// `pos` is q0, samples across the edge are `step` apart. +fn filter_lane(d: &mut [u8], pos: usize, step: usize, wide: bool, lim: Limits) { + let at = |k: isize| d[(pos as isize + k * step as isize) as usize] as i32; + let (p3, p2, p1, p0) = (at(-4), at(-3), at(-2), at(-1)); + let (q0, q1, q2, q3) = (at(0), at(1), at(2), at(3)); + let Limits { limit, blimit, thresh } = lim; + let mask = (p3 - p2).abs() <= limit + && (p2 - p1).abs() <= limit + && (p1 - p0).abs() <= limit + && (q1 - q0).abs() <= limit + && (q2 - q1).abs() <= limit + && (q3 - q2).abs() <= limit + && (p0 - q0).abs() * 2 + (p1 - q1).abs() / 2 <= blimit; + if !mask { + return; + } + let flat = + wide && (p1 - p0).abs() <= 1 && (q1 - q0).abs() <= 1 && (p2 - p0).abs() <= 1 && (q2 - q0).abs() <= 1 && (p3 - p0).abs() <= 1 && (q3 - q0).abs() <= 1; + let mut set = |k: isize, v: i32| d[(pos as isize + k * step as isize) as usize] = v as u8; + if flat { + // 8-tap: positions p2..q2, each the rounded mean of a 7-wide clamped window plus itself. + let s = [p3, p2, p1, p0, q0, q1, q2, q3]; + let px = |k: isize| s[(k.clamp(-4, 3) + 4) as usize]; + for k in -3isize..3 { + let mut sum = px(k); + for j in -3..=3 { + sum += px(k + j); + } + set(k, (sum + 4) >> 3); + } + return; + } + let c = |v: i32| v.clamp(-128, 127); + let hev = (p1 - p0).abs() > thresh || (q1 - q0).abs() > thresh; + let (ps1, ps0, qs0, qs1) = (p1 - 128, p0 - 128, q0 - 128, q1 - 128); + let filter = c(if hev { c(ps1 - qs1) } else { 0 } + 3 * (qs0 - ps0)); + let f1 = c(filter + 4) >> 3; + let f2 = c(filter + 3) >> 3; + set(0, c(qs0 - f1) + 128); + set(-1, c(ps0 + f2) + 128); + if !hev { + let f = (f1 + 1) >> 1; + set(1, c(qs1 - f) + 128); + set(-2, c(ps1 + f) + 128); + } +} diff --git a/crates/vp9enc/src/lib.rs b/crates/vp9enc/src/lib.rs index 02cbb0e2..8f72cb7b 100644 --- a/crates/vp9enc/src/lib.rs +++ b/crates/vp9enc/src/lib.rs @@ -1,290 +1,89 @@ -//! 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. +//! A VP9 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). +//! +//! * **Key frames**: 64×64 superblocks split to 8×8 intra blocks (DC / V / H / TM chosen per +//! block), 4×4 DCT/ADST transforms (the Walsh–Hadamard transform at quality 100: lossless). +//! * **Inter frames** (error resilient, single reference: the previous frame as LAST): 16×16 or +//! 8×8 blocks with motion vectors found by a candidate + diamond search refined to quarter +//! samples with the regular 8-tap filters; NEARESTMV / NEARMV / ZEROMV / NEWMV chosen by cost, +//! motion vector prediction exactly as the decoder derives it; skipped blocks; 4×4 DCT residual. +//! * **In-loop deblocking** (8.8) on every lossy frame; the level is picked per frame by trial. +//! * **Probabilities**: per-frame forward updates (compressed header) of the coefficient, skip, +//! inter mode, reference, partition and motion vector probabilities, chosen from the frame's +//! own symbol counts (no backward adaptation: `refresh_frame_context` = 0). +//! * **Rate control**: constant quality (quality → base_q_idx), or a target bitrate with a +//! per-frame q_idx model. +//! +//! The encoder reconstructs exactly what a decoder reconstructs (prediction, integer inverse +//! transforms, loop filter), so prediction never drifts; [`Vp9Encoder::reconstruction`] returns +//! it. mod bool; +mod lf; +mod mc; +mod probs; mod tables; +mod tile; +mod transform; -use bool::BoolEncoder; -use tables::*; +pub use mc::Mv; +use probs::Probs; +use tables::{AC_QLOOKUP, DC_QLOOKUP}; +use transform::Quant; -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], -]; +pub(crate) const BLOCK_8X8: u8 = 3; +pub(crate) const BLOCK_16X16: u8 = 6; /// Encoder settings. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub struct EncoderConfig { pub width: u32, pub height: u32, - /// 1..=100; 100 is lossless. + /// 1..=100; 100 is lossless. With a target bitrate: the quality of the first frame. pub quality: u8, /// Full-range (0–255) rather than limited (16–235) YUV; only signalled. pub full_range: bool, + /// A key frame every this many frames (1 = every frame is a key frame; 0 = only the first). + pub keyframe_interval: u32, + /// Target bitrate in kbit/s (`None`: constant quality). + pub target_kbps: Option, + /// Frames per second (rate control). + pub frame_rate: f64, + /// The in-loop deblocking filter. + pub loop_filter: 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, +impl Default for EncoderConfig { + fn default() -> Self { + EncoderConfig { width: 0, height: 0, quality: 80, full_range: false, keyframe_interval: 60, target_kbps: None, frame_rate: 30.0, loop_filter: true } } } -/// 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 +/// One coded frame. +#[derive(Clone, Debug)] +pub struct EncodedFrame { + pub data: Vec, + pub key: bool, + pub q_idx: u8, + pub filter_level: u8, } -// ---------------------------------------------------------------- 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, - rec: Vec, +/// A plane being coded: source and reconstruction at superblock-aligned size. +pub(crate) struct Plane { + /// Stride (superblock-aligned width). + pub w: usize, + /// Last valid column / row for intra prediction edges (the 8-aligned mode-info grid). + pub max_x: usize, + pub max_y: usize, + /// Visible size. + pub vis_w: usize, + pub vis_h: usize, + pub src: Vec, + pub rec: Vec, } impl Plane { + #[allow(clippy::too_many_arguments)] 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 { @@ -294,103 +93,141 @@ impl Plane { 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] } + Plane { w, max_x, max_y, vis_w: vw, vis_h: vh, 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::() + above[..size].iter().sum::() + size as i32) >> (log2 + 1), - (true, false) => (left[..size].iter().sum::() + (1 << (log2 - 1))) >> log2, - (false, true) => (above[..size].iter().sum::() + (1 << (log2 - 1))) >> log2, - (false, false) => base, - }; - out[..size * size].fill(v); - } - out +/// Mode info of one 8×8 position. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct Mi { + pub inter: bool, + /// Intra mode (0..=9) or inter mode (NEARESTMV..=NEWMV). + pub mode: u8, + pub mv: Mv, + /// Skip as the loop filter sees it (inter blocks without coefficients). + pub skip: bool, + pub size: u8, } -// ---------------------------------------------------------------- the encoder +/// Rate control state (target bitrate mode). +#[derive(Clone, Debug)] +struct RateControl { + /// bits ≈ complexity / step: per frame type, from the last frame of that type. + key_complexity: Option, + inter_complexity: Option, + /// Bits spent minus bits budgeted so far. + debt: f64, + last_inter_q: Option, +} pub struct Vp9Encoder { cfg: EncoderConfig, - qidx: u8, + base_qidx: u8, lossless: bool, + frames: u64, + since_key: u32, + /// The reconstruction of the last frame (the LAST reference): planes at their strides. + reference: Option<[Plane; 3]>, + mvs: Vec, + rc: RateControl, } -/// 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; 3], - left_nz: [Vec; 3], - above_part: Vec, - left_part: Vec, - /// y mode of every 8×8 block (for mode contexts). - modes: &'a mut [u8], +fn quality_to_qidx(q: u8) -> u8 { + // 99 ≈ 3, 90 ≈ 30, 50 ≈ 150, 1 ≈ 255. + ((100 - q.clamp(1, 99) as i32) * 255 / 85).clamp(1, 255) as 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 } + let lossless = q >= 100 && cfg.target_kbps.is_none(); + let base_qidx = if lossless { 0 } else { quality_to_qidx(q) }; + Vp9Encoder { + cfg, + base_qidx, + lossless, + frames: 0, + since_key: 0, + reference: None, + mvs: vec![], + rc: RateControl { key_complexity: None, inter_complexity: None, debt: 0.0, last_inter_q: None }, + } } pub fn config(&self) -> &EncoderConfig { &self.cfg } - /// Encode one key frame from planar 8-bit 4:2:0 (`y`: width×height, `u`/`v`: + /// Encode one 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 { + self.encode_frame(y, u, v, false).data + } + + /// The decoder's reconstruction of the last coded frame (visible area, tightly packed Y, U, + /// V). + pub fn reconstruction(&self) -> Option<[Vec; 3]> { + let r = self.reference.as_ref()?; + Some(std::array::from_fn(|i| { + let p = &r[i]; + (0..p.vis_h).flat_map(|y| p.rec[y * p.w..y * p.w + p.vis_w].iter().copied()).collect() + })) + } + + fn is_key_due(&self, force: bool) -> bool { + force || self.reference.is_none() || self.cfg.keyframe_interval == 1 || (self.cfg.keyframe_interval > 1 && self.since_key >= self.cfg.keyframe_interval) + } + + /// The q index of the next frame. + fn pick_qidx(&mut self, key: bool) -> u8 { + if self.lossless { + return 0; + } + let Some(kbps) = self.cfg.target_kbps else { return self.base_qidx }; + let per_frame = kbps as f64 * 1000.0 / self.cfg.frame_rate.max(1.0); + let interval = if self.cfg.keyframe_interval == 0 { 300.0 } else { self.cfg.keyframe_interval as f64 }; + // Key frames get a larger share of the group's budget. + let boost = (interval / 6.0).clamp(1.0, 5.0); + let group = per_frame * interval; + let key_share = group * boost / (boost + interval - 1.0); + let inter_share = (group - key_share) / (interval - 1.0).max(1.0); + let mut target = if key { key_share } else { inter_share }; + // Pay back (or spend) the running difference over about a second. + let horizon = self.cfg.frame_rate.max(1.0); + target = (target - self.rc.debt / horizon).max(target * 0.25); + let complexity = if key { self.rc.key_complexity } else { self.rc.inter_complexity.or(self.rc.key_complexity.map(|c| c / (boost * 2.0))) }; + let Some(cx) = complexity else { return self.base_qidx }; + let step = (cx / target).max(4.0); + let mut q = (0..=255u8).min_by_key(|&q| ((AC_QLOOKUP[q as usize] as f64 - step).abs() * 16.0) as i64).unwrap_or(self.base_qidx); + if !key && let Some(last) = self.rc.last_inter_q { + q = q.clamp(last.saturating_sub(24), last.saturating_add(24)); + } + q.max(1) + } + + fn rc_update(&mut self, key: bool, qidx: u8, bytes: usize) { + let Some(kbps) = self.cfg.target_kbps else { return }; + let bits = bytes as f64 * 8.0; + let cx = bits * AC_QLOOKUP[qidx as usize] as f64; + if key { + self.rc.key_complexity = Some(cx); + } else { + self.rc.inter_complexity = Some(match self.rc.inter_complexity { + Some(c) => c * 0.5 + cx * 0.5, + None => cx, + }); + self.rc.last_inter_q = Some(qidx); + } + let per_frame = kbps as f64 * 1000.0 / self.cfg.frame_rate.max(1.0); + self.rc.debt += bits - per_frame; + } + + /// Encode one frame; `force_key` starts a new key frame. + pub fn encode_frame(&mut self, y: &[u8], u: &[u8], v: &[u8], force_key: bool) -> EncodedFrame { let (w, h) = (self.cfg.width.max(1) as usize, self.cfg.height.max(1) as usize); + let key = self.is_key_due(force_key); + let qidx = self.pick_qidx(key); + let lossless = qidx == 0; let mi_cols = w.div_ceil(8); let mi_rows = h.div_ceil(8); let sb_cols = mi_cols.div_ceil(8); @@ -413,71 +250,77 @@ impl Vp9Encoder { } max_log2 -= 1; let tile_cols = 1usize << min_log2; - let mut modes = vec![0u8; mi_cols * mi_rows]; - let mut tiles = vec![]; + let quant = Quant { dcq: DC_QLOOKUP[qidx as usize], acq: AC_QLOOKUP[qidx as usize], lossless, round_dc: 0.5, round_ac: 0.38 }; + let step = AC_QLOOKUP[qidx as usize] as f64 / 8.0; + let lambda_sse = 0.35 * step * step; + let refs = if key { + None + } else { + self.reference.as_ref().map(|r| std::array::from_fn::<_, 3, _>(|i| mc::RefPlane { data: &r[i].rec, stride: r[i].w, w: r[i].vis_w, h: r[i].vis_h })) + }; + let prev = (!key && self.mvs.len() == mi_cols * mi_rows).then_some(&self.mvs[..]); + let phases = refs.as_ref().map(|r| mc::PhasePlanes::new(&r[0], 48)); + let fp = tile::FrameParams { key, quant, mi_cols, mi_rows, refs, phases, prev_mvs: prev, lambda_sad: lambda_sse.sqrt(), lambda_sse }; + let mut mi = vec![Mi::default(); mi_cols * mi_rows]; + let mut writers = 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()); + let mut tile = tile::Tile::new(&fp, &mut planes, &mut mi, start, end); + tile.code(); + writers.push(tile.w); } - // 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 + // Forward probability updates from this frame's counts. + let defaults = Probs::defaults(); + let mut counts = vec![[0u32; 2]; probs::NPROBS]; + for wr in &writers { + wr.count(&mut counts); } - 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(); + let wanted = probs::plan_updates(&defaults, &counts, !key); + let (compressed, used) = probs::compressed_header(&defaults, &wanted, lossless, !key); + let tiles: Vec> = writers.iter().map(|wr| wr.emit(&used)).collect(); + drop(fp); + // Loop filter: the level with the least error against the source. + let level = if lossless || !self.cfg.loop_filter { 0 } else { choose_level(&mut planes, &mi, mi_rows, mi_cols, qidx, key) }; + lf::filter_frame(&mut planes, &mi, mi_rows, mi_cols, level); // 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(!key as u32, 1); // frame_type 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 + if key { + 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 + } else { + bw.put(1, 1); // error_resilient_mode (no reliance on earlier contexts or vectors) + bw.put(0x01, 8); // refresh_frame_flags: slot 0 becomes the new LAST + for _ in 0..3 { + bw.put(0, 3); // ref_frame_idx: slot 0 + bw.put(0, 1); // sign bias + } + bw.put(1, 1); // found_ref: the size of LAST + bw.put(0, 1); // render_and_frame_size_different + bw.put(0, 1); // allow_high_precision_mv + bw.put(0, 1); // is_filter_switchable + bw.put(1, 2); // raw_interpolation_filter → EIGHTTAP (regular) + } bw.put(0, 2); // frame_context_idx - bw.put(0, 6); // loop_filter_level + bw.put(level as u32, 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(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 @@ -496,10 +339,46 @@ impl Vp9Encoder { } out.extend_from_slice(&t); } - out + // State for the next frame. + self.mvs = mi.iter().map(|m| if m.inter { m.mv } else { Mv::ZERO }).collect(); + self.reference = Some(planes); + self.frames += 1; + self.since_key = if key { 1 } else { self.since_key + 1 }; + self.rc_update(key, qidx, out.len()); + EncodedFrame { data: out, key, q_idx: qidx, filter_level: level } } } +/// Try a few loop filter levels on the luma reconstruction; the one closest to the source +/// (squared error over the visible area) wins (0 when filtering doesn't help). +fn choose_level(planes: &mut [Plane; 3], mi: &[Mi], mi_rows: usize, mi_cols: usize, qidx: u8, key: bool) -> u8 { + let guess = ((qidx as f64 * if key { 0.25 } else { 0.2 }).round() as u8).clamp(1, 63); + let sse = |p: &Plane| -> u64 { + let mut s = 0u64; + for y in 0..p.vis_h { + for x in 0..p.vis_w { + let d = p.src[y * p.w + x] as i64 - p.rec[y * p.w + x] as i64; + s += (d * d) as u64; + } + } + s + }; + let mut best = (sse(&planes[0]), 0u8); + let saved = planes[0].rec.clone(); + for level in [guess / 2, guess, (guess as u16 * 3 / 2).min(63) as u8] { + if level == 0 { + continue; + } + lf::filter_frame(&mut planes[..1], mi, mi_rows, mi_cols, level); + let s = sse(&planes[0]); + planes[0].rec.copy_from_slice(&saved); + if s < best.0 { + best = (s, level); + } + } + best.1 +} + #[derive(Default)] struct BitWriter { out: Vec, @@ -527,254 +406,6 @@ impl BitWriter { } } -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). @@ -822,36 +453,3 @@ pub fn alpha_to_yuv420(rgba: &[u8], w: u32, h: u32) -> (Vec, Vec, Vec = 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::().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:?}"); - } - } - } -} diff --git a/crates/vp9enc/src/mc.rs b/crates/vp9enc/src/mc.rs new file mode 100644 index 00000000..779a1ea5 --- /dev/null +++ b/crates/vp9enc/src/mc.rs @@ -0,0 +1,207 @@ +//! Inter prediction exactly as the decoder forms it: motion vector clamping (spec 8.5.2.2) and +//! the unscaled block inter prediction process (8.5.2.4) with the regular 8-tap filters and +//! reference samples clamped to the reference frame's visible area. + +use crate::tables::SUBPEL_REGULAR; + +/// A motion vector in 1/8 luma samples. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Mv { + pub row: i32, + pub col: i32, +} + +impl Mv { + pub const ZERO: Mv = Mv { row: 0, col: 0 }; + pub fn new(row: i32, col: i32) -> Mv { + Mv { row, col } + } + pub fn minus(self, o: Mv) -> Mv { + Mv::new(self.row - o.row, self.col - o.col) + } +} + +/// A reference plane: `w`×`h` visible samples at `stride`. +pub struct RefPlane<'a> { + pub data: &'a [u8], + pub stride: usize, + pub w: usize, + pub h: usize, +} + +/// The block's motion vector for `plane` in 1/16 samples of that plane, clamped as the +/// decoder clamps it. `n8`: block size in 8×8 units. +pub fn clamp_for_plane(mv: Mv, r: usize, c: usize, n8: usize, mi_rows: usize, mi_cols: usize, chroma: bool) -> (i32, i32) { + let s = chroma as i32; + let (r, c, b) = (r as i32, c as i32, n8 as i32); + let to_top = (-(r * 8 * 16)) >> s; + let to_bottom = (((mi_rows as i32 - b - r) * 8) * 16) >> s; + let to_left = (-(c * 8 * 16)) >> s; + let to_right = (((mi_cols as i32 - b - c) * 8) * 16) >> s; + let spel = (4 + ((b * 8) >> s)) << 4; + let row = ((2 * mv.row) >> s).clamp(to_top - spel, (to_bottom + spel - 16).max(to_top - spel)); + let col = ((2 * mv.col) >> s).clamp(to_left - spel, (to_right + spel - 16).max(to_left - spel)); + (row, col) +} + +/// Predict a `w`×`h` block whose top-left sample is at (`x16`, `y16`) (1/16 samples) into `out` +/// (stride `w`). +pub fn predict(r: &RefPlane, x16: i32, y16: i32, w: usize, h: usize, out: &mut [u8]) { + let (x0, y0) = (x16 >> 4, y16 >> 4); + let (fx, fy) = ((x16 & 15) as usize, (y16 & 15) as usize); + let (lx, ly) = (r.w as i32 - 1, r.h as i32 - 1); + let inside = x0 - 3 >= 0 && y0 - 3 >= 0 && x0 + w as i32 + 4 <= lx && y0 + h as i32 + 4 <= ly; + let at = |x: i32, y: i32| -> i32 { + if inside { r.data[y as usize * r.stride + x as usize] as i32 } else { r.data[y.clamp(0, ly) as usize * r.stride + x.clamp(0, lx) as usize] as i32 } + }; + let fh = &SUBPEL_REGULAR[fx]; + let fv = &SUBPEL_REGULAR[fy]; + if fx == 0 && fy == 0 { + for i in 0..h { + for j in 0..w { + out[i * w + j] = at(x0 + j as i32, y0 + i as i32) as u8; + } + } + return; + } + // Horizontal pass over h + 7 rows (from y0 - 3), then vertical. + let mut tmp = [0u8; 23 * 16]; + for i in 0..h + 7 { + let y = y0 - 3 + i as i32; + for j in 0..w { + let x = x0 + j as i32 - 3; + let mut acc = 64; + for t in 0..8 { + acc += fh[t] * at(x + t as i32, y); + } + tmp[i * w + j] = (acc >> 7).clamp(0, 255) as u8; + } + } + for i in 0..h { + for j in 0..w { + let mut acc = 64; + for t in 0..8 { + acc += fv[t] * tmp[(i + t) * w + j] as i32; + } + out[i * w + j] = (acc >> 7).clamp(0, 255) as u8; + } + } +} + +/// The reference luma pre-interpolated at the 16 quarter-sample phases the encoder uses (motion +/// vectors are even in 1/8 units: fractions 0, 4, 8, 12 in 1/16), over the visible area plus a +/// border. Each phase plane holds exactly what [`predict`] returns at that phase (the decoder's +/// separable filter with edge clamping), so motion search reads predictions directly. +pub struct PhasePlanes { + border: usize, + stride: usize, + rows: usize, + planes: Vec>, +} + +impl PhasePlanes { + pub fn new(r: &RefPlane, border: usize) -> PhasePlanes { + const M: usize = 4; + let (pw, ph) = (r.w + 2 * border, r.h + 2 * border); + // Edge-replicated source with M more samples on every side for the filter taps. + let rs = pw + 2 * M; + let rh = ph + 2 * M; + let off = (border + M) as i32; + let mut raw = vec![0u8; rs * rh]; + for y in 0..rh { + let sy = (y as i32 - off).clamp(0, r.h as i32 - 1) as usize; + let src = &r.data[sy * r.stride..sy * r.stride + r.w]; + for x in 0..rs { + raw[y * rs + x] = src[(x as i32 - off).clamp(0, r.w as i32 - 1) as usize]; + } + } + // Horizontal phases over every raw row (inner columns). + let hpass = |fx: usize| -> Vec { + let f = &SUBPEL_REGULAR[fx]; + let mut out = vec![0u8; pw * rh]; + for y in 0..rh { + let row = &raw[y * rs..y * rs + rs]; + for x in 0..pw { + let s = &row[x + M - 3..x + M + 5]; + let acc: i32 = 64 + (0..8).map(|t| f[t] * s[t] as i32).sum::(); + out[y * pw + x] = (acc >> 7).clamp(0, 255) as u8; + } + } + out + }; + let unfiltered: Vec = (0..rh).flat_map(|y| raw[y * rs + M..y * rs + M + pw].iter().copied()).collect(); + let h: Vec> = (0..4).map(|k| if k == 0 { unfiltered.clone() } else { hpass(k * 4) }).collect(); + let mut planes = Vec::with_capacity(16); + for fy in 0..4 { + for hp in &h { + if fy == 0 { + planes.push(hp[M * pw..(M + ph) * pw].to_vec()); + continue; + } + let f = &SUBPEL_REGULAR[fy * 4]; + let mut out = vec![0u8; pw * ph]; + for y in 0..ph { + for x in 0..pw { + let mut acc = 64; + for t in 0..8 { + acc += f[t] * hp[(y + M + t - 3) * pw + x] as i32; + } + out[y * pw + x] = (acc >> 7).clamp(0, 255) as u8; + } + } + planes.push(out); + } + } + PhasePlanes { border, stride: pw, rows: ph, planes } + } + + /// The prediction of the `n`×`n` block at (`x16`, `y16`) (fractions multiples of 4) as rows of + /// a phase plane: (plane, offset of the first sample, stride). `None` beyond the border. + pub fn block(&self, x16: i32, y16: i32, n: usize) -> Option<(&[u8], usize, usize)> { + let (x0, y0) = ((x16 >> 4) + self.border as i32, (y16 >> 4) + self.border as i32); + let (fx, fy) = ((x16 & 15) as usize, (y16 & 15) as usize); + if fx % 4 != 0 || fy % 4 != 0 || x0 < 0 || y0 < 0 || x0 as usize + n > self.stride || y0 as usize + n > self.rows { + return None; + } + Some((&self.planes[(fy / 4) * 4 + fx / 4], y0 as usize * self.stride + x0 as usize, self.stride)) + } +} + +#[cfg(test)] +mod tests { + #[test] + fn phase_planes_match_predict() { + let (w, h) = (37usize, 29usize); + let data: Vec = (0..w * h).map(|i| ((i * 7919) % 251) as u8).collect(); + let r = RefPlane { data: &data, stride: w, w, h }; + let pp = PhasePlanes::new(&r, 24); + let mut out = [0u8; 64]; + for y16 in (-20 * 16..(h as i32 + 10) * 16).step_by(4 * 7) { + for x16 in (-20 * 16..(w as i32 + 10) * 16).step_by(4 * 5) { + let Some((p, o, s)) = pp.block(x16, y16, 8) else { continue }; + predict(&r, x16, y16, 8, 8, &mut out); + for i in 0..8 { + assert_eq!(&p[o + i * s..o + i * s + 8], &out[i * 8..i * 8 + 8], "({x16}, {y16}) row {i}"); + } + } + } + } + + use super::*; + + #[test] + fn whole_sample_and_linear_ramps() { + let (w, h) = (40usize, 30usize); + let data: Vec = (0..w * h).map(|i| ((i % w) * 4 + (i / w)) as u8).collect(); + let r = RefPlane { data: &data, stride: w, w, h }; + let mut out = [0u8; 64]; + predict(&r, 10 << 4, 8 << 4, 8, 8, &mut out); + assert_eq!(out[0], data[8 * w + 10]); + // Half a sample right on a linear ramp: exact midpoint. + predict(&r, (10 << 4) + 8, 8 << 4, 8, 8, &mut out); + assert_eq!(out[0], data[8 * w + 10] + 2); + // Off the left edge: clamped to column 0. + predict(&r, -(20 << 4), 0, 4, 4, &mut out); + assert_eq!(out[0], data[0]); + } +} diff --git a/crates/vp9enc/src/probs.rs b/crates/vp9enc/src/probs.rs new file mode 100644 index 00000000..8f55bbba --- /dev/null +++ b/crates/vp9enc/src/probs.rs @@ -0,0 +1,426 @@ +//! Probabilities: the frame's adaptable probability table (one flat array laid out in the order +//! the compressed header updates them), a symbol recorder that codes every boolean decision +//! against either a fixed probability or an entry of that table, bit-cost estimates, and the +//! per-frame forward updates (spec 6.3: `diff_update_prob` with `decode_term_subexp` / +//! `inv_remap_prob`, and `update_mv_prob`). +//! +//! Tiles are first recorded, then the encoder counts how often each table entry coded a 0 or a +//! 1, picks updates that pay for themselves, writes them in the compressed header and finally +//! entropy codes the recorded symbols with the updated probabilities. + +use crate::bool::BoolEncoder; +use crate::tables::*; + +/// Coefficient probabilities `[ptype][ref][band][ctx][3]` for 4×4 transforms (band 0 uses +/// contexts 0..3 only). +pub const COEF: usize = 0; +pub const SKIP: usize = 432; +pub const INTER_MODE: usize = SKIP + 3; +pub const IS_INTER: usize = INTER_MODE + 21; +pub const SINGLE_REF: usize = IS_INTER + 4; +pub const PARTITION: usize = SINGLE_REF + 10; +pub const MV_JOINT: usize = PARTITION + 48; +pub const MV_SIGN: usize = MV_JOINT + 3; +pub const MV_CLASS: usize = MV_SIGN + 2; +pub const MV_CLASS0_BIT: usize = MV_CLASS + 20; +pub const MV_BITS: usize = MV_CLASS0_BIT + 2; +pub const MV_CLASS0_FR: usize = MV_BITS + 20; +pub const MV_FR: usize = MV_CLASS0_FR + 12; +pub const NPROBS: usize = MV_FR + 6; + +/// Index of the first of the three coefficient probabilities of a context. +pub fn coef_index(ptype: usize, inter: bool, band: usize, ctx: usize) -> usize { + COEF + (((ptype * 2 + inter as usize) * 6 + band) * 6 + ctx) * 3 +} + +#[derive(Clone)] +pub struct Probs(pub [u8; NPROBS]); + +impl Probs { + /// The default probabilities (setup_past_independence / key frames). + pub fn defaults() -> Probs { + let mut p = [128u8; NPROBS]; + p[COEF..COEF + 432].copy_from_slice(&DEFAULT_COEF_PROBS); + p[SKIP..SKIP + 3].copy_from_slice(&DEFAULT_SKIP_PROB); + p[INTER_MODE..INTER_MODE + 21].copy_from_slice(&DEFAULT_INTER_MODE_PROBS); + p[IS_INTER..IS_INTER + 4].copy_from_slice(&DEFAULT_IS_INTER_PROB); + p[SINGLE_REF..SINGLE_REF + 10].copy_from_slice(&DEFAULT_SINGLE_REF_PROB); + p[PARTITION..PARTITION + 48].copy_from_slice(&DEFAULT_PARTITION_PROBS); + p[MV_JOINT..MV_JOINT + 3].copy_from_slice(&DEFAULT_MV_JOINT_PROBS); + p[MV_SIGN..MV_SIGN + 2].copy_from_slice(&DEFAULT_MV_SIGN_PROB); + p[MV_CLASS..MV_CLASS + 20].copy_from_slice(&DEFAULT_MV_CLASS_PROBS); + p[MV_CLASS0_BIT..MV_CLASS0_BIT + 2].copy_from_slice(&DEFAULT_MV_CLASS0_BIT_PROB); + p[MV_BITS..MV_BITS + 20].copy_from_slice(&DEFAULT_MV_BITS_PROB); + p[MV_CLASS0_FR..MV_CLASS0_FR + 12].copy_from_slice(&DEFAULT_MV_CLASS0_FR_PROBS); + p[MV_FR..MV_FR + 6].copy_from_slice(&DEFAULT_MV_FR_PROBS); + Probs(p) + } +} + +/// Probabilities of the full token tree (9.3.2) for model probability `p`. +pub 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 +} + +/// The probability a recorded symbol is coded with. +#[derive(Clone, Copy, Debug)] +pub enum P { + Fixed(u8), + /// An entry of the frame's [`Probs`]. + Ctx(u16), + /// Node `n` of the Pareto token tree derived from the coefficient probability at the index. + Pareto(u16, u8), +} + +#[derive(Clone, Copy)] +struct Sym { + bit: bool, + p: P, +} + +/// Records the boolean decisions of a tile. +#[derive(Default)] +pub struct Writer { + syms: Vec, +} + +impl Writer { + #[inline] + pub fn put(&mut self, bit: bool, p: P) { + self.syms.push(Sym { bit, p }); + } + #[inline] + pub fn fixed(&mut self, bit: bool, prob: u8) { + self.put(bit, P::Fixed(prob)); + } + #[inline] + pub fn ctx(&mut self, bit: bool, idx: usize) { + self.put(bit, P::Ctx(idx as u16)); + } + /// Write `value` with `tree` (leaves stored negated); node `i` uses `prob(i)`. + pub fn tree(&mut self, tree: &[i8], value: u8, prob: impl Fn(usize) -> P) { + for (node, bit) in tree_path(tree, value) { + self.put(bit, prob(node)); + } + } + + /// Add the 0/1 counts of every table entry this tile used. + pub fn count(&self, counts: &mut [[u32; 2]]) { + for s in &self.syms { + if let P::Ctx(i) = s.p { + counts[i as usize][s.bit as usize] += 1; + } + } + } + + /// Entropy code the recorded symbols. + pub fn emit(&self, probs: &Probs) -> Vec { + let mut bc = BoolEncoder::new(); + bc.write(false, 128); // marker bit + for s in &self.syms { + let p = match s.p { + P::Fixed(p) => p, + P::Ctx(i) => probs.0[i as usize], + P::Pareto(i, n) => pareto(probs.0[i as usize])[n as usize], + }; + bc.write(s.bit, p); + } + bc.finish() + } +} + +/// The (node, bit) path to `value` in a tree (node = tree index / 2). +pub fn tree_path(tree: &[i8], value: u8) -> impl Iterator { + struct Path { + steps: [(u8, bool); 16], + len: usize, + } + fn walk(tree: &[i8], node: usize, value: u8, out: &mut Path) -> bool { + for b in 0..2 { + let t = tree[node + b]; + out.steps[out.len] = ((node >> 1) as u8, b == 1); + out.len += 1; + if t <= 0 { + if (-t) as u8 == value { + return true; + } + } else if walk(tree, t as usize, value, out) { + return true; + } + out.len -= 1; + } + false + } + let mut p = Path { steps: [(0, false); 16], len: 0 }; + walk(tree, 0, value, &mut p); + let Path { steps, len } = p; + steps.into_iter().take(len).map(|(n, b)| (n as usize, b)) +} + +// ---------------------------------------------------------------- costs + +/// Cost in 1/256 bit of coding a 0 (`[p][0]`) or a 1 (`[p][1]`) with probability `p`. +pub fn cost_table() -> &'static [[u32; 2]; 256] { + use std::sync::OnceLock; + static T: OnceLock<[[u32; 2]; 256]> = OnceLock::new(); + T.get_or_init(|| { + let mut t = [[0u32; 2]; 256]; + for (p, e) in t.iter_mut().enumerate() { + let q = (p.max(1) as f64) / 256.0; + e[0] = (-q.log2() * 256.0).round() as u32; + e[1] = (-(1.0 - q).log2() * 256.0).round() as u32; + } + t + }) +} + +#[inline] +pub fn cost(bit: bool, p: u8) -> u32 { + cost_table()[p as usize][bit as usize] +} + +/// Cost of `value` in a tree with probabilities `probs` (node i → probs[i]). +pub fn tree_cost(tree: &[i8], probs: &[u8], value: u8) -> u32 { + tree_path(tree, value).map(|(n, b)| cost(b, probs[n])).sum() +} + +// ---------------------------------------------------------------- forward updates + +fn inv_recenter_nonneg(v: i32, m: i32) -> i32 { + if v > 2 * m { + v + } else if v & 1 != 0 { + m - ((v + 1) >> 1) + } else { + m + (v >> 1) + } +} + +/// `inv_remap_prob` (6.3.5). +fn inv_remap_prob(delta: usize, prob: u8) -> u8 { + let v = inv_map_table()[delta.min(254)] as i32; + let m = prob as i32 - 1; + let r = if (m << 1) <= 255 { 1 + inv_recenter_nonneg(v, m) } else { 255 - inv_recenter_nonneg(v, 255 - 1 - m) }; + r.clamp(1, 255) as u8 +} + +/// The `decode_term_subexp` value that moves `old` to `new`. +fn delta_for(old: u8, new: u8) -> Option { + (0..255).find(|&d| inv_remap_prob(d, old) == new) +} + +/// Bits of `decode_term_subexp` for `d`. +fn subexp_bits(d: usize) -> u32 { + match d { + 0..16 => 5, + 16..32 => 6, + 32..64 => 8, + 64..129 => 10, + _ => 11, + } +} + +fn write_subexp(bc: &mut BoolEncoder, d: usize) { + let d = d as u32; + if d < 16 { + bc.literal(0, 1); + bc.literal(d, 4); + } else if d < 32 { + bc.literal(2, 2); + bc.literal(d - 16, 4); + } else if d < 64 { + bc.literal(6, 3); + bc.literal(d - 32, 5); + } else { + bc.literal(7, 3); + if d < 129 { + bc.literal(d - 64, 7); + } else { + let v = (d + 1) >> 1; + bc.literal(v, 7); + bc.literal((d + 1) & 1, 1); + } + } +} + +fn counts_cost(c: [u32; 2], p: u8) -> u64 { + c[0] as u64 * cost(false, p) as u64 + c[1] as u64 * cost(true, p) as u64 +} + +/// Whether table entry `i` is an MV probability (7-bit `update_mv_prob` rather than +/// `diff_update_prob`). +fn is_mv(i: usize) -> bool { + i >= MV_JOINT +} + +/// Choose updated probabilities: an entry changes when the bits saved on this frame's symbols +/// exceed the cost of signalling the change. `inter`: the inter-frame tables are updatable. +pub fn plan_updates(old: &Probs, counts: &[[u32; 2]], inter: bool) -> Probs { + let mut new = old.clone(); + let flag = cost(true, 252) as i64 - cost(false, 252) as i64; + let limit = if inter { NPROBS } else { INTER_MODE }; + for i in 0..limit { + let c = counts[i]; + let n = c[0] + c[1]; + if n == 0 { + continue; + } + let o = old.0[i]; + let ideal = ((c[0] as f64 * 256.0 / n as f64).round() as i32).clamp(1, 255) as u8; + let cands: Vec = if is_mv(i) { vec![ideal | 1, ideal.saturating_sub(1).max(1) | 1] } else { vec![ideal] }; + let base = counts_cost(c, o) as i64; + let mut best = (0i64, o); + for p in cands { + if p == o { + continue; + } + let signal = if is_mv(i) { + 7 * 256 + } else { + match delta_for(o, p) { + Some(d) => subexp_bits(d) as i64 * 256, + None => continue, + } + }; + let gain = base - counts_cost(c, p) as i64 - signal - flag; + if gain > best.0 { + best = (gain, p); + } + } + new.0[i] = best.1; + } + new +} + +/// `diff_update_prob` for one entry. +fn diff_update(bc: &mut BoolEncoder, old: &Probs, new: &Probs, i: usize) { + let (o, n) = (old.0[i], new.0[i]); + match (o != n).then(|| delta_for(o, n)).flatten() { + Some(d) => { + bc.write(true, 252); + write_subexp(bc, d); + } + None => bc.write(false, 252), + } +} + +/// `update_mv_prob` for one entry. +fn mv_update(bc: &mut BoolEncoder, old: &Probs, new: &Probs, i: usize) { + let n = new.0[i]; + if n != old.0[i] && n & 1 == 1 { + bc.write(true, 252); + bc.literal(n as u32 >> 1, 7); + } else { + bc.write(false, 252); + } +} + +/// The compressed header (6.3) of a frame coded with ONLY_4X4 transforms, single LAST +/// references and a fixed interpolation filter, moving the probabilities from `old` to `new`. +/// Entries `new` cannot signal are left at `old` (and must be coded with `old`): the caller +/// codes the tiles with the probabilities this returns. +pub fn compressed_header(old: &Probs, new: &Probs, lossless: bool, inter: bool) -> (Vec, Probs) { + let mut bc = BoolEncoder::new(); + bc.write(false, 128); + if !lossless { + bc.literal(0, 2); // tx_mode = ONLY_4X4 + } + // read_coef_probs: TX_4X4 only. + let coef_changed = (COEF..COEF + 432).any(|i| new.0[i] != old.0[i]); + bc.literal(coef_changed as u32, 1); + if coef_changed { + for i in 0..2 { + for j in 0..2 { + for k in 0..6 { + for l in 0..if k == 0 { 3 } else { 6 } { + for m in 0..3 { + diff_update(&mut bc, old, new, coef_index(i, j == 1, k, l) + m); + } + } + } + } + } + } + for i in 0..3 { + diff_update(&mut bc, old, new, SKIP + i); + } + if inter { + for i in 0..21 { + diff_update(&mut bc, old, new, INTER_MODE + i); + } + for i in 0..4 { + diff_update(&mut bc, old, new, IS_INTER + i); + } + // reference_mode is SINGLE_REFERENCE (no compound: equal sign biases). + for i in 0..10 { + diff_update(&mut bc, old, new, SINGLE_REF + i); + } + for _ in 0..36 { + bc.write(false, 252); // y mode probabilities (no intra blocks in inter frames) + } + for i in 0..48 { + diff_update(&mut bc, old, new, PARTITION + i); + } + for j in 0..3 { + mv_update(&mut bc, old, new, MV_JOINT + j); + } + for i in 0..2 { + mv_update(&mut bc, old, new, MV_SIGN + i); + for j in 0..10 { + mv_update(&mut bc, old, new, MV_CLASS + i * 10 + j); + } + mv_update(&mut bc, old, new, MV_CLASS0_BIT + i); + for j in 0..10 { + mv_update(&mut bc, old, new, MV_BITS + i * 10 + j); + } + } + for i in 0..2 { + for j in 0..6 { + mv_update(&mut bc, old, new, MV_CLASS0_FR + i * 6 + j); + } + for j in 0..3 { + mv_update(&mut bc, old, new, MV_FR + i * 3 + j); + } + } + } + // What the decoder ends up with. + let mut used = old.clone(); + for i in 0..NPROBS { + let ok = if is_mv(i) { new.0[i] & 1 == 1 } else { delta_for(old.0[i], new.0[i]).is_some() }; + if ok && (inter || i < INTER_MODE) { + used.0[i] = new.0[i]; + } + } + (bc.finish(), used) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remap_reaches_every_probability() { + for old in 1..=255u8 { + for new in 1..=255u8 { + if new != old { + assert!(delta_for(old, new).is_some(), "{old} -> {new}"); + } + } + } + } + + #[test] + fn inv_map_table_shape() { + let t = inv_map_table(); + assert_eq!(&t[..4], &[7, 20, 33, 46]); + assert_eq!(t[19], 254); + assert_eq!(&t[20..27], &[1, 2, 3, 4, 5, 6, 8]); + assert_eq!(&t[252..], &[252, 253, 253]); + } +} diff --git a/crates/vp9enc/src/tables.rs b/crates/vp9enc/src/tables.rs index dc8027a6..ffb26641 100644 --- a/crates/vp9enc/src/tables.rs +++ b/crates/vp9enc/src/tables.rs @@ -128,3 +128,76 @@ pub const DEFAULT_COEF_PROBS: [u8; 432] = [ 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, ]; + +// ---------------------------------------------------------------- inter frames (spec section 10.5) + +/// `inv_map_table` (used by `inv_remap_prob`): the 20 values 7 + 13k first, then every other value +/// 1..=253 in increasing order, then 253 again. +pub fn inv_map_table() -> &'static [u8; 255] { + use std::sync::OnceLock; + static T: OnceLock<[u8; 255]> = OnceLock::new(); + T.get_or_init(|| { + let mut t = [253u8; 255]; + let mut n = 0; + for k in 0..20 { + t[n] = 7 + 13 * k; + n += 1; + } + for v in 1..=253u8 { + if v < 7 || (v - 7) % 13 != 0 { + t[n] = v; + n += 1; + } + } + t + }) +} + +/// The regular 8-tap sub-sample interpolation filters (`subpel_filters[EIGHTTAP]`), 16 positions. +pub const SUBPEL_REGULAR: [[i32; 8]; 16] = [ + [0, 0, 0, 128, 0, 0, 0, 0], + [0, 1, -5, 126, 8, -3, 1, 0], + [-1, 3, -10, 122, 18, -6, 2, 0], + [-1, 4, -13, 118, 27, -9, 3, -1], + [-1, 4, -16, 112, 37, -11, 4, -1], + [-1, 5, -18, 105, 48, -14, 4, -1], + [-1, 5, -19, 97, 58, -16, 5, -1], + [-1, 6, -19, 88, 68, -18, 5, -1], + [-1, 6, -19, 78, 78, -19, 6, -1], + [-1, 5, -18, 68, 88, -19, 6, -1], + [-1, 5, -16, 58, 97, -19, 5, -1], + [-1, 4, -14, 48, 105, -18, 5, -1], + [-1, 4, -11, 37, 112, -16, 4, -1], + [-1, 3, -9, 27, 118, -13, 4, -1], + [0, 2, -6, 18, 122, -10, 3, -1], + [0, 1, -3, 8, 126, -5, 1, 0], +]; + +/// `mv_ref_blocks` (row, column offsets) for BLOCK_8X8 and BLOCK_16X16. +pub const MV_REF_8X8: [(i32, i32); 8] = [(-1, 0), (0, -1), (-1, -1), (-2, 0), (0, -2), (-2, -1), (-1, -2), (-2, -2)]; +pub const MV_REF_16X16: [(i32, i32); 8] = [(-1, 0), (0, -1), (-1, 1), (1, -1), (-1, -1), (-3, 0), (0, -3), (-3, -3)]; + +/// `mode_2_counter` indexed by y mode (intra modes 0..=9, then NEARESTMV, NEARMV, ZEROMV, NEWMV). +pub const MODE_2_COUNTER: [u8; 14] = [9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 0, 0, 3, 1]; + +/// `counter_to_context`; INVALID_CASE entries are 9. +pub const COUNTER_TO_CONTEXT: [u8; 19] = [2, 3, 4, 1, 3, 9, 0, 9, 9, 5, 5, 9, 5, 9, 9, 9, 9, 9, 6]; + +pub const DEFAULT_PARTITION_PROBS: [u8; 48] = [ + 199, 122, 141, 147, 63, 159, 148, 133, 118, 121, 104, 114, 174, 73, 87, 92, 41, 83, 82, 99, 50, 53, 39, 39, 177, 58, 59, 68, 26, 63, 52, 79, 25, 17, 14, + 12, 222, 34, 30, 72, 16, 44, 58, 32, 12, 10, 7, 6, +]; + +pub const DEFAULT_IS_INTER_PROB: [u8; 4] = [9, 102, 187, 225]; + +pub const DEFAULT_SINGLE_REF_PROB: [u8; 10] = [33, 16, 77, 74, 142, 142, 172, 170, 238, 247]; + +pub const DEFAULT_INTER_MODE_PROBS: [u8; 21] = [2, 173, 34, 7, 145, 85, 7, 166, 63, 7, 94, 66, 8, 64, 46, 17, 81, 31, 25, 29, 30]; + +pub const DEFAULT_MV_JOINT_PROBS: [u8; 3] = [32, 64, 96]; +pub const DEFAULT_MV_SIGN_PROB: [u8; 2] = [128, 128]; +pub const DEFAULT_MV_CLASS_PROBS: [u8; 20] = [224, 144, 192, 168, 192, 176, 192, 198, 198, 245, 216, 128, 176, 160, 176, 176, 192, 198, 198, 208]; +pub const DEFAULT_MV_CLASS0_BIT_PROB: [u8; 2] = [216, 208]; +pub const DEFAULT_MV_BITS_PROB: [u8; 20] = [136, 140, 148, 160, 176, 192, 224, 234, 234, 240, 136, 140, 148, 160, 176, 192, 224, 234, 234, 240]; +pub const DEFAULT_MV_CLASS0_FR_PROBS: [u8; 12] = [128, 128, 64, 96, 112, 64, 128, 128, 64, 96, 112, 64]; +pub const DEFAULT_MV_FR_PROBS: [u8; 6] = [64, 96, 64, 64, 96, 64]; diff --git a/crates/vp9enc/src/tile.rs b/crates/vp9enc/src/tile.rs new file mode 100644 index 00000000..fe3a1a49 --- /dev/null +++ b/crates/vp9enc/src/tile.rs @@ -0,0 +1,894 @@ +//! Tile coding: partitions, intra blocks (key frames) and inter blocks (motion search, mode +//! decision, motion vector prediction, residual), recorded as symbols (see [`crate::probs`]). +//! +//! Mode info, motion vector candidate lists and contexts follow the decoding process of the +//! VP9 specification (6.4, 6.4.x `find_mv_refs` / `find_best_ref_mvs`) for the subset of the +//! syntax this encoder emits: 8×8 and 16×16 blocks, single LAST references, no +//! `UsePrevFrameMvs` (inter frames are error resilient), no high-precision vectors. + +use crate::mc::{Mv, PhasePlanes, RefPlane, clamp_for_plane, predict}; +use crate::probs::{self, P, Writer, cost, tree_cost}; +use crate::tables::*; +use crate::transform::{ADST_ADST, ADST_DCT, DCT_ADST, DCT_DCT, Quant}; +use crate::{BLOCK_8X8, BLOCK_16X16, Mi, Plane}; + +pub const DC_PRED: u8 = 0; +pub const V_PRED: u8 = 1; +pub const H_PRED: u8 = 2; +pub const TM_PRED: u8 = 9; +const MODES: [u8; 4] = [DC_PRED, V_PRED, H_PRED, TM_PRED]; +pub const NEARESTMV: u8 = 10; +pub const NEARMV: u8 = 11; +pub const ZEROMV: u8 = 12; +pub const NEWMV: u8 = 13; + +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]; +/// Leaves are `mode - NEARESTMV`. +const INTER_MODE_TREE: [i8; 6] = [-2, 2, 0, 4, -1, -3]; +const MV_JOINT_TREE: [i8; 6] = [0, 2, -1, 4, -2, -3]; +const MV_CLASS_TREE: [i8; 20] = [0, 2, -1, 4, 6, 8, -2, -3, 10, 12, -4, -5, -6, 14, 16, 18, -7, -8, -9, -10]; +const MV_FR_TREE: [i8; 6] = [0, 2, -1, 4, -2, -3]; + +/// 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], +]; + +/// mode2txfm_map for the intra modes used here (inter blocks use DCT_DCT). +fn mode_tx_type(mode: u8) -> u8 { + match mode { + V_PRED => ADST_DCT, + H_PRED => DCT_ADST, + TM_PRED => ADST_ADST, + _ => DCT_DCT, + } +} + +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 +} + +fn eob_of(levels: &[i32; 16], tx_type: u8) -> usize { + let sc = scan(tx_type); + (0..16).rev().find(|&i| levels[sc[i] as usize] != 0).map_or(0, |i| i + 1) +} + +/// Rough coefficient bit cost (1/256 bit) for rate-distortion decisions. +fn levels_cost(levels: &[i32; 16], eob: usize) -> u32 { + if eob == 0 { + return 0; + } + let mut bits = 256; + for &l in levels { + let a = l.unsigned_abs(); + bits += if a == 0 { 160 } else { 640 + 384 * (32 - a.leading_zeros()) }; + } + bits +} + +/// Intra prediction (8.5.1) of a square block for DC / V / H / TM. +fn predict_intra(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::() + above[..size].iter().sum::() + size as i32) >> (log2 + 1), + (true, false) => (left[..size].iter().sum::() + (1 << (log2 - 1))) >> log2, + (false, true) => (above[..size].iter().sum::() + (1 << (log2 - 1))) >> log2, + (false, false) => base, + }; + out[..size * size].fill(v); + } + out +} + +// ---------------------------------------------------------------- motion vector coding + +/// `mv_class` of an offset (|component| - 1). +fn mv_class(offset: u32) -> u32 { + if offset < 16 { 0 } else { (31 - offset.leading_zeros()) - 3 } +} + +fn mv_component_cost(i: usize, v: i32) -> u32 { + let d = &probs_defaults().0; + let offset = v.unsigned_abs() - 1; + let c = mv_class(offset); + let mut bits = cost(v < 0, d[probs::MV_SIGN + i]); + bits += tree_cost(&MV_CLASS_TREE, &d[probs::MV_CLASS + i * 10..], c as u8); + let fr = ((offset >> 1) & 3) as u8; + if c == 0 { + let b = offset >> 3; + bits += cost(b == 1, d[probs::MV_CLASS0_BIT + i]); + bits += tree_cost(&MV_FR_TREE, &d[probs::MV_CLASS0_FR + i * 6 + b as usize * 3..], fr); + } else { + let dd = (offset - (1 << (c + 3))) >> 3; + for b in 0..c { + bits += cost((dd >> b) & 1 == 1, d[probs::MV_BITS + i * 10 + b as usize]); + } + bits += tree_cost(&MV_FR_TREE, &d[probs::MV_FR + i * 3..], fr); + } + bits +} + +const MV_TABLE: i32 = 8192; + +/// Component costs for |v| ≤ MV_TABLE (index v + MV_TABLE) and the four joint costs. +fn mv_tables() -> &'static ([Vec; 2], [u32; 4]) { + use std::sync::OnceLock; + static T: OnceLock<([Vec; 2], [u32; 4])> = OnceLock::new(); + T.get_or_init(|| { + let comp = std::array::from_fn(|i| (-MV_TABLE..=MV_TABLE).map(|v| if v == 0 { 0 } else { mv_component_cost(i, v) }).collect()); + let d = &probs_defaults().0; + let joints = std::array::from_fn(|j| tree_cost(&MV_JOINT_TREE, &d[probs::MV_JOINT..], j as u8)); + (comp, joints) + }) +} + +/// Bits (1/256) of a NEWMV difference with the default probabilities. +pub fn mv_cost(diff: Mv) -> u32 { + let (comp, joints) = mv_tables(); + let joint = (diff.row != 0) as usize * 2 + (diff.col != 0) as usize; + let c = |i: usize, v: i32| if v.abs() <= MV_TABLE { comp[i][(v + MV_TABLE) as usize] } else { mv_component_cost(i, v) }; + joints[joint] + if diff.row != 0 { c(0, diff.row) } else { 0 } + if diff.col != 0 { c(1, diff.col) } else { 0 } +} + +fn probs_defaults() -> &'static probs::Probs { + use std::sync::OnceLock; + static D: OnceLock = OnceLock::new(); + D.get_or_init(probs::Probs::defaults) +} + +// ---------------------------------------------------------------- the tile + +/// Per-frame coding parameters shared by the tiles. +pub struct FrameParams<'a> { + pub key: bool, + pub quant: Quant, + pub mi_cols: usize, + pub mi_rows: usize, + /// Reference planes (inter frames). + pub refs: Option<[RefPlane<'a>; 3]>, + /// The reference luma at every quarter-sample phase (motion search). + pub phases: Option, + /// Encoder-side motion hints from the previous frame (by MI position). + pub prev_mvs: Option<&'a [Mv]>, + /// Lagrangian multipliers: per bit, in SAD and in squared-error units. + pub lambda_sad: f64, + pub lambda_sse: f64, +} + +pub struct Tile<'a> { + pub f: &'a FrameParams<'a>, + pub w: Writer, + pub planes: &'a mut [Plane; 3], + pub mi: &'a mut [Mi], + pub col_start: usize, + pub col_end: usize, + above_nz: [Vec; 3], + left_nz: [Vec; 3], + above_part: Vec, + left_part: Vec, + /// Motion vectors of the four 8×8 blocks evaluated for the current 16×16 area. + pending: [(usize, usize, Mv); 4], +} + +impl<'a> Tile<'a> { + pub fn new(f: &'a FrameParams<'a>, planes: &'a mut [Plane; 3], mi: &'a mut [Mi], col_start: usize, col_end: usize) -> Tile<'a> { + let n = f.mi_cols * 2 + 16; + Tile { + f, + w: Writer::default(), + planes, + mi, + col_start, + col_end, + above_nz: [vec![0; n], vec![0; n], vec![0; n]], + left_nz: [vec![0; 16], vec![0; 16], vec![0; 16]], + above_part: vec![0; f.mi_cols + 8], + left_part: vec![0; 8], + pending: [(usize::MAX, 0, Mv::ZERO); 4], + } + } + + /// Code the tile's superblocks. + pub fn code(&mut self) { + for r in (0..self.f.mi_rows).step_by(8) { + for p in 0..3 { + self.left_nz[p].fill(0); + } + self.left_part.fill(0); + for c in (self.col_start..self.col_end).step_by(8) { + self.partition(r, c, 12); + } + } + } + + fn mi_at(&self, r: usize, c: usize) -> Mi { + self.mi[r * self.f.mi_cols + c] + } + + fn set_mi(&mut self, r: usize, c: usize, n8: usize, m: Mi) { + for dr in 0..n8 { + for dc in 0..n8 { + if r + dr < self.f.mi_rows && c + dc < self.f.mi_cols { + self.mi[(r + dr) * self.f.mi_cols + c + dc] = m; + } + } + } + } + + fn partition(&mut self, r: usize, c: usize, bsize: u8) { + let (mi_rows, mi_cols) = (self.f.mi_rows, self.f.mi_cols); + if r >= mi_rows || c >= mi_cols { + return; + } + let num8 = [1usize, 1, 1, 1, 1, 2, 2, 2, 4, 4, 4, 8, 8][bsize as usize]; + let half = num8 >> 1; + let has_rows = (r + half) < mi_rows; + let has_cols = (c + half) < mi_cols; + let bsl = [0usize, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3][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 ctx = bsl * 4 + (((left >> boffset) & 1) as usize) * 2 + ((above >> boffset) & 1) as usize; + let key = self.f.key; + let prob = |i: usize| if key { P::Fixed(KF_PARTITION_PROBS[ctx * 3 + i]) } else { P::Ctx((probs::PARTITION + ctx * 3 + i) as u16) }; + if bsize == BLOCK_8X8 { + self.w.put(false, prob(0)); // PARTITION_NONE + if key { + self.intra_block(r, c); + } else { + let mv = self.pending.iter().find(|p| p.0 == r && p.1 == c).map(|p| p.2); + let mv = mv.unwrap_or_else(|| self.search(r, c, BLOCK_8X8).0); + self.inter_block(r, c, BLOCK_8X8, mv); + } + self.above_part[c] = 15 >> 1; + self.left_part[r & 7] = 15 >> 1; + return; + } + if !key && bsize == BLOCK_16X16 && has_rows && has_cols { + if let Some(mv) = self.decide16(r, c) { + self.w.put(false, prob(0)); // PARTITION_NONE + self.inter_block(r, c, BLOCK_16X16, mv); + for i in 0..2 { + self.above_part[c + i] = 15 >> 2; + self.left_part[(r & 7) + i] = 15 >> 2; + } + return; + } + } else if !key && bsize == BLOCK_16X16 { + self.pending = [(usize::MAX, 0, Mv::ZERO); 4]; + } + // PARTITION_SPLIT. + if has_rows && has_cols { + self.w.tree(&PARTITION_TREE, 3, prob); + } else if has_cols { + self.w.put(true, prob(1)); + } else if has_rows { + self.w.put(true, prob(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); + } + + // ------------------------------------------------------------ intra (key frames) + + fn intra_block(&mut self, r: usize, c: usize) { + let avail_u = r > 0; + let avail_l = c > self.col_start; + let y_mode = self.choose_intra(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.intra_cost(p, c * 4, r * 4, 2, m, avail_l, avail_u)).sum(); + if cost < best.0 { + best = (cost, m); + } + } + best.1 + }; + // skip = 0 (neighbours never skip in key frames: context 0); tx_size implied (4×4). + self.w.ctx(false, probs::SKIP); + let above_mode = if avail_u { self.mi_at(r - 1, c).mode } else { DC_PRED }; + let left_mode = if avail_l { self.mi_at(r, c - 1).mode } else { DC_PRED }; + let o = (above_mode as usize * 10 + left_mode as usize) * 9; + self.w.tree(&INTRA_MODE_TREE, y_mode, |i| P::Fixed(KF_Y_MODE_PROBS[o + i])); + let o = y_mode as usize * 9; + self.w.tree(&INTRA_MODE_TREE, uv_mode, |i| P::Fixed(KF_UV_MODE_PROBS[o + i])); + self.set_mi(r, c, 1, Mi { inter: false, mode: y_mode, mv: Mv::ZERO, skip: false, size: BLOCK_8X8 }); + // 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.f.quant.lossless { DCT_DCT } else { mode_tx_type(y_mode) }; + self.intra_tx(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.intra_tx(p, c * 4, r * 4, uv_mode, DCT_DCT, avail_l, avail_u); + } + } + + #[allow(clippy::too_many_arguments)] + fn intra_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_intra(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_intra(&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.intra_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 intra_tx(&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.f.mi_cols * 8) >> ss; + let max_y = (self.f.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_intra(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 q = Quant { round_dc: 0.5, round_ac: 0.38, ..self.f.quant }; + let levels = q.levels(&res, tx_type); + let eob = eob_of(&levels, tx_type); + let ctx0 = (self.above_nz[plane][x4] + self.left_nz[plane][ly]) as usize; + self.tokens(plane, false, tx_type, &levels, eob, ctx0); + let resid = q.residual(&levels, tx_type, eob); + 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, inter: bool, 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; + 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 = probs::coef_index(ptype, inter, band, ctx); + if check_eob { + let more = c < eob; + self.w.ctx(more, o); + if !more { + break; + } + } + let v = levels[pos].unsigned_abs(); + if v == 0 { + self.w.ctx(false, o + 1); + tc[pos] = 0; + check_eob = false; + c += 1; + continue; + } + self.w.ctx(true, o + 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.w.ctx(false, o + 2); + } else { + self.w.ctx(true, o + 2); + let path: &[(u8, 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.w.put(b, P::Pareto((o + 2) as u16, 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.w.fixed((extra >> (num_extra as usize - 1 - e)) & 1 != 0, cp[e]); + } + } + tc[pos] = ENERGY_CLASS[token]; + self.w.fixed(levels[pos] < 0, 128); + c += 1; + } + } + + // ------------------------------------------------------------ inter + + fn is_inside(&self, r: i32, c: i32) -> bool { + r >= 0 && (r as usize) < self.f.mi_rows && c >= self.col_start as i32 && (c as usize) < self.col_end + } + + /// `find_mv_refs` + `find_best_ref_mvs` for LAST_FRAME: (NearestMv, NearMv, mode context). + fn mv_refs(&self, r: usize, c: usize, bsize: u8) -> (Mv, Mv, usize) { + let search = if bsize == BLOCK_16X16 { &MV_REF_16X16 } else { &MV_REF_8X8 }; + let mut list = [Mv::ZERO; 2]; + let mut count = 0; + let add = |mv: Mv, list: &mut [Mv; 2], count: &mut usize| { + if *count >= 2 || (*count > 0 && mv == list[0]) { + return; + } + list[*count] = mv; + *count += 1; + }; + let mut counter = 0usize; + for (i, &(dr, dc)) in search.iter().enumerate() { + let (cr, cc) = (r as i32 + dr, c as i32 + dc); + if !self.is_inside(cr, cc) { + continue; + } + let m = self.mi_at(cr as usize, cc as usize); + if i < 2 { + counter += MODE_2_COUNTER[m.mode as usize] as usize; + } + if m.inter { + add(m.mv, &mut list, &mut count); + } + } + let ctx = COUNTER_TO_CONTEXT[counter.min(18)] as usize; + let n8 = if bsize == BLOCK_16X16 { 2 } else { 1 }; + let clamp = |mv: Mv, border: i32| -> Mv { + let (r, c, b) = (r as i32, c as i32, n8); + let to_top = -(r * 64); + let to_bottom = (self.f.mi_rows as i32 - b - r) * 64; + let to_left = -(c * 64); + let to_right = (self.f.mi_cols as i32 - b - c) * 64; + Mv::new( + mv.row.clamp(to_top - border, (to_bottom + border).max(to_top - border)), + mv.col.clamp(to_left - border, (to_right + border).max(to_left - border)), + ) + }; + for mv in list.iter_mut() { + *mv = clamp(*mv, 128); + // No high precision: odd components move toward zero. + let even = |v: i32| if v & 1 != 0 { v + if v > 0 { -1 } else { 1 } } else { v }; + *mv = clamp(Mv::new(even(mv.row), even(mv.col)), (160 - 4) << 3); + } + (list[0], list[1], ctx) + } + + /// The allowed motion vector range of a block (no decoder clamping inside it). + fn mv_range(&self, r: usize, c: usize, n: i32) -> (i32, i32, i32, i32) { + let n8 = n / 8; + let (r, c) = (r as i32, c as i32); + let lo_c = -(c * 64) - (4 + n) * 8; + let hi_c = (self.f.mi_cols as i32 - n8 - c) * 64 + (4 + n) * 8 - 8; + let lo_r = -(r * 64) - (4 + n) * 8; + let hi_r = (self.f.mi_rows as i32 - n8 - r) * 64 + (4 + n) * 8 - 8; + let lim = 8 * 1000; + (lo_r.max(-lim), hi_r.min(lim), lo_c.max(-lim), hi_c.min(lim)) + } + + /// SAD of the luma prediction at `mv` against the source. + fn sad(&self, r: usize, c: usize, n: usize, mv: Mv) -> u32 { + let p = &self.planes[0]; + let (x16, y16) = (((c * 8) << 4) as i32 + 2 * mv.col, ((r * 8) << 4) as i32 + 2 * mv.row); + let mut s = 0u32; + if let Some((pl, o, st)) = self.f.phases.as_ref().and_then(|ph| ph.block(x16, y16, n)) { + for i in 0..n { + let a = &p.src[(r * 8 + i) * p.w + c * 8..][..n]; + let b = &pl[o + i * st..o + i * st + n]; + s += a.iter().zip(b).map(|(a, b)| (*a as i32 - *b as i32).unsigned_abs()).sum::(); + } + return s; + } + let refs = self.f.refs.as_ref().expect("reference"); + let mut pred = [0u8; 256]; + predict(&refs[0], x16, y16, n, n, &mut pred); + for i in 0..n { + let a = &p.src[(r * 8 + i) * p.w + c * 8..][..n]; + s += a.iter().zip(&pred[i * n..i * n + n]).map(|(a, b)| (*a as i32 - *b as i32).unsigned_abs()).sum::(); + } + s + } + + /// Motion search for the block at (`r`, `c`): the best vector and its cost + /// (SAD + λ·bits). + fn search(&self, r: usize, c: usize, bsize: u8) -> (Mv, f64) { + let n = if bsize == BLOCK_16X16 { 16 } else { 8 }; + let (nearest, near, mctx) = self.mv_refs(r, c, bsize); + let (lo_r, hi_r, lo_c, hi_c) = self.mv_range(r, c, n as i32); + let inside = |mv: Mv| mv.row >= lo_r && mv.row <= hi_r && mv.col >= lo_c && mv.col <= hi_c; + let lambda = self.f.lambda_sad / 256.0; + let d = &probs_defaults().0; + let mb: [u32; 4] = std::array::from_fn(|m| tree_cost(&INTER_MODE_TREE, &d[probs::INTER_MODE + mctx * 3..], m as u8)); + let mode_bits = |m: u8| mb[(m - NEARESTMV) as usize]; + let cost = |mv: Mv| -> f64 { + let bits = if mv == nearest { + mode_bits(NEARESTMV) + } else if mv == Mv::ZERO { + mode_bits(ZEROMV) + } else if mv == near { + mode_bits(NEARMV) + } else { + mode_bits(NEWMV) + mv_cost(mv.minus(nearest)) + }; + self.sad(r, c, n, mv) as f64 + lambda * bits as f64 + }; + let mut best = (Mv::ZERO, cost(Mv::ZERO)); + let prev = self.f.prev_mvs.map(|p| p[r * self.f.mi_cols + c]); + for cand in [Some(nearest), Some(near), prev].into_iter().flatten() { + if cand != Mv::ZERO && inside(cand) { + let j = cost(cand); + if j < best.1 { + best = (cand, j); + } + } + } + // A perfect match needs no search. + let good_enough = lambda * 256.0 * 0.5; + if best.1 > good_enough { + // Whole-sample search from the best candidate: a coarse cross, then a small diamond. + let round8 = |v: i32| (v + 4).div_euclid(8) * 8; + let mut cur = Mv::new(round8(best.0.row), round8(best.0.col)); + let mut cur_j = if inside(cur) { cost(cur) } else { f64::MAX }; + if cur_j < best.1 { + best = (cur, cur_j); + } else { + cur = best.0; + cur_j = best.1; + } + for (step, max_iters) in [(16, 1), (8, 2), (4, 3), (2, 4), (1, 8)] { + let s = step * 8; + for _ in 0..max_iters { + let mut moved = false; + let center = cur; + for (dr, dc) in [(-s, 0), (s, 0), (0, -s), (0, s)] { + let cand = Mv::new(center.row + dr, center.col + dc); + if !inside(cand) { + continue; + } + let j = cost(cand); + if j < cur_j { + cur = cand; + cur_j = j; + moved = true; + } + } + if !moved { + break; + } + } + } + if cur_j < best.1 { + best = (cur, cur_j); + } + } + // Sub-sample refinement: half then quarter samples (1/8 units, even: no high precision). + for s in [4, 2] { + let center = best.0; + for (dr, dc) in [(-s, 0), (s, 0), (0, -s), (0, s), (-s, -s), (-s, s), (s, -s), (s, s)] { + let cand = Mv::new(center.row + dr, center.col + dc); + if !inside(cand) { + continue; + } + let j = cost(cand); + if j < best.1 { + best = (cand, j); + } + } + } + best + } + + /// Choose between one 16×16 block and four 8×8 blocks at (`r`, `c`): `Some(mv)` for 16×16. + fn decide16(&mut self, r: usize, c: usize) -> Option { + let (mv16, j16) = self.search(r, c, BLOCK_16X16); + let lambda = self.f.lambda_sad; + // A near-perfect 16×16 prediction needs no split. + if j16 < 2.0 * 256.0 / 256.0 * 16.0 { + return Some(mv16); + } + let mut j8 = lambda * 4.0; // partition split + three more blocks of mode info (≈ 4 bits) + for (k, (dr, dc)) in [(0, 0), (0, 1), (1, 0), (1, 1)].into_iter().enumerate() { + let (rr, cc) = (r + dr, c + dc); + let (mv, j) = self.search(rr, cc, BLOCK_8X8); + j8 += j; + self.pending[k] = (rr, cc, mv); + // Tentative mode info so the next 8×8 blocks see this one as a candidate. + self.set_mi(rr, cc, 1, Mi { inter: true, mode: NEWMV, mv, skip: false, size: BLOCK_8X8 }); + } + if j16 <= j8 { + self.pending = [(usize::MAX, 0, Mv::ZERO); 4]; + Some(mv16) + } else { + None + } + } + + /// Code an inter block with motion vector `mv`. + fn inter_block(&mut self, r: usize, c: usize, bsize: u8, mv: Mv) { + let (mi_rows, mi_cols) = (self.f.mi_rows, self.f.mi_cols); + let n8 = if bsize == BLOCK_16X16 { 2 } else { 1 }; + let n = 8 * n8; + let (nearest, near, mctx) = self.mv_refs(r, c, bsize); + let d = &probs_defaults().0; + let mode_bits = |m: u8| tree_cost(&INTER_MODE_TREE, &d[probs::INTER_MODE + mctx * 3..], m - NEARESTMV); + let mut mode = (NEWMV, mode_bits(NEWMV) + mv_cost(mv.minus(nearest))); + for (m, ok) in [(NEARESTMV, mv == nearest), (NEARMV, mv == near), (ZEROMV, mv == Mv::ZERO)] { + if ok && mode_bits(m) < mode.1 { + mode = (m, mode_bits(m)); + } + } + let mode = mode.0; + // Prediction of every plane (written straight into the reconstruction). + let refs = self.f.refs.as_ref().expect("reference"); + for plane in 0..3 { + let ch = plane > 0; + let (row16, col16) = clamp_for_plane(mv, r, c, n8, mi_rows, mi_cols, ch); + let bn = if ch { n / 2 } else { n }; + let (bx, by) = if ch { (c * 4, r * 4) } else { (c * 8, r * 8) }; + let mut pred = [0u8; 256]; + predict(&refs[plane], ((bx as i32) << 4) + col16, ((by as i32) << 4) + row16, bn, bn, &mut pred); + let p = &mut self.planes[plane]; + for i in 0..bn { + p.rec[(by + i) * p.w + bx..][..bn].copy_from_slice(&pred[i * bn..i * bn + bn]); + } + } + // Residual: quantise every 4×4, dropping blocks whose coefficients don't pay for themselves. + let q = Quant { round_dc: 0.5, round_ac: 0.42, ..self.f.quant }; + // Weight of the rate term when dropping a transform block's coefficients (low: keep quality on par + // with key frames at the same q index). + let zl = 0.07; + let tx = DCT_DCT; + let mut blocks: Vec<(usize, usize, usize, [i32; 16], usize)> = Vec::with_capacity(24); + let mut any = false; + for plane in 0..3 { + let ch = plane > 0; + let bn = if ch { n / 2 } else { n }; + let (bx, by) = if ch { (c * 4, r * 4) } else { (c * 8, r * 8) }; + let p = &self.planes[plane]; + for ty in (0..bn).step_by(4) { + for tx4 in (0..bn).step_by(4) { + let (x, y) = (bx + tx4, by + ty); + let mut res = [0i32; 16]; + for i in 0..4 { + for j in 0..4 { + let k = (y + i) * p.w + x + j; + res[i * 4 + j] = p.src[k] as i32 - p.rec[k] as i32; + } + } + let mut levels = q.levels(&res, tx); + let mut eob = eob_of(&levels, tx); + if eob > 0 && !q.lossless { + let back = q.residual(&levels, tx, eob); + let d_skip: i64 = res.iter().map(|v| (*v as i64) * (*v as i64)).sum(); + let d_code: i64 = res.iter().zip(&back).map(|(a, b)| ((a - b) as i64).pow(2)).sum(); + if d_skip as f64 <= d_code as f64 + zl * self.f.lambda_sse * levels_cost(&levels, eob) as f64 / 256.0 { + levels = [0; 16]; + eob = 0; + } + } + any |= eob > 0; + blocks.push((plane, x, y, levels, eob)); + } + } + } + let skip = !any; + // Mode info. + let avail_u = r > 0; + let avail_l = c > self.col_start; + let above = avail_u.then(|| self.mi_at(r - 1, c)); + let left = avail_l.then(|| self.mi_at(r, c - 1)); + let skip_ctx = above.is_some_and(|m| m.skip) as usize + left.is_some_and(|m| m.skip) as usize; + self.w.ctx(skip, probs::SKIP + skip_ctx); + // is_inter context: neighbours are inter (no intra blocks in inter frames). + let intra = |m: Option| m.is_some_and(|m| !m.inter); + let inter_ctx = match (above, left) { + (Some(_), Some(_)) => { + if intra(above) && intra(left) { + 3 + } else { + (intra(above) || intra(left)) as usize + } + } + (Some(_), None) => 2 * intra(above) as usize, + (None, Some(_)) => 2 * intra(left) as usize, + (None, None) => 0, + }; + self.w.ctx(true, probs::IS_INTER + inter_ctx); + // single_ref_p1 = 0 (LAST_FRAME); context for single-reference LAST neighbours. + let ref_ctx = match (above, left) { + (Some(a), Some(l)) => match (a.inter, l.inter) { + (false, false) => 2, + (false, true) => 4, + (true, false) => 4, + (true, true) => 4, + }, + (Some(m), None) | (None, Some(m)) => { + if m.inter { + 4 + } else { + 2 + } + } + (None, None) => 2, + }; + self.w.ctx(false, probs::SINGLE_REF + ref_ctx * 2); + self.w.tree(&INTER_MODE_TREE, mode - NEARESTMV, |i| P::Ctx((probs::INTER_MODE + mctx * 3 + i) as u16)); + if mode == NEWMV { + self.write_mv(mv.minus(nearest)); + } + self.set_mi(r, c, n8, Mi { inter: true, mode, mv, skip, size: bsize }); + // Tokens and reconstruction. + for (plane, x, y, levels, eob) in blocks { + let ss = (plane > 0) as usize; + let (x4, ly) = (x >> 2, (y >> 2) & (15 >> ss)); + if !skip { + let ctx0 = (self.above_nz[plane][x4] + self.left_nz[plane][ly]) as usize; + self.tokens(plane, true, tx, &levels, eob, ctx0); + if eob > 0 { + let resid = q.residual(&levels, tx, eob); + let p = &mut self.planes[plane]; + for i in 0..4 { + for j in 0..4 { + let k = (y + i) * p.w + x + j; + p.rec[k] = (p.rec[k] as i32 + resid[i * 4 + j]).clamp(0, 255) as u8; + } + } + } + } + let nz = (!skip && eob > 0) as u8; + self.above_nz[plane][x4] = nz; + self.left_nz[plane][ly] = nz; + } + } + + fn write_mv(&mut self, diff: Mv) { + let joint = (diff.row != 0) as u8 * 2 + (diff.col != 0) as u8; + self.w.tree(&MV_JOINT_TREE, joint, |i| P::Ctx((probs::MV_JOINT + i) as u16)); + if diff.row != 0 { + self.write_mv_component(0, diff.row); + } + if diff.col != 0 { + self.write_mv_component(1, diff.col); + } + } + + fn write_mv_component(&mut self, i: usize, v: i32) { + debug_assert!(v != 0 && v & 1 == 0, "vectors are even without high precision"); + let offset = v.unsigned_abs() - 1; + let c = mv_class(offset); + self.w.ctx(v < 0, probs::MV_SIGN + i); + self.w.tree(&MV_CLASS_TREE, c as u8, |k| P::Ctx((probs::MV_CLASS + i * 10 + k) as u16)); + let fr = ((offset >> 1) & 3) as u8; + if c == 0 { + let b = (offset >> 3) as usize; + self.w.ctx(b == 1, probs::MV_CLASS0_BIT + i); + self.w.tree(&MV_FR_TREE, fr, |k| P::Ctx((probs::MV_CLASS0_FR + i * 6 + b * 3 + k) as u16)); + } else { + let dd = (offset - (1 << (c + 3))) >> 3; + for b in 0..c { + self.w.ctx((dd >> b) & 1 == 1, probs::MV_BITS + i * 10 + b as usize); + } + self.w.tree(&MV_FR_TREE, fr, |k| P::Ctx((probs::MV_FR + i * 3 + k) as u16)); + } + } +} diff --git a/crates/vp9enc/src/transform.rs b/crates/vp9enc/src/transform.rs new file mode 100644 index 00000000..e6a685d5 --- /dev/null +++ b/crates/vp9enc/src/transform.rs @@ -0,0 +1,249 @@ +//! 4×4 transforms: the decoder's integer inverse DCT / ADST (spec 8.7.1) and Walsh–Hadamard +//! transform (8.7.1.10), and their forward counterparts (the exact inverse matrices of the +//! decoder's linear transforms, so `inverse4(forward(res)) ≈ res`; the WHT inverse is exact). + +pub const DCT_DCT: u8 = 0; +pub const ADST_DCT: u8 = 1; +pub const DCT_ADST: u8 = 2; +pub const ADST_ADST: u8 = 3; + +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. +pub 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] +} + +pub 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`. +pub 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. +pub 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 +} + +/// Quantisation settings of one block. +#[derive(Clone, Copy)] +pub struct Quant { + pub dcq: i32, + pub acq: i32, + pub lossless: bool, + /// Rounding offsets (fractions of a step) for DC and AC; smaller = wider dead zone. + pub round_dc: f64, + pub round_ac: f64, +} + +impl Quant { + /// Quantised levels (raster order) of a residual. + pub fn levels(&self, res: &[i32; 16], tx_type: u8) -> [i32; 16] { + if self.lossless { + return forward_wht(res); + } + 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, rnd) = if k == 0 { (self.dcq, self.round_dc) } else { (self.acq, self.round_ac) }; + let l = (coef.abs() / qs as f64 + rnd).floor() as i32; + lv[k] = if coef < 0.0 { -l } else { l }; + } + lv + } + + /// The decoder's residual for `levels` (all zero when `eob` is 0). + pub fn residual(&self, levels: &[i32; 16], tx_type: u8, eob: usize) -> [i32; 16] { + if eob == 0 { + return [0; 16]; + } + let mut deq = [0i32; 16]; + for k in 0..16 { + deq[k] = levels[k] * if k == 0 { self.dcq } else { self.acq }; + } + if self.lossless { inverse_wht(&deq) } else { inverse4(&deq, tx_type) } + } +} + +#[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::().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:?}"); + } + } + } +} diff --git a/docs/parity.md b/docs/parity.md index 38df7882..8fc67b9a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -110,7 +110,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Area | Weighted parity | Remaining (agent-hours) | Biggest gaps | |---|---|---|---| | Layers | 88% | 4.8 | frame blending, collapse transformations, slip edit | -| 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) | +| Output | 90% | 3.0 | multiple output modules, pre-render (WebM with VP9 alpha + Opus and WAV/AIFF audio-only landed: VP9 key + inter frames with motion search, loop filter and rate control; Opus CELT-only) | | Audio | 85% | 0.5 | audio to keyframes | | 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 | ≈ 90% | 0.8 | scripting covers the documented core object model (AUT-2, M14.4: `app`, project items, comps, layers, properties and keyframes, text documents, markers, render queue, Script Console, `effectcraft-cli script`, MCP `run_script`); still missing: ScriptUI panels/dialogs, `.jsxbin`, sockets | From 1242a775fed8c65893731cf6ee01c38d6f26da4c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 04:55:31 -0400 Subject: [PATCH 2/2] M10.2: Render Queue completeness (render settings, output module, templates, logs, overflow, notify) Render Settings: field render with 3:2 pulldown phases, effects/solo/guide layer/color depth/frame blending/motion blur overrides, custom time sampling (duration), storage overflow through a quota hook. Output Module: crop (edges or region of interest), resize (presets, lock aspect, quality), straight/premultiplied/alpha-only channels, post-render actions, include project link, audio channels and PCM formats, WebM bitrate and key frame interval. Render Settings / Output Module templates with built-ins and defaults per slot (Edit > Templates dialogs), render logs next to the output, Notify on completion. All through renderQueue.* commands and persisted in the project. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + README.md | 7 +- crates/engine/src/commands/comp_more.rs | 40 +- crates/engine/src/commands/proxy.rs | 19 +- crates/engine/src/commands/render_queue.rs | 520 ++++++++++-- crates/engine/src/lib.rs | 3 + crates/engine/src/render_queue.rs | 51 +- crates/engine/src/tests_stubs.rs | 117 +++ crates/export/Cargo.toml | 1 + crates/export/src/encode.rs | 80 +- crates/export/src/lib.rs | 170 ++-- crates/export/src/pipeline.rs | 327 ++++++++ crates/export/src/webm.rs | 79 +- crates/export/tests/render_settings.rs | 310 ++++++++ crates/export/tests/roundtrip.rs | 28 +- crates/host/src/lib.rs | 18 +- crates/project/src/lib.rs | 17 +- crates/project/src/render_queue.rs | 743 +++++++++++++++++- crates/project/src/render_templates.rs | 323 ++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 14 +- crates/ui-egui/src/panels/dialogs.rs | 1 + crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/render_queue.rs | 259 +++++- crates/ui-egui/src/panels/rq_templates.rs | 166 ++++ crates/ui-egui/tests/render_queue_snapshot.rs | 23 + docs/parity.md | 4 +- docs/web.md | 2 - 28 files changed, 3096 insertions(+), 230 deletions(-) create mode 100644 crates/export/src/pipeline.rs create mode 100644 crates/export/tests/render_settings.rs create mode 100644 crates/project/src/render_templates.rs create mode 100644 crates/ui-egui/src/panels/rq_templates.rs diff --git a/Cargo.lock b/Cargo.lock index c7934100..1dcfbb95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1236,6 +1236,7 @@ name = "effectcraft-export" version = "0.1.0" dependencies = [ "effectcraft-color", + "effectcraft-keyframe", "effectcraft-media", "effectcraft-opusenc", "effectcraft-project", diff --git a/README.md b/README.md index 3bff64fb..3a9cabbc 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,11 @@ tools, and Front, Top, Left and Custom views. ## Export A Render Queue like After Effects', with Render Settings and Output Modules: **H.264** MP4 and -**ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, PNG, JPEG, TIFF and 32-bit EXR -sequences, and animated GIF. The same queue runs from the command line. Every encoder is +**ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, **WebM** (VP9 with inter frames and +alpha, Opus), PNG, JPEG, TIFF and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field +rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and +resize, Render Settings and Output Module templates with defaults, post-render actions, storage +overflow and render logs. The same queue runs from the command line. Every encoder is FilmCraft's pure-Rust code; there is no FFmpeg inside. **Lottie** goes both ways: File ▸ Export ▸ Lottie JSON… writes a composition (precomps, shape, diff --git a/crates/engine/src/commands/comp_more.rs b/crates/engine/src/commands/comp_more.rs index e3057f67..0f07fd67 100644 --- a/crates/engine/src/commands/comp_more.rs +++ b/crates/engine/src/commands/comp_more.rs @@ -82,7 +82,37 @@ pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result Result { + use effectcraft_project::render_queue::{RenderQueueItem, TimeSpan}; + use effectcraft_project::render_templates::TemplateSlot; + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time()); + let mut it = RenderQueueItem::new(0, cid); + it.settings = s.project.render_templates.default_render_settings(TemplateSlot::Still); + it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() }; + it.output = s.project.render_templates.default_output_module(TemplateSlot::Still); + if let Some(path) = str_p(p, "path") { + it.output.output = path.to_string(); + } else if !it.output.format.is_sequence() { + it.output.output = "[compName]_frame.[fileExtension]".into(); + } + let id = s.edit("Save Frame As", None, |proj, _| { + it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1; + let id = it.id; + proj.render_queue.push(it); + Ok(id) + })?; + let path = s.project.render_queue.last().and_then(|i| s.resolve_output(i)); + Ok(json!({"item": id, "queued": true, "output": path, "time": t.seconds()})) +} + fn save_frame(s: &mut Session, p: &Value) -> Result { + if p.get("queue").and_then(Value::as_bool) == Some(true) { + return queue_frame(s, p); + } let path = str_p(p, "path").ok_or_else(|| bad("comp.saveFrameAs", "missing `path` (.png)"))?.to_string(); let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; @@ -124,7 +154,15 @@ pub fn specs() -> Vec { vec![ cmd!("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", ["Composition"], None, "{comp?}", has_roi, crop_to_roi), cmd!("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", ["Composition"], None, "{layers?}", has_layers, crop_to_layers), - cmd!("comp.saveFrameAs", "File...", ["Composition", "Save Frame As"], Some("Cmd+Alt+S"), "{path (.png), time?, scale?}", has_comp, save_frame), + cmd!( + "comp.saveFrameAs", + "File...", + ["Composition", "Save Frame As"], + Some("Cmd+Alt+S"), + "{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now)}", + has_comp, + save_frame + ), cmd!("comp.responsiveTime", "Responsive Design — Time", [], None, "{op: intro|outro|workArea}", has_comp, responsive_time), ] } diff --git a/crates/engine/src/commands/proxy.rs b/crates/engine/src/commands/proxy.rs index 607dd5dd..3e1126a9 100644 --- a/crates/engine/src/commands/proxy.rs +++ b/crates/engine/src/commands/proxy.rs @@ -4,6 +4,7 @@ //! item's switch). use effectcraft_project::render_queue::{Channels, OutputFormat, OutputModule, PostRenderAction, ProResProfile, RenderQuality, RenderQueueItem, TimeSpan}; +use effectcraft_project::render_templates::TemplateSlot; use effectcraft_project::{Footage, ItemId, ItemKind, Proxy}; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -159,18 +160,28 @@ fn create_proxy(s: &mut Session, p: &Value) -> Result { k => return Err(bad(C, format!("kind: still|movie, not `{k}`"))), }; let mut it = RenderQueueItem::new(0, cid); + // The Movie / Still Proxy Default templates, at draft quality and the proxy resolution. + let templates = &s.project.render_templates; + let slot = if still { TemplateSlot::StillProxy } else { TemplateSlot::MovieProxy }; + it.settings = templates.default_render_settings(slot); it.settings.quality = RenderQuality::Draft; it.settings.resolution = p.get("resolution").and_then(Value::as_f64).unwrap_or(0.5).clamp(0.05, 1.0); if still { // One frame at the current time (the comp's poster frame when not the active comp). let t = if s.active_comp_id() == Some(cid) { s.time() } else { comp.poster_time }; it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() }; - it.output = OutputModule::for_format(OutputFormat::PngSequence); - it.output.output = "[compName]_proxy_[#####].png".into(); + it.output = templates.default_output_module(slot); + if !it.output.format.is_sequence() { + it.output = OutputModule::for_format(OutputFormat::PngSequence); + } + it.output.output = "[compName]_proxy_[#####].[fileExtension]".into(); } else { it.settings.time_span = TimeSpan::LengthOfComp; - it.output = OutputModule::for_format(OutputFormat::ProRes); - it.output.prores_profile = ProResProfile::P4444; + it.output = templates.default_output_module(slot); + if !it.output.format.is_movie() { + it.output = OutputModule::for_format(OutputFormat::ProRes); + it.output.prores_profile = ProResProfile::P4444; + } it.output.output = "[compName]_proxy.[fileExtension]".into(); } it.output.channels = Channels::Rgba; diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index 91467ea5..fcf088ac 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -1,16 +1,20 @@ //! Render Queue: Composition ▸ Add to Render Queue, the queue's Render Settings / Output Module / -//! Output To, Render and Stop. +//! Output To / Log, Render and Stop, Notify, storage overflow folders, and the Render Settings / +//! Output Module templates (Edit ▸ Templates). //! //! Items are addressed by `item` (the stable id from `renderQueue.add` / `renderQueue.list`) or //! `index` (1-based, the # column). use effectcraft_project::render_queue::{ - AudioOutput, Channels, OutputFormat, OutputModule, PostRenderAction, ProResProfile, RenderQuality, RenderQueueItem, RenderSettings, RenderStatus, TimeSpan, + AlphaMode, AudioFormat, AudioOutput, Channels, ColorDepth, CurrentOrOff, DEFAULT_SEQUENCE_TEMPLATE, DEFAULT_TEMPLATE, EffectsMode, FieldRender, + OutputFormat, OutputModule, PostRenderAction, ProResProfile, Pulldown, RESIZE_PRESETS, RenderLog, RenderQuality, RenderQueueItem, RenderSettings, + RenderStatus, ResizeQuality, SwitchOverride, TimeSpan, post_render_parse, }; +use effectcraft_project::render_templates::{RenderTemplates, TemplateKind, TemplateSlot}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; -use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, str_p}; +use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, str_p}; use crate::{EngineError, Result, Session, cmd, query}; fn can_add(s: &Session) -> std::result::Result<(), String> { @@ -54,16 +58,32 @@ fn time_p(p: &Value, k: &str) -> Option { f_p(p, k).map(Tick::from_seconds_f64) } -/// Apply Render Settings parameters. -fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result { +/// Parse an enum parameter. +fn enum_p(p: &Value, k: &str, cmd: &str, parse: fn(&str) -> Option, help: &str) -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(v)) => parse(v).map(Some).ok_or_else(|| bad(cmd, format!("{k}: {help}"))), + Some(Value::Bool(b)) => parse(if *b { "on" } else { "off" }).map(Some).ok_or_else(|| bad(cmd, format!("{k}: {help}"))), + Some(Value::Number(n)) => parse(&n.to_string()).map(Some).ok_or_else(|| bad(cmd, format!("{k}: {help}"))), + Some(_) => Err(bad(cmd, format!("{k}: {help}"))), + } +} + +/// Apply Render Settings parameters (`template` first, then the individual settings). Any +/// individual change makes the settings "Custom". +fn apply_settings(rs: &mut RenderSettings, templates: &RenderTemplates, p: &Value, cmd: &str) -> Result { let mut any = false; + if let Some(t) = str_p(p, "template") { + *rs = templates.render_settings(t).ok_or_else(|| bad(cmd, format!("no Render Settings template “{t}”")))?; + any = true; + } + let before = rs.clone(); if let Some(q) = str_p(p, "quality") { rs.quality = match q.to_ascii_lowercase().as_str() { "best" => RenderQuality::Best, "draft" => RenderQuality::Draft, _ => return Err(bad(cmd, "quality: best|draft")), }; - any = true; } match p.get("resolution") { Some(Value::String(r)) => { @@ -74,15 +94,21 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result "quarter" => 0.25, _ => return Err(bad(cmd, "resolution: full|half|third|quarter|")), }; - any = true; } Some(Value::Number(n)) => { rs.resolution = n.as_f64().unwrap_or(1.0).clamp(0.01, 4.0); - any = true; } _ => {} } - let (start, end) = (time_p(p, "start"), time_p(p, "end")); + let (start, mut end) = (time_p(p, "start"), time_p(p, "end")); + if let Some(d) = time_p(p, "duration") { + // Time Span ▸ Custom ▸ Duration: from the given (or current custom) start. + let a = start.unwrap_or(match rs.time_span { + TimeSpan::Custom { start, .. } => start, + _ => Tick::ZERO, + }); + end = Some(a + d); + } if let Some(ts) = str_p(p, "timeSpan") { rs.time_span = match ts.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { "workarea" | "workareaonly" => TimeSpan::WorkArea, @@ -90,14 +116,12 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result "custom" => TimeSpan::Custom { start: start.unwrap_or(Tick::ZERO), end: end.unwrap_or(Tick::from_seconds_f64(1.0)) }, _ => return Err(bad(cmd, "timeSpan: workArea|comp|custom")), }; - any = true; } else if start.is_some() || end.is_some() { let (a, b) = match rs.time_span { TimeSpan::Custom { start, end } => (start, end), _ => (Tick::ZERO, Tick(i64::MAX / 4)), }; rs.time_span = TimeSpan::Custom { start: start.unwrap_or(a), end: end.unwrap_or(b) }; - any = true; } if let TimeSpan::Custom { start, end } = rs.time_span && end <= start @@ -106,61 +130,193 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result } if let Some(u) = str_p(p, "proxyUse") { rs.proxy_use = effectcraft_project::render_queue::ProxyUse::parse(u).ok_or_else(|| bad(cmd, "proxyUse: current|all|comp|none"))?; - any = true; } match p.get("frameRate") { Some(Value::Null) => { rs.frame_rate = None; - any = true; } Some(v) => { let f = v.as_f64().filter(|f| *f > 0.0 && *f <= 1000.0).ok_or_else(|| bad(cmd, "frameRate: fps > 0 or null (comp rate)"))?; rs.frame_rate = Some(FrameRate::from_f64(f)); - any = true; } None => {} } - if let Some(b) = b_p(p, "motionBlur") { - rs.motion_blur = b; - any = true; + match p.get("motionBlur") { + Some(Value::Bool(b)) => { + rs.motion_blur = *b; + if *b && rs.motion_blur_mode == SwitchOverride::OffForAll { + rs.motion_blur_mode = SwitchOverride::Current; + } + } + Some(_) => { + let m = enum_p(p, "motionBlur", cmd, SwitchOverride::parse, "current|onForChecked|offForAll")?.unwrap_or_default(); + rs.motion_blur = m != SwitchOverride::OffForAll; + rs.motion_blur_mode = m; + } + None => {} + } + if let Some(m) = enum_p(p, "frameBlending", cmd, SwitchOverride::parse, "current|onForChecked|offForAll")? { + rs.frame_blending = m; + } + if let Some(f) = enum_p(p, "fieldRender", cmd, FieldRender::parse, "off|upper|lower")? { + rs.field_render = f; + } + if let Some(f) = enum_p(p, "pulldown", cmd, Pulldown::parse, "off|WSSWW|SSWWW|SWWWS|WWWSS|WWSSW")? { + rs.pulldown = f; + } + if let Some(e) = enum_p(p, "effects", cmd, EffectsMode::parse, "current|allOn|allOff")? { + rs.effects = e; + } + if let Some(e) = enum_p(p, "solo", cmd, CurrentOrOff::parse, "current|allOff")? { + rs.solo = e; + } + if let Some(e) = enum_p(p, "guideLayers", cmd, CurrentOrOff::parse, "current|allOff")? { + rs.guide_layers = e; + } + if let Some(e) = enum_p(p, "colorDepth", cmd, ColorDepth::parse, "current|8|16|32")? { + rs.color_depth = e; } if let Some(b) = b_p(p, "skipExisting") { rs.skip_existing = b; + } + if let Some(b) = b_p(p, "storageOverflow") { + rs.storage_overflow = b; + } + if *rs != before { + rs.name = "Custom".into(); any = true; } Ok(any) } -/// Apply Output Module parameters. -fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result { +/// Apply a template to a module, keeping its output path (with the extension fixed). +fn apply_om_template(om: &mut OutputModule, t: &OutputModule) { + let keep = om.output.clone(); + let old = om.format; + *om = t.clone(); + om.output = keep; + let f = om.format; + om.format = old; + om.set_format(f); + om.channels = t.channels; + om.name = t.name.clone(); +} + +/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest +/// (captured by `crop.useRoi`). +fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result { let mut any = false; + if let Some(t) = str_p(p, "template") { + let t = templates.output_module(t).ok_or_else(|| bad(cmd, format!("no Output Module template “{t}”")))?; + apply_om_template(om, &t); + any = true; + } + let before = om.clone(); if let Some(f) = str_p(p, "format") { 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; } if let Some(c) = str_p(p, "channels") { om.channels = match c.to_ascii_lowercase().replace([' ', '+'], "").as_str() { "rgb" => Channels::Rgb, "rgba" | "rgbalpha" => Channels::Rgba, - _ => return Err(bad(cmd, "channels: rgb|rgba")), + "alpha" | "alphaonly" => Channels::Alpha, + _ => return Err(bad(cmd, "channels: rgb|rgba|alpha")), }; if om.channels == Channels::Rgba && !om.format.supports_alpha() { return Err(bad(cmd, format!("{} has no alpha channel", om.format.label()))); } - any = true; + } + if let Some(c) = str_p(p, "color").or(str_p(p, "alphaMode")) { + om.alpha_mode = match c.to_ascii_lowercase().replace([' ', '(', ')'], "").as_str() { + "straight" | "straightunmatted" => AlphaMode::Straight, + "premultiplied" | "premultipliedmatted" | "premul" => AlphaMode::Premultiplied, + _ => return Err(bad(cmd, "color: straight|premultiplied")), + }; } if let Some(q) = f_p(p, "quality") { om.quality = q.clamp(1.0, 100.0) as u8; - any = true; } if let Some(b) = f_p(p, "bitrate") { - om.bitrate_kbps = b.clamp(100.0, 500_000.0) as u32; - any = true; + om.bitrate_kbps = b.clamp(50.0, 500_000.0) as u32; + } + if let Some(b) = b_p(p, "webmBitrate") { + om.webm_bitrate = b; + } + if let Some(k) = p.get("keyframeInterval").and_then(Value::as_u64) { + om.keyframe_interval = k.min(100_000) as u32; } if let Some(pp) = str_p(p, "proresProfile") { om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?; - any = true; + } + match p.get("crop") { + Some(Value::Bool(b)) => om.crop.enabled = *b, + Some(Value::Object(c)) => { + om.crop.enabled = c.get("enabled").and_then(Value::as_bool).unwrap_or(true); + for (k, v) in [("top", &mut om.crop.top), ("left", &mut om.crop.left), ("bottom", &mut om.crop.bottom), ("right", &mut om.crop.right)] { + if let Some(n) = c.get(k).and_then(Value::as_i64) { + *v = n.clamp(-20_000, 20_000) as i32; + } + } + if let Some(u) = c.get("useRoi").and_then(Value::as_bool) { + om.crop.use_roi = u; + if u { + om.crop.roi = c.get("roi").and_then(|r| serde_json::from_value::<[f64; 4]>(r.clone()).ok()).or(roi); + if om.crop.roi.is_none() { + return Err(bad( + cmd, + "Use Region of Interest: the composition has no region of interest (View ▸ Region of Interest) — pass crop.roi [x, y, w, h]", + )); + } + } + } + } + Some(Value::Null) | None => {} + Some(_) => return Err(bad(cmd, "crop: bool or {enabled?, useRoi?, roi?: [x,y,w,h], top?, left?, bottom?, right?}")), + } + // Flat keys (dialog forms): cropTop/cropLeft/cropBottom/cropRight, resizeWidth/resizeHeight. + for (k, v) in [("cropTop", &mut om.crop.top), ("cropLeft", &mut om.crop.left), ("cropBottom", &mut om.crop.bottom), ("cropRight", &mut om.crop.right)] { + if let Some(n) = p.get(k).and_then(Value::as_f64) { + *v = (n.round() as i64).clamp(-20_000, 20_000) as i32; + om.crop.enabled = true; + om.crop.use_roi = false; + } + } + for (k, v) in [("resizeWidth", &mut om.resize.width), ("resizeHeight", &mut om.resize.height)] { + if let Some(n) = p.get(k).and_then(Value::as_f64) { + *v = (n.round() as u32).clamp(1, 30_000); + om.resize.enabled = true; + } + } + match p.get("resize") { + Some(Value::Bool(b)) => om.resize.enabled = *b, + Some(Value::Object(r)) => { + om.resize.enabled = r.get("enabled").and_then(Value::as_bool).unwrap_or(true); + if let Some(name) = r.get("preset").and_then(Value::as_str) { + let (_, w, h) = + RESIZE_PRESETS.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name)).ok_or_else(|| bad(cmd, format!("no resize preset “{name}”")))?; + (om.resize.width, om.resize.height) = (*w, *h); + om.resize.lock_aspect = false; + } + if let Some(l) = r.get("lockAspect").and_then(Value::as_bool) { + om.resize.lock_aspect = l; + } + if let Some(w) = r.get("width").and_then(Value::as_u64) { + om.resize.width = (w as u32).clamp(1, 30_000); + } + if let Some(h) = r.get("height").and_then(Value::as_u64) { + om.resize.height = (h as u32).clamp(1, 30_000); + } + if let Some(q) = r.get("quality").and_then(Value::as_str) { + om.resize.quality = match q.to_ascii_lowercase().as_str() { + "low" | "bilinear" => ResizeQuality::Low, + "high" | "bicubic" => ResizeQuality::High, + _ => return Err(bad(cmd, "resize.quality: low|high")), + }; + } + } + Some(Value::Null) | None => {} + Some(_) => return Err(bad(cmd, "resize: bool or {enabled?, preset?, width?, height?, lockAspect?, quality?: low|high}")), } if let Some(a) = p.get("audio") { om.audio = match a { @@ -174,15 +330,29 @@ fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result { }, _ => return Err(bad(cmd, "audio: auto|on|off")), }; - any = true; } if let Some(r) = p.get("sampleRate").and_then(Value::as_u64) { om.audio_sample_rate = (r as u32).clamp(8_000, 192_000); - any = true; + } + match p.get("audioChannels") { + Some(Value::Number(n)) => om.audio_channels = if n.as_u64() == Some(1) { 1 } else { 2 }, + Some(Value::String(c)) => { + om.audio_channels = match c.to_ascii_lowercase().as_str() { + "mono" | "1" => 1, + "stereo" | "2" => 2, + _ => return Err(bad(cmd, "audioChannels: mono|stereo")), + } + } + _ => {} + } + if let Some(f) = enum_p(p, "audioFormat", cmd, AudioFormat::parse, "16|24|32 (float)")? { + om.audio_format = f; } if let Some(l) = b_p(p, "loop") { om.gif_loop = l; - any = true; + } + if let Some(l) = b_p(p, "includeProjectLink") { + om.include_project_link = l; } if let Some(o) = str_p(p, "output").or(str_p(p, "path")) { om.output = o.to_string(); @@ -194,6 +364,14 @@ fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result { om.set_format(f); om.output = keep; } + } + if *om != before { + // Output To alone doesn't make the module custom. + let mut o = om.clone(); + o.output = before.output.clone(); + if o != before { + om.name = "Custom".into(); + } any = true; } Ok(any) @@ -227,7 +405,7 @@ fn requeue(it: &mut RenderQueueItem) { fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value { let comp = s.project.comp(it.comp); let name = s.project.item(it.comp).map(|i| i.name.clone()); - let (w, h) = comp.map(|c| it.settings.output_size(c)).map(|(w, h)| it.output.format.coded_size(w, h)).unwrap_or((0, 0)); + let (w, h) = comp.map(|c| it.output.output_size(c, &it.settings)).unwrap_or((0, 0)); let frames = comp.map(|c| it.settings.frame_count(c)).unwrap_or(0); let mut v = serde_json::to_value(it).unwrap_or(Value::Null); if let Some(o) = v.as_object_mut() { @@ -249,6 +427,10 @@ fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value { o.insert("outputModules".into(), json!(it.extra_outputs.len() + 1)); o.insert("extraOutputs".into(), json!(extra)); o.insert("postRenderAction".into(), json!(it.post_render.label())); + o.insert("logLabel".into(), json!(it.log.label())); + if let Some(p) = s.resolve_output(it) { + o.insert("logPath".into(), json!(effectcraft_project::render_queue::log_path(&p))); + } o.insert("width".into(), json!(w)); o.insert("height".into(), json!(h)); o.insert("frames".into(), json!(frames)); @@ -259,16 +441,25 @@ fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value { fn add(s: &mut Session, p: &Value) -> Result { let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?; let mut it = RenderQueueItem::new(0, cid); + // New items start from the Movie Default templates (Edit ▸ Templates). + let templates = s.project.render_templates.clone(); + it.settings = templates.default_render_settings(TemplateSlot::Movie); + it.output = templates.default_output_module(TemplateSlot::Movie); if let Some(f) = str_p(p, "format") { 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); + if f != it.output.format { + it.output = OutputModule::for_format(f); + } } - apply_settings(&mut it.settings, p, "renderQueue.add")?; + apply_settings(&mut it.settings, &templates, p, "renderQueue.add")?; let mut op = p.clone(); if let Some(o) = op.as_object_mut() { o.remove("format"); } - apply_output(&mut it.output, &op, "renderQueue.add")?; + apply_output(&mut it.output, &templates, s.state.region_of_interest, &op, "renderQueue.add")?; + if let Some(l) = enum_p(p, "log", "renderQueue.add", RenderLog::parse, "errorsOnly|plusSettings|plusPerFrameInfo")? { + it.log = l; + } let id = s.edit("Add to Render Queue", None, |proj, _| { it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1; let id = it.id; @@ -305,7 +496,7 @@ fn set_render(s: &mut Session, p: &Value) -> Result { fn set_render_settings(s: &mut Session, p: &Value) -> Result { let i = item_index(s, p, "renderQueue.setRenderSettings")?; let mut rs = s.project.render_queue[i].settings.clone(); - if !apply_settings(&mut rs, p, "renderQueue.setRenderSettings")? { + if !apply_settings(&mut rs, &s.project.render_templates, p, "renderQueue.setRenderSettings")? { return Err(bad("renderQueue.setRenderSettings", "nothing to change")); } s.edit("Render Settings", None, |proj, _| { @@ -321,12 +512,20 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result { let i = item_index(s, p, "renderQueue.setOutputModule")?; let m = module_p(s, i, p, "renderQueue.setOutputModule")?; let mut om = module_ref(&s.project.render_queue[i], m).clone(); - if !apply_output(&mut om, p, "renderQueue.setOutputModule")? { + let changed = apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, p, "renderQueue.setOutputModule")?; + let post = match str_p(p, "postRenderAction") { + Some(a) => Some(post_render_parse(a).ok_or_else(|| bad("renderQueue.setOutputModule", "postRenderAction: none|import|importAndReplace|setProxy"))?), + None => None, + }; + if !changed && post.is_none() { return Err(bad("renderQueue.setOutputModule", "nothing to change")); } s.edit("Output Module Settings", None, |proj, _| { let it = &mut proj.render_queue[i]; *module_mut(it, m) = om; + if let Some(a) = post { + it.post_render = a; + } requeue(it); Ok(()) })?; @@ -338,7 +537,7 @@ fn set_output(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").or(str_p(p, "output")).ok_or_else(|| bad("renderQueue.setOutput", "pass `path` (file path or template)"))?; let m = module_p(s, i, p, "renderQueue.setOutput")?; let mut om = module_ref(&s.project.render_queue[i], m).clone(); - apply_output(&mut om, &json!({"output": path}), "renderQueue.setOutput")?; + apply_output(&mut om, &s.project.render_templates, None, &json!({"output": path}), "renderQueue.setOutput")?; s.edit("Output To", None, |proj, _| { let it = &mut proj.render_queue[i]; *module_mut(it, m) = om; @@ -452,7 +651,7 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result { if let Some(o) = op.as_object_mut() { o.remove("format"); } - apply_output(&mut om, &op, "render.addOutputModule")?; + apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, &op, "render.addOutputModule")?; s.edit("Add Output Module", None, |proj, _| { let it = &mut proj.render_queue[i]; it.extra_outputs.push(om); @@ -469,11 +668,11 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result { fn pre_render(s: &mut Session, p: &Value) -> Result { let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?; let mut it = RenderQueueItem::new(0, cid); - it.output = OutputModule::for_format(OutputFormat::ProRes); - it.output.prores_profile = ProResProfile::P4444; - it.output.channels = Channels::Rgba; + let templates = s.project.render_templates.clone(); + it.settings = templates.default_render_settings(TemplateSlot::PreRender); + it.output = templates.default_output_module(TemplateSlot::PreRender); it.output.output = "[compName]_prerender.[fileExtension]".into(); - apply_output(&mut it.output, p, "render.preRender")?; + apply_output(&mut it.output, &templates, s.state.region_of_interest, p, "render.preRender")?; it.post_render = PostRenderAction::ImportAndReplace; let id = s.edit("Pre-render", None, |proj, _| { it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1; @@ -499,13 +698,202 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result { let mut item = RenderQueueItem::new(0, cid); item.output = OutputModule::for_format(format); item.output.output = path.clone(); - let job = - crate::ExportJob { project: &s.project, footage: s.footage.as_ref(), expr: s.expr.as_deref(), accel: s.accel.as_deref(), item: &item, path: &path }; + let job = crate::ExportJob { + project: &s.project, + footage: s.footage.as_ref(), + expr: s.expr.as_deref(), + accel: s.accel.as_deref(), + item: &item, + path: &path, + storage: s.storage_quota.as_deref(), + label: "Save Current Preview".into(), + }; let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?; s.toast(format!("Saved preview to {}", r.path)); Ok(serde_json::to_value(&r).unwrap_or_default()) } +// ---------------------------------------------------------------- templates (Edit ▸ Templates) + +fn kind_p(p: &Value, cmd: &str) -> Result { + str_p(p, "kind").and_then(TemplateKind::parse).ok_or_else(|| bad(cmd, "kind: renderSettings|outputModule")) +} + +fn name_p<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> { + str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "pass `name`")) +} + +/// Templates as JSON (lists, built-in flags, defaults per slot). +fn templates_json(t: &RenderTemplates) -> Value { + let rs: Vec = t + .render_settings_list() + .iter() + .map(|r| json!({"name": r.name, "builtin": RenderTemplates::is_builtin(TemplateKind::RenderSettings, &r.name), "settings": r})) + .collect(); + let om: Vec = t + .output_module_list() + .iter() + .map(|m| json!({"name": m.name, "builtin": RenderTemplates::is_builtin(TemplateKind::OutputModule, &m.name), "summary": format!("{} · {}", m.format.label(), m.channels.label()), "module": m})) + .collect(); + let slots = |k: TemplateKind| -> Value { + TemplateSlot::ALL.iter().map(|sl| (format!("{sl:?}"), json!(t.default_name(k, *sl)))).collect::>().into() + }; + json!({ + "renderSettings": rs, + "outputModules": om, + "defaults": {"renderSettings": slots(TemplateKind::RenderSettings), "outputModules": slots(TemplateKind::OutputModule)}, + "slots": TemplateSlot::ALL.iter().map(|sl| json!({"id": format!("{sl:?}"), "label": sl.label()})).collect::>(), + "resizePresets": RESIZE_PRESETS.iter().map(|(n, w, h)| json!({"name": n, "width": w, "height": h})).collect::>(), + }) +} + +fn templates(s: &mut Session, _: &Value) -> Result { + Ok(templates_json(&s.project.render_templates)) +} + +/// Save a template: from an item's settings (`item`/`index`, `module`), or the slot default, +/// adjusted by `params` (the same keys as setRenderSettings / setOutputModule). +fn save_template(s: &mut Session, p: &Value) -> Result { + const C: &str = "renderQueue.saveTemplate"; + let kind = kind_p(p, C)?; + let name = name_p(p, C)?.to_string(); + if name.eq_ignore_ascii_case("custom") { + return Err(bad(C, "“Custom” is reserved")); + } + let item = if p.get("item").is_some() || p.get("index").is_some() { Some(item_index(s, p, C)?) } else { None }; + let params = p.get("params").cloned().unwrap_or(Value::Null); + let from = str_p(p, "from"); + let mut t = s.project.render_templates.clone(); + match kind { + TemplateKind::RenderSettings => { + let mut rs = match (item, from) { + (Some(i), _) => s.project.render_queue[i].settings.clone(), + (None, Some(f)) => t.render_settings(f).ok_or_else(|| bad(C, format!("no Render Settings template “{f}”")))?, + (None, None) => t.default_render_settings(TemplateSlot::Movie), + }; + if params.is_object() { + apply_settings(&mut rs, &t, ¶ms, C)?; + } + t.save_render_settings(&name, rs); + } + TemplateKind::OutputModule => { + let mut om = match (item, from) { + (Some(i), _) => module_ref(&s.project.render_queue[i], module_p(s, i, p, C)?).clone(), + (None, Some(f)) => t.output_module(f).ok_or_else(|| bad(C, format!("no Output Module template “{f}”")))?, + (None, None) => t.default_output_module(TemplateSlot::Movie), + }; + if params.is_object() { + apply_output(&mut om, &t, s.state.region_of_interest, ¶ms, C)?; + } + // Templates name files after the composition, not a particular path. + om.output = if om.format.is_sequence() { DEFAULT_SEQUENCE_TEMPLATE } else { DEFAULT_TEMPLATE }.into(); + t.save_output_module(&name, om); + } + } + s.edit("Save Template", None, |proj, _| { + proj.render_templates = t; + Ok(()) + })?; + Ok(json!({"saved": name, "templates": templates_json(&s.project.render_templates)})) +} + +fn delete_template(s: &mut Session, p: &Value) -> Result { + const C: &str = "renderQueue.deleteTemplate"; + let kind = kind_p(p, C)?; + let name = name_p(p, C)?.to_string(); + let mut t = s.project.render_templates.clone(); + t.delete(kind, &name).map_err(|e| bad(C, e))?; + s.edit("Delete Template", None, |proj, _| { + proj.render_templates = t; + Ok(()) + })?; + Ok(json!({"deleted": name})) +} + +fn set_template_default(s: &mut Session, p: &Value) -> Result { + const C: &str = "renderQueue.setTemplateDefault"; + let kind = kind_p(p, C)?; + let slot = str_p(p, "slot").and_then(TemplateSlot::parse).ok_or_else(|| bad(C, "slot: movie|still|preRender|movieProxy|stillProxy"))?; + let name = name_p(p, C)?.to_string(); + let mut t = s.project.render_templates.clone(); + t.set_default(kind, slot, &name).map_err(|e| bad(C, e))?; + s.edit("Template Default", None, |proj, _| { + proj.render_templates = t; + Ok(()) + })?; + Ok(json!({"slot": format!("{slot:?}"), "name": name})) +} + +/// Apply templates to an item: `renderSettings` and/or `outputModule` (to `module`). +fn apply_template(s: &mut Session, p: &Value) -> Result { + const C: &str = "renderQueue.applyTemplate"; + let i = item_index(s, p, C)?; + let m = module_p(s, i, p, C)?; + let t = s.project.render_templates.clone(); + let rs = match str_p(p, "renderSettings") { + Some(n) => Some(t.render_settings(n).ok_or_else(|| bad(C, format!("no Render Settings template “{n}”")))?), + None => None, + }; + let om = match str_p(p, "outputModule") { + Some(n) => { + let tpl = t.output_module(n).ok_or_else(|| bad(C, format!("no Output Module template “{n}”")))?; + let mut om = module_ref(&s.project.render_queue[i], m).clone(); + apply_om_template(&mut om, &tpl); + Some(om) + } + None => None, + }; + if rs.is_none() && om.is_none() { + return Err(bad(C, "pass `renderSettings` and/or `outputModule` (template names)")); + } + s.edit("Apply Template", None, |proj, _| { + let it = &mut proj.render_queue[i]; + if let Some(rs) = rs { + it.settings = rs; + } + if let Some(om) = om { + *module_mut(it, m) = om; + } + requeue(it); + Ok(()) + })?; + Ok(item_json(s, &s.project.render_queue[i], i)) +} + +fn set_log(s: &mut Session, p: &Value) -> Result { + const C: &str = "renderQueue.setLog"; + let i = item_index(s, p, C)?; + let log = enum_p(p, "log", C, RenderLog::parse, "errorsOnly|plusSettings|plusPerFrameInfo")?.ok_or_else(|| bad(C, "pass `log`"))?; + s.edit("Render Queue: Log", None, |proj, _| { + proj.render_queue[i].log = log; + Ok(()) + })?; + Ok(item_json(s, &s.project.render_queue[i], i)) +} + +fn set_notify(s: &mut Session, p: &Value) -> Result { + let on = b_p(p, "notify").or(b_p(p, "value")).unwrap_or(!s.project.render_prefs.notify); + s.edit("Render Queue: Notify", None, |proj, _| { + proj.render_prefs.notify = on; + Ok(()) + })?; + Ok(json!({"notify": on})) +} + +fn set_overflow(s: &mut Session, p: &Value) -> Result { + const C: &str = "renderQueue.setOverflowFolders"; + let folders: Vec = match p.get("folders") { + Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::trim).filter(|f| !f.is_empty()).map(String::from).collect(), + Some(Value::Null) | None => vec![], + _ => return Err(bad(C, "folders: [path, …]")), + }; + s.edit("Storage Overflow Folders", None, |proj, _| { + proj.render_prefs.overflow_folders = folders.clone(); + Ok(()) + })?; + Ok(json!({"folders": s.project.render_prefs.overflow_folders})) +} + pub fn specs() -> Vec { vec![ cmd!( @@ -513,7 +901,7 @@ pub fn specs() -> Vec { "Add to Render Queue", ["Composition"], Some("Cmd+M"), - "{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, proxyUse?: current|all|comp|none, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}", + "{comp?: id|name, template?: Render Settings template, format?: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}", can_add, add ), @@ -524,7 +912,7 @@ pub fn specs() -> Vec { "Render Settings...", [], None, - "{item?|index?, quality?: best|draft, resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, proxyUse?: current|all|comp|none}", + "{item?|index?, template?: name, quality?: best|draft, resolution?: full|half|third|quarter|scale, proxyUse?: current|all|comp|none, effects?: current|allOn|allOff, solo?: current|allOff, guideLayers?: current|allOff, colorDepth?: current|8|16|32, frameBlending?: current|onForChecked|offForAll, fieldRender?: off|upper|lower, pulldown?: off|WSSWW|SSWWW|SWWWS|WWWSS|WWSSW, motionBlur?: bool|current|onForChecked|offForAll, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool}", has_items, set_render_settings ), @@ -533,7 +921,7 @@ pub fn specs() -> Vec { "Output Module Settings...", [], None, - "{item?|index?, module?: n (1 = first), format?, channels?: rgb|rgba, quality?: 1-100, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool, output?}", + "{item?|index?, module?: n (1 = first), template?: name, format?, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames, proresProfile?: proxy|lt|standard|hq|4444|4444xq, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}", has_items, set_output_module ), @@ -562,6 +950,46 @@ pub fn specs() -> Vec { cmd!("renderQueue.render", "Render", [], None, "{wait?: bool (default true; the UI renders in the background)}", has_queued, render), cmd!("renderQueue.stop", "Stop Rendering", [], None, "{}", rendering, stop), query!("renderQueue.list", "Render Queue Items", "{}", list), + query!("renderQueue.templates", "Render Templates", "{}", templates), + cmd!( + "renderQueue.saveTemplate", + "Save Template...", + [], + None, + "{kind: renderSettings|outputModule, name, item?|index? (save from this item), from?: template to copy, module?, params?: {setRenderSettings / setOutputModule keys}}", + always, + save_template + ), + cmd!("renderQueue.deleteTemplate", "Delete Template", [], None, "{kind: renderSettings|outputModule, name}", always, delete_template), + cmd!( + "renderQueue.setTemplateDefault", + "Set Template Default", + [], + None, + "{kind: renderSettings|outputModule, slot: movie|still|preRender|movieProxy|stillProxy, name}", + always, + set_template_default + ), + cmd!( + "renderQueue.applyTemplate", + "Apply Template", + [], + None, + "{item?|index?, renderSettings?: template name, outputModule?: template name, module?: n}", + has_items, + apply_template + ), + cmd!("renderQueue.setLog", "Render Queue: Log", [], None, "{item?|index?, log: errorsOnly|plusSettings|plusPerFrameInfo}", has_items, set_log), + cmd!("renderQueue.setNotify", "Notify When Done", [], None, "{notify?: bool (toggles)}", always, set_notify), + cmd!( + "renderQueue.setOverflowFolders", + "Storage Overflow Folders", + [], + None, + "{folders: [path, …] (used in order when the output volume is full)}", + always, + set_overflow + ), query!("renderQueue.formats", "Output Formats", "{}", formats), ] } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9333329a..d7a89160 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -275,6 +275,8 @@ pub struct Session { pub importer: Option>, /// Render Queue encoder (the export layer); `None` = export unavailable. pub exporter: Option>, + /// Free-space hook for Render Settings ▸ Use Storage Overflow (`None`: never full). + pub storage_quota: Option>, /// The running (or finished, not yet polled) render. pub render_job: Option, /// The running (or finished, not yet polled) track analysis. @@ -357,6 +359,7 @@ impl Default for Session { expr_check: None, importer: None, exporter: None, + storage_quota: None, render_job: None, track_job: None, mask_job: None, diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 9dd20e8d..3de9d8e5 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -27,6 +27,10 @@ pub struct ExportJob<'a> { pub accel: Option<&'a dyn effectcraft_render::Accelerator>, /// Resolved output path (templates expanded except the `#` frame-number run). pub path: &'a str, + /// Free-space hook (Use Storage Overflow). + pub storage: Option<&'a dyn effectcraft_project::render_queue::StorageQuota>, + /// "#n Comp Name", for the render log. + pub label: String, } /// What an export wrote. @@ -39,6 +43,12 @@ pub struct ExportResult { pub bytes: u64, pub seconds: f64, pub audio: bool, + /// The render log written next to the output, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub log: Option, + /// Files that went to storage overflow folders. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub overflow: Vec, } /// Encodes render queue items (implemented by the export layer). @@ -127,6 +137,7 @@ struct Work { expr: Option>, accel: Option>, exporter: Arc, + storage: Option>, /// Per queue item: one (item with that output module, resolved path) per output module. items: Vec>, } @@ -143,7 +154,15 @@ pub(crate) fn run_items( shared: &JobShared, notify: &mut dyn FnMut(&JobShared), ) { - let work = Work { project: s.project.clone(), footage: s.footage.clone(), expr: s.expr.clone(), accel: s.accel.clone(), exporter, items }; + let work = Work { + project: s.project.clone(), + footage: s.footage.clone(), + expr: s.expr.clone(), + accel: s.accel.clone(), + exporter, + storage: s.storage_quota.clone(), + items, + }; run_work(work, shared, notify); } @@ -174,7 +193,18 @@ fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared)) { // first module's file is the item's output. let mut r: Result = Err("no output module".into()); for (mi, (mitem, path)) in modules.iter().enumerate() { - let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), accel: w.accel.as_deref(), item: mitem, path }; + let name = w.project.item(mitem.comp).map(|i| i.name.clone()).unwrap_or_default(); + let index = w.project.render_queue.iter().position(|i| i.id == mitem.id).map_or(0, |i| i + 1); + let job = ExportJob { + project: &w.project, + footage: w.footage.as_ref(), + expr: w.expr.as_deref(), + accel: w.accel.as_deref(), + item: mitem, + path, + storage: w.storage.as_deref(), + label: format!("#{index} {name} (output module {})", mi + 1), + }; let rr = w.exporter.export(&job, &mut |done, total| { { let mut s = lock(&shared.state); @@ -235,8 +265,7 @@ impl Session { if tpl.is_empty() { return None; } - let (w, h) = item.settings.output_size(comp); - let (w, h) = item.output.format.coded_size(w, h); + let (w, h) = item.output.output_size(comp, &item.settings); let rate = item.settings.rate(comp); let n = item.settings.frame_count(comp) as i64; let f0 = item.settings.first_frame(comp); @@ -329,7 +358,15 @@ impl Session { self.poll_render(); return Ok(ids); } - let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), accel: self.accel.clone(), exporter, items }; + let work = Work { + project: self.project.clone(), + footage: self.footage.clone(), + expr: self.expr.clone(), + accel: self.accel.clone(), + exporter, + storage: self.storage_quota.clone(), + items, + }; let shared = Arc::new(JobShared::default()); if wait { run_work(work, &shared, &mut |_| {}); @@ -426,6 +463,10 @@ impl Session { } else { format!("Rendered {} item(s) in {:.1} s", st.items_total, st.elapsed) }; + if self.project.render_prefs.notify { + // Notify: the frontend plays a sound and shows a system notification. + self.events.push(Event::Frontend { command: "renderQueue.notify".into(), params: serde_json::json!({"message": msg, "error": failed > 0}) }); + } self.events.push(Event::Toast { message: msg, error: failed > 0 }); } for (id, path) in post { diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index 997552bc..18e65cb0 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -312,6 +312,123 @@ fn pre_render_imports_and_replaces() { assert!(matches!(s.project.comp(outer).unwrap().layers[0].source, LayerSource::Comp { .. })); } +#[test] +fn post_render_actions_import_and_set_proxy() { + let (mut s, _) = rq(); + let cid = s.active_comp_id().unwrap(); + let items_before = s.project.items.len(); + // Import: the rendered file becomes a footage item. + let a = s.execute("renderQueue.add", json!({"output": "/tmp/post/a.mov"})).unwrap(); + s.execute("renderQueue.setOutputModule", json!({"item": a["item"], "postRenderAction": "import"})).unwrap(); + assert_eq!(s.project.render_queue[0].post_render, PostRenderAction::Import); + s.execute("renderQueue.render", json!({})).unwrap(); + assert_eq!(s.project.items.len(), items_before + 1); + assert!(s.project.items.values().any(|i| matches!(&i.kind, effectcraft_project::ItemKind::Footage(f) if f.path == "/tmp/post/a.mov"))); + // Set Proxy: the comp gets the render as its proxy. + let b = s.execute("renderQueue.add", json!({"output": "/tmp/post/b.mov"})).unwrap(); + s.execute("renderQueue.setOutputModule", json!({"item": b["item"], "postRenderAction": "Set Proxy"})).unwrap(); + s.execute("renderQueue.render", json!({})).unwrap(); + let px = s.project.item(cid).unwrap().proxy.clone().expect("proxy set"); + assert_eq!(px.footage.path, "/tmp/post/b.mov"); + // None: nothing happens. + let n = s.project.items.len(); + let c = s.execute("renderQueue.add", json!({"output": "/tmp/post/c.mov"})).unwrap(); + s.execute("renderQueue.setOutputModule", json!({"item": c["item"], "postRenderAction": "none"})).unwrap(); + s.execute("renderQueue.render", json!({})).unwrap(); + assert_eq!(s.project.items.len(), n); + assert!(s.execute("renderQueue.setOutputModule", json!({"item": c["item"], "postRenderAction": "explode"})).is_err()); +} + +#[test] +fn templates_save_apply_default_and_persist() { + let (mut s, _) = rq(); + let t = s.execute("renderQueue.templates", json!({})).unwrap(); + let names: Vec<&str> = t["renderSettings"].as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Best Settings", "Draft Settings", "DV Settings", "Multi-Machine Settings", "Current Settings"]); + assert!(t["outputModules"].as_array().unwrap().iter().any(|m| m["name"] == "High Quality with Alpha")); + assert_eq!(t["defaults"]["renderSettings"]["Movie"], "Best Settings"); + // A new item starts from the Movie defaults. + let a = s.execute("renderQueue.add", json!({"output": "/tmp/t/a.mp4"})).unwrap(); + assert_eq!(a["renderSettingsSummary"], "Best Settings"); + assert_eq!(a["outputModuleSummary"], "H.264 - Match Render Settings - 15 Mbps"); + // Edit it: Custom; save templates from it; make them the defaults. + let r = s + .execute( + "renderQueue.setRenderSettings", + json!({"item": a["item"], "fieldRender": "upper", "pulldown": "WSSWW", "colorDepth": 16, "effects": "allOff", "solo": "allOff", "guideLayers": "current", "frameBlending": "offForAll", "motionBlur": "onForChecked", "duration": 0.5, "storageOverflow": false}), + ) + .unwrap(); + assert_eq!(r["renderSettingsSummary"], "Custom"); + assert_eq!(r["settings"]["field_render"], "UpperFirst"); + s.execute( + "renderQueue.setOutputModule", + json!({"item": a["item"], "format": "png", "channels": "rgba", "color": "premultiplied", "crop": {"top": 2, "bottom": 2}, "resize": {"width": 320, "height": 200, "lockAspect": false, "quality": "low"}}), + ) + .unwrap(); + s.execute("renderQueue.saveTemplate", json!({"kind": "renderSettings", "name": "Interlaced 16", "item": a["item"]})).unwrap(); + s.execute("renderQueue.saveTemplate", json!({"kind": "outputModule", "name": "Small PNG", "item": a["item"], "params": {"quality": 50}})).unwrap(); + s.execute("renderQueue.setTemplateDefault", json!({"kind": "renderSettings", "slot": "movie", "name": "Interlaced 16"})).unwrap(); + s.execute("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "movie", "name": "Small PNG"})).unwrap(); + assert!(s.execute("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "movie", "name": "Nope"})).is_err()); + let b = s.execute("renderQueue.add", json!({"output": "/tmp/t/b_[####].png"})).unwrap(); + assert_eq!(b["renderSettingsSummary"], "Interlaced 16"); + assert_eq!(b["outputModuleSummary"], "Small PNG"); + let it = s.project.render_queue.last().unwrap().clone(); + assert_eq!(it.output.resize.width, 320); + assert_eq!(it.output.output, "/tmp/t/b_[####].png"); + assert_eq!((b["width"].as_u64(), b["height"].as_u64()), (Some(320), Some(200))); + // Apply built-ins to an item; the output path keeps its name with the new extension. + let r = s.execute("renderQueue.applyTemplate", json!({"item": a["item"], "renderSettings": "DV Settings", "outputModule": "High Quality"})).unwrap(); + assert_eq!(r["renderSettingsSummary"], "DV Settings"); + assert_eq!(r["outputModuleSummary"], "High Quality"); + assert!(r["outputPath"].as_str().unwrap().ends_with("a.mov"), "{r}"); + // Persisted with the project. + let p = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap(); + assert_eq!(p.render_templates, s.project.render_templates); + assert_eq!(p.render_queue, s.project.render_queue); + // Delete: saved ones go; built-ins stay. Undo restores. + s.execute("renderQueue.deleteTemplate", json!({"kind": "outputModule", "name": "Small PNG"})).unwrap(); + assert!(s.project.render_templates.output_module("Small PNG").is_none()); + assert!(s.execute("renderQueue.deleteTemplate", json!({"kind": "renderSettings", "name": "Best Settings"})).is_err()); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.render_templates.output_module("Small PNG").is_some()); +} + +#[test] +fn save_frame_as_queues_with_the_frame_defaults() { + let (mut s, _) = rq(); + s.execute("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "TIFF Sequence with Alpha"})).unwrap(); + let r = s.execute("comp.saveFrameAs", json!({"queue": true, "time": 1.0})).unwrap(); + assert_eq!(r["queued"], true); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.format, OutputFormat::TiffSequence); + assert_eq!(it.settings.name, "Current Settings"); + let comp = s.project.comp(it.comp).unwrap(); + assert_eq!(it.settings.frame_count(comp), 1); + assert_eq!(it.settings.frame_time(comp, 0), Tick::from_seconds_f64(1.0)); +} + +#[test] +fn log_notify_and_overflow_settings() { + let (mut s, _) = rq(); + let a = s.execute("renderQueue.add", json!({"output": "/tmp/n/a.mp4", "log": "plusPerFrameInfo"})).unwrap(); + assert_eq!(a["logLabel"], "Plus Per Frame Info"); + assert_eq!(a["logPath"], "/tmp/n/a_RenderLog.txt"); + s.execute("renderQueue.setLog", json!({"item": a["item"], "log": "plusSettings"})).unwrap(); + assert_eq!(s.project.render_queue[0].log, effectcraft_project::render_queue::RenderLog::PlusSettings); + s.execute("renderQueue.setOverflowFolders", json!({"folders": ["/tmp/o1", " ", "/tmp/o2"]})).unwrap(); + assert_eq!(s.project.render_prefs.overflow_folders, ["/tmp/o1", "/tmp/o2"]); + assert_eq!(s.execute("renderQueue.setNotify", json!({})).unwrap()["notify"], true); + s.drain_events(); + s.execute("renderQueue.render", json!({})).unwrap(); + let ev = s.drain_events(); + assert!(ev.iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "renderQueue.notify")), "{ev:?}"); + s.execute("renderQueue.setNotify", json!({"notify": false})).unwrap(); + s.execute("renderQueue.setRender", json!({"index": 1, "render": true})).unwrap(); + s.execute("renderQueue.render", json!({})).unwrap(); + assert!(!s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "renderQueue.notify"))); +} + #[test] fn save_current_preview_writes_a_movie() { let (mut s, ex) = rq(); diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index 15f9b17d..6900c204 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -32,3 +32,4 @@ effectcraft-media = { workspace = true } effectcraft-color = { workspace = true } filmcraft-opus = { workspace = true } filmcraft-vp9 = { workspace = true } +effectcraft-keyframe = { workspace = true } diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs index d6cf3150..b0566d9b 100644 --- a/crates/export/src/encode.rs +++ b/crates/export/src/encode.rs @@ -4,12 +4,12 @@ use std::io::Write; use effectcraft_project::Comp; -use effectcraft_project::render_queue::{Channels, OutputFormat, ProResProfile}; +use effectcraft_project::render_queue::{AudioFormat, Channels, OutputFormat, ProResProfile}; use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; use filmcraft_isobmff::{Brand, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions}; use rayon::prelude::*; -use crate::{ExportError, Job, Report, Result, State, batch_size, render_frame, rgba8, wants_audio}; +use crate::{Cx, ExportError, Report, Result, State, batch_size, wants_audio}; fn enc(e: impl std::fmt::Display) -> ExportError { ExportError::Encode(e.to_string()) @@ -208,29 +208,60 @@ enum Audio { Pcm, } -const AUDIO_CHANNELS: u32 = 2; +fn deinterleave(buf: &[f32], channels: usize) -> Vec> { + (0..channels).map(|c| buf.chunks_exact(channels).map(|p| p[c]).collect()).collect() +} -fn deinterleave(buf: &[f32]) -> [Vec; 2] { - let l = buf.chunks_exact(2).map(|p| p[0]).collect(); - let r = buf.chunks_exact(2).map(|p| p[1]).collect(); - [l, r] +/// The comp's audio over `n` samples from `start`, interleaved with the module's channel count +/// (mono = the average of left and right). +pub(crate) fn mix(cx: &Cx, start: effectcraft_time::Tick, n: usize, sr: u32) -> Vec { + let st = effectcraft_render::audio::mix_comp(&cx.project, cx.footage, cx.expr, cx.comp, start, n, sr); + if cx.output.audio_channels == 1 { st.chunks_exact(2).map(|p| (p[0] + p[1]) * 0.5).collect() } else { st } +} + +/// PCM bytes of samples in `fmt` (little or big endian). +pub(crate) fn pcm_bytes(buf: &[f32], fmt: AudioFormat, big_endian: bool) -> Vec { + let mut out = Vec::with_capacity(buf.len() * 4); + for &s in buf { + let s = s.clamp(-1.0, 1.0); + match fmt { + AudioFormat::S16 => { + let v = (s * 32767.0).round() as i16; + out.extend_from_slice(&if big_endian { v.to_be_bytes() } else { v.to_le_bytes() }); + } + AudioFormat::S24 => { + let v = (s * 8_388_607.0).round() as i32; + let b = v.to_le_bytes(); + if big_endian { + out.extend_from_slice(&[b[2], b[1], b[0]]); + } else { + out.extend_from_slice(&b[..3]); + } + } + AudioFormat::F32 => out.extend_from_slice(&if big_endian { s.to_be_bytes() } else { s.to_le_bytes() }), + } + } + out } // ---------------------------------------------------------------- the movie type Writer<'f, 's> = Mp4Writer<&'f mut crate::out::Out<'s>>; -pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result { +pub(crate) fn movie(job: &Cx, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result { let rate = job.settings.rate(comp); let fmt = job.output.format; - let channels = if fmt == OutputFormat::ProRes { job.output.channels } else { Channels::Rgb }; + let channels = match job.output.channels { + Channels::Rgba if fmt != OutputFormat::ProRes => Channels::Rgb, + c => c, + }; let mut venc: Box = match fmt { OutputFormat::H264 => Box::new(H264::new(w, h, rate, job.output.bitrate_kbps)?), - _ => Box::new(ProRes::new(w, h, job.output.prores_profile, channels)), + _ => Box::new(ProRes::new(w, h, job.output.prores_profile, if channels == Channels::Alpha { Channels::Rgb } else { channels })), }; let brand = if fmt == OutputFormat::H264 { Brand::Mp4 } else { Brand::Mov }; let batch = batch_size(); - let render = |k: u64| -> Vec { rgba8(&render_frame(job, comp, k), comp, channels, w, h) }; + let render = |k: u64| -> Vec { job.pixels(&job.frame(comp, k), comp, channels, w, h) }; // Encode the first batch before creating tracks: encoders finalise their config on frame 1. let first_end = batch.min(st.total); @@ -242,7 +273,8 @@ pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> R drop(first); st.advance(first_end)?; - let mut file = crate::out::create(job.sink, job.path)?; + let path = job.place(job.path, 1); + let mut file = crate::out::create(job.sink, &path)?; let opts = WriterOptions::new(brand); let movie_ts = opts.movie_timescale.max(1) as i128; let mut mux: Writer = Mp4Writer::new(&mut file, opts).map_err(mux_err)?; @@ -257,15 +289,22 @@ pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> R let vt = mux.add_track(vcfg).map_err(mux_err)?; let sr = job.output.audio_sample_rate.clamp(8_000, 192_000); let with_audio = wants_audio(job); + let chans = if job.output.audio_channels == 1 { 1u32 } else { 2 }; + let afmt = job.output.audio_format; let mut audio = None; if with_audio { let (a, entry, start) = if brand == Brand::Mp4 { - let e = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(sr, AUDIO_CHANNELS as usize, 320_000)).map_err(enc)?; - let entry = SampleEntry::aac(e.audio_specific_config(), AUDIO_CHANNELS, sr); + let e = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(sr, chans as usize, 160_000 * chans)).map_err(enc)?; + let entry = SampleEntry::aac(e.audio_specific_config(), chans, sr); let priming = e.priming_samples() as i64; (Audio::Aac(Box::new(e)), entry, Some(priming)) } else { - let pcm = PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: AUDIO_CHANNELS, sample_rate: sr as f64 }; + let (bits, float) = match afmt { + AudioFormat::S16 => (16, false), + AudioFormat::S24 => (24, false), + AudioFormat::F32 => (32, true), + }; + let pcm = PcmConfig { bits, float, big_endian: false, signed: true, channels: chans, sample_rate: sr as f64 }; (Audio::Pcm, SampleEntry::pcm(pcm), None) }; let mut c = TrackConfig::new(entry, sr); @@ -290,21 +329,18 @@ pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> R } let n = (end - *cursor) as usize; let start = Tick(((*cursor as i128 * TICKS_PER_SECOND as i128) / sr as i128) as i64); - let buf = effectcraft_render::audio::mix_comp(job.project, job.footage, job.expr, job.comp, start, n, sr); + let buf = mix(job, start, n, sr); *cursor = end; match a { Audio::Aac(e) => { - let planar = deinterleave(&buf); + let planar = deinterleave(&buf, chans as usize); let refs: Vec<&[f32]> = planar.iter().map(Vec::as_slice).collect(); for au in e.encode(&refs) { mux.write_sample(*at, WriteSample { data: &au, duration: 1024, composition_offset: 0, is_sync: true }).map_err(mux_err)?; } } Audio::Pcm => { - let mut pcm = Vec::with_capacity(n * 4); - for s in buf { - pcm.extend_from_slice(&((s.clamp(-1.0, 1.0) * 32767.0).round() as i16).to_le_bytes()); - } + let pcm = pcm_bytes(&buf, afmt, false); mux.write_sample(*at, WriteSample { data: &pcm, duration: n as u32, composition_offset: 0, is_sync: true }).map_err(mux_err)?; } } @@ -336,7 +372,7 @@ pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> R let out = mux.finish().map_err(mux_err)?; out.flush().map_err(mux_err)?; let bytes = file.finish()?; - Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio }) + Ok(Report { path, frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio, log: None, overflow: vec![] }) } #[cfg(test)] diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index c9196625..74b12c76 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -2,13 +2,21 @@ //! //! | Format | Container / files | Video | Alpha | Audio | //! |---|---|---|---|---| -//! | H.264 | MP4 | FilmCraft H.264 High, 8-bit 4:2:0 BT.709 limited, VBR | no | AAC-LC (FilmCraft), stereo | -//! | ProRes | QuickTime MOV | FilmCraft ProRes 422 Proxy/LT/422/HQ, 10-bit; 4444/4444 XQ 4:4:4 | 4444 (16-bit alpha coding) | 16-bit PCM, stereo | +//! | H.264 | MP4 | FilmCraft H.264 High, 8-bit 4:2:0 BT.709 limited, VBR | no | AAC-LC (FilmCraft), mono/stereo | +//! | ProRes | QuickTime MOV | FilmCraft ProRes 422 Proxy/LT/422/HQ, 10-bit; 4444/4444 XQ 4:4:4 | 4444 (16-bit alpha coding) | PCM 16/24-bit or 32-bit float, mono/stereo | //! | 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 (`effectcraft-vp9enc`: key + inter frames, a key frame every 2 s, loop filter), 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) | +//! | WAV / AIFF | RIFF WAVE / AIFF | — | — | PCM 16/24-bit (WAV also 32-bit float), mono/stereo (audio only) | +//! +//! Every frame goes through [`pipeline`]: the Render Settings overrides (Effects, Solo Switches, +//! Guide Layers, Frame Blending, Motion Blur, Color Depth), field rendering (two fields half a +//! frame apart woven line by line, with optional 3:2 pulldown from film frames at 4/5 of the +//! rate), then the Output Module's Crop (edges or the region of interest) and Resize. Channels +//! may be RGB, RGB + Alpha (straight or premultiplied) or the alpha matte alone. With Use Storage +//! Overflow and a [`StorageQuota`] hook, files that don't fit go to the overflow folders. A +//! render log (Errors Only / Plus Settings / Plus Per Frame Info) is written next to the output. //! //! 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 @@ -20,19 +28,21 @@ mod encode; mod out; +mod pipeline; mod webm; use web_time::Instant; -use effectcraft_project::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, RenderQuality, RenderSettings, sequence_path}; +use effectcraft_project::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, RenderLog, RenderQuality, RenderSettings, sequence_path}; use effectcraft_project::{Comp, ItemId, Project}; use effectcraft_raster::Image; -use effectcraft_render::{ExprHost, FootageSource, RenderOpts, Renderer}; -use effectcraft_time::Tick; +use effectcraft_render::{ExprHost, FootageSource}; use rayon::prelude::*; pub use effectcraft_project::render_queue; +pub use effectcraft_project::render_queue::StorageQuota; pub use out::Sink; +pub(crate) use pipeline::Cx; #[derive(Debug, thiserror::Error)] pub enum ExportError { @@ -69,6 +79,21 @@ pub struct Job<'a> { pub path: &'a str, /// Hand finished files to this instead of writing them to disk (web downloads, tests). pub sink: Option<&'a Sink>, + /// Render log, storage overflow. + pub options: JobOptions<'a>, +} + +/// Job options beyond the Render Settings and Output Module. +#[derive(Default)] +pub struct JobOptions<'a> { + /// The render log written next to the output. + pub log: RenderLog, + /// What the log calls the job (e.g. "#2 Main Comp"). + pub label: String, + /// Free-space hook for Use Storage Overflow (`None`: never full). + pub storage: Option<&'a dyn StorageQuota>, + /// Overflow folders, in order. + pub overflow: Vec, } /// Progress after a batch of frames. @@ -100,6 +125,10 @@ pub struct Report { pub seconds: f64, pub bytes: u64, pub audio: bool, + /// The render log, when one was written. + pub log: Option, + /// Files that went to an overflow folder. + pub overflow: Vec, } /// Formats this build can write (all of them; kept as the hook for optional encoders). @@ -107,10 +136,9 @@ pub fn available_formats() -> Vec { OutputFormat::ALL.to_vec() } -/// The size of the written frames for a job (H.264 rounds to even). +/// The size of the written frames for a job (crop, resize; H.264 rounds to even). pub fn output_size(comp: &Comp, settings: &RenderSettings, output: &OutputModule) -> (u32, u32) { - let (w, h) = settings.output_size(comp); - output.format.coded_size(w, h) + output.output_size(comp, settings) } /// Whether the job writes an audio track. @@ -125,19 +153,30 @@ pub fn wants_audio(job: &Job) -> bool { /// Run the export (blocking). `progress` is called after every batch; returning `false` cancels. pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result { - let comp = job.project.comp(job.comp).ok_or(ExportError::NoComp)?; let t0 = Instant::now(); - let total = job.settings.frame_count(comp); + let cx = Cx::new(job); + let mut r = export_with(&cx, progress, t0); + let log = cx.write_log(&r, t0.elapsed().as_secs_f64()); + if let Ok(rep) = &mut r { + rep.log = log; + rep.overflow = cx.overflowed.lock().unwrap_or_else(|e| e.into_inner()).clone(); + } + r +} + +fn export_with(cx: &Cx, progress: &mut dyn FnMut(&Progress) -> bool, t0: Instant) -> Result { + let comp = cx.comp().ok_or(ExportError::NoComp)?; + let total = cx.settings.frame_count(comp); let mut st = State { t0, total, done: 0, progress }; st.advance(0)?; - let (w, h) = output_size(comp, job.settings, job.output); - 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)?, + let (w, h) = output_size(comp, cx.settings, cx.output); + let mut report = match cx.output.format { + OutputFormat::H264 | OutputFormat::ProRes => encode::movie(cx, comp, w, h, &mut st)?, + OutputFormat::Gif => gif_export(cx, comp, w, h, &mut st)?, + OutputFormat::WebM => webm::webm(cx, comp, w, h, &mut st)?, + OutputFormat::Wav => webm::audio_file(cx, comp, false, &mut st)?, + OutputFormat::Aiff => webm::audio_file(cx, comp, true, &mut st)?, + f if f.is_sequence() => sequence(cx, comp, w, h, &mut st)?, f => return Err(ExportError::Unsupported(f.label().into())), }; report.seconds = t0.elapsed().as_secs_f64(); @@ -170,37 +209,8 @@ pub(crate) fn batch_size() -> u64 { rayon::current_num_threads().clamp(2, 16) as u64 } -/// Render output frame `i` of the job at the settings' resolution. -pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image { - let t: Tick = job.settings.frame_time(comp, i); - let opts = RenderOpts { - scale: job.settings.resolution.clamp(0.01, 4.0), - motion_blur: job.settings.motion_blur, - guides: false, - draft: job.settings.quality == RenderQuality::Draft, - // Output renders always look through the comp's active camera. - view: None, - backend: effectcraft_render::Backend::Auto, - roi: None, - proxy: job.settings.proxy_use, - }; - let mut r = Renderer::new(job.project, job.footage, opts); - r.expr = job.expr; - r.accel = job.accel; - r.comp_frame(job.comp, t) -} - -/// 8-bit RGBA of the requested channels (RGB: over the comp background, opaque; RGBA: straight), -/// cropped/padded to `w`×`h`. -pub(crate) fn rgba8(img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec { - let px = match channels { - Channels::Rgb => img.to_rgba8_over(comp.background), - Channels::Rgba => img.to_rgba8(), - }; - fit(px, img.width, img.height, w, h) -} - -fn fit(px: Vec, iw: u32, ih: u32, w: u32, h: u32) -> Vec { +/// Crop/pad 8-bit RGBA to `w`×`h` (codec rounding). +pub(crate) fn fit(px: Vec, iw: u32, ih: u32, w: u32, h: u32) -> Vec { if iw == w && ih == h { return px; } @@ -217,10 +227,13 @@ fn first_frame_number(job: &Job, comp: &Comp) -> i64 { job.settings.first_frame(comp) } -fn sequence(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result { - let f0 = first_frame_number(job, comp); - let fmt = job.output.format; - let channels = if fmt.supports_alpha() { job.output.channels } else { Channels::Rgb }; +fn sequence(cx: &Cx, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result { + let f0 = first_frame_number(cx, comp); + let fmt = cx.output.format; + let channels = match cx.output.channels { + Channels::Rgba if !fmt.supports_alpha() => Channels::Rgb, + c => c, + }; let batch = batch_size(); let mut bytes = 0u64; let mut i = 0; @@ -229,13 +242,18 @@ fn sequence(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result> = (i..end) .into_par_iter() .map(|k| { - let path = sequence_path(job.path, f0 + k as i64); - if job.settings.skip_existing && out::exists(job.sink, &path) { + let path = sequence_path(cx.path, f0 + k as i64); + if cx.settings.skip_existing && out::exists(cx.sink, &path) { return Ok(0); } - let img = render_frame(job, comp, k); - let mut f = out::create(job.sink, &path)?; - write_still(&mut f, fmt, &img, comp, channels, w, h, job.output.quality)?; + let img = cx.frame(comp, k); + // Encode in memory first: the storage hook decides where it fits. + let mut buf = std::io::Cursor::new(Vec::new()); + write_still(cx, &mut buf, fmt, &img, comp, channels, w, h, cx.output.quality)?; + let data = buf.into_inner(); + let path = cx.place(&path, data.len() as u64); + let mut f = out::create(cx.sink, &path)?; + std::io::Write::write_all(&mut f, &data).map_err(io)?; f.finish() }) .collect(); @@ -245,11 +263,21 @@ fn sequence(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result Result<()> { +fn write_still( + cx: &Cx, + file: &mut W, + fmt: OutputFormat, + img: &Image, + comp: &Comp, + channels: Channels, + w: u32, + h: u32, + quality: u8, +) -> Result<()> { use image::{ExtendedColorType, ImageEncoder, ImageFormat}; match fmt { OutputFormat::ExrSequence => { @@ -260,6 +288,10 @@ fn write_still(file: &mut out::Out, fmt: OutputFormat, img: &Image, comp: &Comp, for x in 0..w { let p = if x < img.width && y < img.height { img.data[img.idx(x, y)] } else { [0.0; 4] }; let (c, a) = match channels { + Channels::Alpha => { + let a = p[3].clamp(0.0, 1.0); + ([lin(a); 3], 1.0) + } Channels::Rgba => { let a = p[3].clamp(0.0, 1.0); let s = |c: f32| if a > 0.0 { lin((c / a).clamp(0.0, 1.0)) * a } else { 0.0 }; @@ -278,16 +310,17 @@ fn write_still(file: &mut out::Out, fmt: OutputFormat, img: &Image, comp: &Comp, image::DynamicImage::ImageRgba32F(buf).write_to(file, ImageFormat::OpenExr).map_err(|e| ExportError::Encode(e.to_string())) } OutputFormat::JpegSequence => { - let px = rgba8(img, comp, Channels::Rgb, w, h); + let px = cx.pixels(img, comp, if channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }, w, h); let rgb: Vec = px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(); image::codecs::jpeg::JpegEncoder::new_with_quality(file, quality.clamp(1, 100)) .write_image(&rgb, w, h, ExtendedColorType::Rgb8) .map_err(|e| ExportError::Encode(e.to_string())) } OutputFormat::PngSequence | OutputFormat::TiffSequence => { - let px = rgba8(img, comp, channels, w, h); + let px = cx.pixels(img, comp, channels, w, h); let (data, ct) = match channels { Channels::Rgba => (px, ExtendedColorType::Rgba8), + Channels::Alpha => (px.chunks_exact(4).map(|p| p[0]).collect(), ExtendedColorType::L8), Channels::Rgb => (px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(), ExtendedColorType::Rgb8), }; if fmt == OutputFormat::PngSequence { @@ -301,11 +334,12 @@ fn write_still(file: &mut out::Out, fmt: OutputFormat, img: &Image, comp: &Comp, } } -fn gif_export(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result { +fn gif_export(job: &Cx, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result { if w > u16::MAX as u32 || h > u16::MAX as u32 { return Err(ExportError::Unsupported("GIF frames are limited to 65535 px".into())); } - let mut file = out::create(job.sink, job.path)?; + let path = job.place(job.path, 1); + let mut file = out::create(job.sink, &path)?; let mut enc = gif::Encoder::new(&mut file, w as u16, h as u16, &[]).map_err(|e| ExportError::Encode(e.to_string()))?; enc.set_repeat(if job.output.gif_loop { gif::Repeat::Infinite } else { gif::Repeat::Finite(0) }).map_err(|e| ExportError::Encode(e.to_string()))?; let rate = job.settings.rate(comp); @@ -320,8 +354,8 @@ fn gif_export(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result< let frames: Vec> = (i..end) .into_par_iter() .map(|k| { - let img = render_frame(job, comp, k); - let mut px = rgba8(&img, comp, job.output.channels, w, h); + let img = job.frame(comp, k); + let mut px = job.pixels(&img, comp, job.output.channels, w, h); gif::Frame::from_rgba_speed(w as u16, h as u16, &mut px, 10) }) .collect(); @@ -340,7 +374,7 @@ fn gif_export(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result< } drop(enc); let bytes = file.finish()?; - Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false }) + Ok(Report { path, frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false, log: None, overflow: vec![] }) } #[cfg(test)] diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs new file mode 100644 index 00000000..76087f2a --- /dev/null +++ b/crates/export/src/pipeline.rs @@ -0,0 +1,327 @@ +//! The frame pipeline shared by every output format: Render Settings overrides (effects, solo, +//! guide layers, frame blending, motion blur), field rendering with 3:2 pulldown, colour depth, +//! the Output Module's crop and resize, alpha handling, storage overflow and the render log. + +use std::sync::Mutex; + +use effectcraft_project::render_queue::{ + AlphaMode, Channels, ColorDepth, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path, +}; +use effectcraft_project::{Comp, Project}; +use effectcraft_raster::Image; +use effectcraft_render::{RenderOpts, Renderer}; +use effectcraft_time::Tick; +use web_time::Instant; + +use crate::{Job, RenderQuality, out}; + +/// A job being exported: the job, the project with the Render Settings overrides applied, and +/// per-frame timing for the render log. +pub(crate) struct Cx<'a> { + pub job: &'a Job<'a>, + pub project: Project, + /// (output frame, seconds) of every rendered frame (Plus Per Frame Info logs). + pub frame_times: Option>>, + /// Files written to an overflow folder. + pub overflowed: Mutex>, +} + +impl<'a> std::ops::Deref for Cx<'a> { + type Target = Job<'a>; + fn deref(&self) -> &Job<'a> { + self.job + } +} + +/// The project as the Render Settings render it: Effects All On / All Off, Solo Switches All +/// Off, Frame Blending and Motion Blur "On for Checked Layers" (the composition switches on). +pub(crate) fn apply_overrides(p: &mut Project, job: &Job) { + let s = job.settings; + let ids: Vec<_> = p.comps().map(|(i, _)| *i).collect(); + for id in ids { + let Some(c) = p.comp_mut(id) else { continue }; + match s.frame_blending { + SwitchOverride::Current => {} + SwitchOverride::OnForChecked => c.enable_frame_blending = true, + SwitchOverride::OffForAll => c.enable_frame_blending = false, + } + match s.motion_blur_override() { + SwitchOverride::Current => {} + SwitchOverride::OnForChecked => c.enable_motion_blur = true, + SwitchOverride::OffForAll => c.enable_motion_blur = false, + } + for l in c.layers.iter_mut() { + match s.effects { + EffectsMode::Current => {} + EffectsMode::AllOn => l.switches.effects = true, + EffectsMode::AllOff => l.switches.effects = false, + } + if s.solo == CurrentOrOff::AllOff { + l.switches.solo = false; + } + } + } +} + +impl<'a> Cx<'a> { + pub fn new(job: &'a Job<'a>) -> Cx<'a> { + let mut project = job.project.clone(); + apply_overrides(&mut project, job); + let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new())); + Cx { job, project, frame_times, overflowed: Mutex::new(vec![]) } + } + + pub fn comp(&self) -> Option<&Comp> { + self.project.comp(self.job.comp) + } + + fn render_at(&self, t: Tick) -> Image { + let s = self.job.settings; + let opts = RenderOpts { + scale: s.resolution.clamp(0.01, 4.0), + motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll, + guides: s.guide_layers == CurrentOrOff::Current, + draft: s.quality == RenderQuality::Draft, + // Output renders always look through the comp's active camera. + view: None, + backend: effectcraft_render::Backend::Auto, + roi: None, + proxy: s.proxy_use, + }; + let mut r = Renderer::new(&self.project, self.job.footage, opts); + r.expr = self.job.expr; + r.accel = self.job.accel; + r.comp_frame(self.job.comp, t) + } + + /// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized. + pub fn frame(&self, comp: &Comp, i: u64) -> Image { + let t0 = Instant::now(); + let img = match self.job.settings.sample(comp, i) { + FrameSample::Progressive(t) => self.render_at(t), + FrameSample::Fields { first, second, upper_first } => { + let a = self.render_at(first); + if second == first { a } else { interleave(&a, &self.render_at(second), upper_first) } + } + }; + let mut img = img; + // Color Depth: renders run at the project's depth; another depth quantises the frame. + let depth = self.job.settings.color_depth; + if depth != ColorDepth::Current + && let Some(levels) = depth.resolve(self.project.settings.bit_depth).levels() + { + img.quantize(levels); + } + let img = self.crop_resize(img); + if let Some(ft) = &self.frame_times { + ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, t0.elapsed().as_secs_f64())); + } + img + } + + /// Output Module ▸ Crop, then Resize. + fn crop_resize(&self, img: Image) -> Image { + let om = self.job.output; + let scale = self.job.settings.resolution.clamp(0.01, 4.0); + let (t, l, b, r) = om.crop.edges(img.width, img.height, scale); + let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) }; + if !om.resize.enabled { + return img; + } + let (w, h) = om.frame_size(img.width, img.height, 1.0); + if (w, h) == (img.width, img.height) { + return img; + } + match om.resize.quality { + ResizeQuality::Low => effectcraft_raster::warp::resample(&img, w, h), + ResizeQuality::High => bicubic(&img, w, h), + } + } + + /// 8-bit RGBA of the requested channels cropped/padded to `w`×`h` (codec rounding): RGB over + /// the comp background (opaque), RGB + Alpha straight or premultiplied, or the alpha matte. + pub fn pixels(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec { + let px = match channels { + Channels::Rgb => img.to_rgba8_over(comp.background), + Channels::Rgba => match self.job.output.alpha_mode { + AlphaMode::Straight => img.to_rgba8(), + AlphaMode::Premultiplied => premultiplied8(img), + }, + Channels::Alpha => img + .data + .iter() + .flat_map(|p| { + let a = (p[3].clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + [a, a, a, 255] + }) + .collect(), + }; + crate::fit(px, img.width, img.height, w, h) + } + + /// Where a file goes: `path`, or (Use Storage Overflow) the first overflow folder with room + /// for `bytes` when the quota hook says the output volume is full. + pub fn place(&self, path: &str, bytes: u64) -> String { + let Some(q) = self.job.options.storage else { return path.to_string() }; + if !self.job.settings.storage_overflow || q.has_room(path, bytes) { + return path.to_string(); + } + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); + for dir in &self.job.options.overflow { + let p = std::path::Path::new(dir).join(&name).to_string_lossy().to_string(); + if q.has_room(&p, bytes) { + self.overflowed.lock().unwrap_or_else(|e| e.into_inner()).push(p.clone()); + return p; + } + } + path.to_string() + } + + /// Write the render log next to the output, as the Log setting asks. Returns its path. + pub fn write_log(&self, result: &Result, seconds: f64) -> Option { + let level = self.job.options.log; + // Errors Only: a log for failures (a user stop is not an error). + if level == RenderLog::ErrorsOnly && matches!(result, Ok(_) | Err(crate::ExportError::Cancelled)) { + return None; + } + let comp = self.comp(); + let path = log_path(self.job.path); + let mut s = String::new(); + s.push_str("EffectCraft Render Log\n"); + s.push_str(&format!("Item: {}\n", if self.job.options.label.is_empty() { "(render)" } else { &self.job.options.label })); + s.push_str(&format!("Output: {}\n", self.job.path)); + match result { + Ok(r) => s.push_str(&format!("Result: Done ({} frames, {}×{}, {} bytes) in {seconds:.2} s\n", r.frames, r.width, r.height, r.bytes)), + Err(e) => s.push_str(&format!("Result: Failed\nError: {e}\n")), + } + let over = self.overflowed.lock().unwrap_or_else(|e| e.into_inner()); + if !over.is_empty() { + s.push_str(&format!("Storage overflow: {} file(s) written to overflow folders (first: {})\n", over.len(), over[0])); + } + if level != RenderLog::ErrorsOnly { + s.push_str("\nRender Settings:\n"); + for (k, v) in self.job.settings.describe(comp) { + s.push_str(&format!(" {k}: {v}\n")); + } + s.push_str("\nOutput Module:\n"); + for (k, v) in self.job.output.describe() { + s.push_str(&format!(" {k}: {v}\n")); + } + } + if let (RenderLog::PlusPerFrameInfo, Some(ft), Some(c)) = (level, &self.frame_times, comp) { + let mut v = ft.lock().unwrap_or_else(|e| e.into_inner()).clone(); + v.sort_by_key(|(i, _)| *i); + s.push_str("\nFrames:\n"); + let f0 = self.job.settings.first_frame(c); + for (i, secs) in v { + let t = self.job.settings.frame_time(c, i); + s.push_str(&format!(" Frame {} ({:.3} s): rendered in {:.1} ms\n", f0 + i as i64, t.seconds(), secs * 1000.0)); + } + } + let mut f = out::create(self.job.sink, &path).ok()?; + std::io::Write::write_all(&mut f, s.as_bytes()).ok()?; + f.finish().ok()?; + Some(path) + } +} + +/// Weave two fields: the dominant field's lines (even lines when `upper_first`) from `first`. +pub(crate) fn interleave(first: &Image, second: &Image, upper_first: bool) -> Image { + let mut out = first.clone(); + let w = out.width as usize; + for y in 0..out.height as usize { + let from_first = (y % 2 == 0) == upper_first; + if !from_first && y < second.height as usize && second.width as usize == w { + out.data[y * w..(y + 1) * w].copy_from_slice(&second.data[y * w..(y + 1) * w]); + } + } + out +} + +/// Remove `t`/`l`/`b`/`r` pixels from the edges (negative values add transparent pixels). +pub(crate) fn crop(img: &Image, t: i32, l: i32, b: i32, r: i32) -> Image { + let w = (img.width as i32 - l - r).max(1) as u32; + let h = (img.height as i32 - t - b).max(1) as u32; + let mut out = Image::new(w, h); + for y in 0..h as i32 { + let sy = y + t; + if sy < 0 || sy >= img.height as i32 { + continue; + } + for x in 0..w as i32 { + let sx = x + l; + if sx >= 0 && sx < img.width as i32 { + let o = out.idx(x as u32, y as u32); + out.data[o] = img.data[img.idx(sx as u32, sy as u32)]; + } + } + } + out +} + +/// Bicubic resize (Resize Quality: High). +fn bicubic(src: &Image, w: u32, h: u32) -> Image { + // Shrinking a lot: bilinear with its box pre-filter first, then bicubic for the last step. + if src.width > w * 2 && src.height > h * 2 { + let mid = effectcraft_raster::warp::resample(src, w * 2, h * 2); + return bicubic(&mid, w, h); + } + let mut out = Image::new(w, h); + let (sx, sy) = (src.width as f64 / w as f64, src.height as f64 / h as f64); + for y in 0..h { + for x in 0..w { + let o = out.idx(x, y); + // Clamp to the edge so borders stay opaque. + let fx = ((x as f64 + 0.5) * sx).clamp(0.5, src.width as f64 - 0.5); + let fy = ((y as f64 + 0.5) * sy).clamp(0.5, src.height as f64 - 0.5); + let mut p = src.sample_bicubic(fx, fy); + p[3] = p[3].min(1.0); + out.data[o] = p; + } + } + out +} + +/// Premultiplied 8-bit RGBA (colour matted with black). +fn premultiplied8(img: &Image) -> Vec { + img.data + .iter() + .flat_map(|p| { + let a = p[3].clamp(0.0, 1.0); + [0, 1, 2].map(|c| (p[c].clamp(0.0, a) * 255.0 + 0.5) as u8).into_iter().chain([(a * 255.0 + 0.5) as u8]) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interleave_takes_alternate_lines() { + let a = Image::filled(3, 4, [1.0, 0.0, 0.0, 1.0]); + let b = Image::filled(3, 4, [0.0, 0.0, 1.0, 1.0]); + let up = interleave(&a, &b, true); + let lo = interleave(&a, &b, false); + for y in 0..4u32 { + assert_eq!(up.data[up.idx(1, y)][0], if y % 2 == 0 { 1.0 } else { 0.0 }, "upper first: line {y}"); + assert_eq!(lo.data[lo.idx(1, y)][0], if y % 2 == 1 { 1.0 } else { 0.0 }, "lower first: line {y}"); + } + } + + #[test] + fn crop_and_pad() { + let mut img = Image::new(4, 3); + for (i, p) in img.data.iter_mut().enumerate() { + *p = [i as f32, 0.0, 0.0, 1.0]; + } + let c = crop(&img, 1, 1, 0, 1); + assert_eq!((c.width, c.height), (2, 2)); + assert_eq!(c.data[0][0], 5.0); + let p = crop(&img, -1, 0, 0, 0); + assert_eq!((p.width, p.height), (4, 4)); + assert_eq!(p.data[0], [0.0; 4]); + assert_eq!(p.data[4][0], 0.0); + assert_eq!(p.data[5][0], 1.0); + } +} diff --git a/crates/export/src/webm.rs b/crates/export/src/webm.rs index 65ca1189..af38eaeb 100644 --- a/crates/export/src/webm.rs +++ b/crates/export/src/webm.rs @@ -15,11 +15,12 @@ use std::io::{Seek, SeekFrom, Write}; use effectcraft_project::Comp; -use effectcraft_project::render_queue::Channels; +use effectcraft_project::render_queue::{AudioFormat, 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}; +use crate::encode::{mix, pcm_bytes}; +use crate::{Cx, Report, Result, State, batch_size, io, wants_audio}; const OPUS_RATE: u32 = 48_000; const OPUS_BITRATE: u32 = 192_000; @@ -124,9 +125,10 @@ fn cluster(frames: &mut [Frame]) -> Vec { out } -pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result { +pub(crate) fn webm(job: &Cx, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result { let rate = job.settings.rate(comp); let alpha = job.output.channels == Channels::Rgba; + let colour_channels = if job.output.channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }; let quality = job.output.quality.clamp(1, 100); let fps = rate.as_f64().max(1.0); let cfg = effectcraft_vp9enc::EncoderConfig { @@ -134,20 +136,22 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re height: h, quality, full_range: false, - keyframe_interval: (fps * 2.0).round().max(1.0) as u32, - target_kbps: None, + keyframe_interval: if job.output.keyframe_interval > 0 { job.output.keyframe_interval } else { (fps * 2.0).round().max(1.0) as u32 }, + target_kbps: job.output.webm_bitrate.then_some(job.output.bitrate_kbps.max(50)), frame_rate: fps, loop_filter: true, }; let mut color_enc = effectcraft_vp9enc::Vp9Encoder::new(cfg.clone()); let mut alpha_enc = alpha.then(|| effectcraft_vp9enc::Vp9Encoder::new(cfg.clone())); let with_audio = wants_audio(job); - let mut opus = with_audio.then(|| effectcraft_opusenc::OpusEncoder::new(2, OPUS_BITRATE)); + let opus_channels = if job.output.audio_channels == 1 { 1usize } else { 2 }; + let mut opus = with_audio.then(|| effectcraft_opusenc::OpusEncoder::new(opus_channels as _, 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)?; + let path = job.place(job.path, 1); + let mut file = crate::out::create(job.sink, &path)?; // EBML header. let mut head = vec![]; let mut eb = vec![]; @@ -201,7 +205,7 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re 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_uint(&mut au, 0x9F, opus_channels as u64); el(&mut a, 0xE1, &au); el(&mut tracks, TRACK_ENTRY, &a); } @@ -220,10 +224,10 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re 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)); + pcm_left.extend(mix(job, start, n, OPUS_RATE)); cursor = end; } - let fs = effectcraft_opusenc::OpusEncoder::FRAME_SIZE * 2; + let fs = effectcraft_opusenc::OpusEncoder::FRAME_SIZE * opus_channels; 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; @@ -244,7 +248,7 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re // Frames render in parallel; the VP9 streams (inter-coded) encode in order, colour and // alpha side by side. let pixels: Vec> = - (i..end).into_par_iter().map(|k| rgba8(&render_frame(job, comp, k), comp, if alpha { Channels::Rgba } else { Channels::Rgb }, w, h)).collect(); + (i..end).into_par_iter().map(|k| job.pixels(&job.frame(comp, k), comp, if alpha { Channels::Rgba } else { colour_channels }, w, h)).collect(); let mut frames: Vec = Vec::with_capacity(pixels.len()); for (j, px) in pixels.iter().enumerate() { let (color, a) = rayon::join( @@ -288,7 +292,7 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re 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 }) + Ok(Report { path, frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio, log: None, overflow: vec![] }) } // ---------------------------------------------------------------- audio-only outputs @@ -306,14 +310,25 @@ fn ieee_extended(v: f64) -> [u8; 10] { 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 { +/// WAV or AIFF: the comp's audio over the render span (Audio Output: sample rate, mono/stereo, +/// 16/24-bit integer or 32-bit float; AIFF has no float in plain AIFF, so it writes 24-bit). +pub(crate) fn audio_file(job: &Cx, comp: &Comp, aiff: bool, st: &mut State) -> Result { let sr = job.output.audio_sample_rate.clamp(8_000, 192_000); + let channels = if job.output.audio_channels == 1 { 1u16 } else { 2 }; + let fmt = match job.output.audio_format { + AudioFormat::F32 if aiff => AudioFormat::S24, + f => f, + }; + let bytes_per_sample: u16 = match fmt { + AudioFormat::S16 => 2, + AudioFormat::S24 => 3, + AudioFormat::F32 => 4, + }; 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); + let mut pcm = Vec::with_capacity(n * (channels * bytes_per_sample) as usize); // Mix in one-second blocks, reporting progress in frames. let block = sr as usize; let mut done = 0usize; @@ -322,10 +337,7 @@ pub(crate) fn audio_file(job: &Job, comp: &Comp, aiff: bool, st: &mut State) -> 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() }); - } + pcm.extend(pcm_bytes(&mix(job, t, k, sr), fmt, aiff)); done += k; let now = (done as u64 * total_frames / n.max(1) as u64).min(total_frames); if now > reported && st.total > 0 { @@ -336,13 +348,14 @@ pub(crate) fn audio_file(job: &Job, comp: &Comp, aiff: bool, st: &mut State) -> if reported < st.total { st.advance(st.total - reported)?; } + let block_align = channels * bytes_per_sample; + let frames = (pcm.len() / block_align as usize) as u32; 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(&channels.to_be_bytes()); comm.extend_from_slice(&frames.to_be_bytes()); - comm.extend_from_slice(&16u16.to_be_bytes()); + comm.extend_from_slice(&(bytes_per_sample * 8).to_be_bytes()); comm.extend_from_slice(&ieee_extended(sr as f64)); let ssnd_len = 8 + pcm.len(); out.extend_from_slice(b"FORM"); @@ -359,24 +372,30 @@ pub(crate) fn audio_file(job: &Job, comp: &Comp, aiff: bool, st: &mut State) -> out.push(0); } } else { + // WAVE_FORMAT_PCM (1) or WAVE_FORMAT_IEEE_FLOAT (3). + let tag: u16 = if fmt == AudioFormat::F32 { 3 } else { 1 }; out.extend_from_slice(b"RIFF"); - out.extend_from_slice(&((36 + pcm.len()) as u32).to_le_bytes()); + out.extend_from_slice(&((36 + pcm.len() + pcm.len() % 2) 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(&tag.to_le_bytes()); + out.extend_from_slice(&channels.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(&(sr * block_align as u32).to_le_bytes()); + out.extend_from_slice(&block_align.to_le_bytes()); + out.extend_from_slice(&(bytes_per_sample * 8).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); + if pcm.len() % 2 == 1 { + out.push(0); + } } - let mut file = crate::out::create(job.sink, job.path)?; + let path = job.place(job.path, out.len() as u64); + let mut file = crate::out::create(job.sink, &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 }) + Ok(Report { path, frames: 0, width: 0, height: 0, seconds: 0.0, bytes, audio: true, log: None, overflow: vec![] }) } #[cfg(test)] diff --git a/crates/export/tests/render_settings.rs b/crates/export/tests/render_settings.rs new file mode 100644 index 00000000..3347efe1 --- /dev/null +++ b/crates/export/tests/render_settings.rs @@ -0,0 +1,310 @@ +//! Render Settings and Output Module features end to end (through a [`Sink`], so nothing touches +//! the disk): field rendering and 3:2 pulldown, crop / region of interest / resize, alpha-only +//! and premultiplied output, the solo and guide-layer overrides, the render log, storage +//! overflow and PCM audio formats. + +use std::sync::{Arc, Mutex}; + +use effectcraft_color::Label; +use effectcraft_export::render_queue::*; +use effectcraft_export::{Job, JobOptions, Report, Sink, StorageQuota, export}; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build}; +use effectcraft_render::NoFootage; +use effectcraft_time::{FrameRate, Tick}; + +const W: u32 = 64; +const H: u32 = 48; + +/// A comp at `rate`, black, with a white bar 8 px wide sweeping left to right (64 px/s) and a +/// static red block at the bottom left; `extra` adds layers. +fn project(rate: FrameRate, extra: impl FnOnce(&mut Project, &mut Comp)) -> (Project, ItemId) { + let mut p = Project::default(); + let mut comp = Comp::new(W, H, rate, Tick::from_seconds_f64(2.0)); + comp.background = [0.0, 0.0, 0.0]; + let bar = p.add_item("Bar", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 8, height: 32, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "Bar", LayerSource::Solid { item: bar }, (8, 32), None); + l.props.prop_mut("transform/position").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([0.0, 16.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([64.0, 16.0, 0.0]))]; + comp.layers.push(l); + let red = p.add_item("Block", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 16, height: 8, pixel_aspect: 1.0 })); + let mut b = build::layer(&mut p, &comp, "Block", LayerSource::Solid { item: red }, (16, 8), None); + b.props.prop_mut("transform/position").unwrap().value = Value::Vec3([8.0, 44.0, 0.0]); + comp.layers.push(b); + extra(&mut p, &mut comp); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + (p, cid) +} + +type Files = Arc)>>>; + +fn custom(start: f64, end: f64) -> RenderSettings { + RenderSettings { time_span: TimeSpan::Custom { start: Tick::from_seconds_f64(start), end: Tick::from_seconds_f64(end) }, ..Default::default() } +} + +fn run_with(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, path: &str, options: JobOptions, cancel: bool) -> (Result, Files) { + let files: Files = Default::default(); + let f = files.clone(); + let sink: Box = Box::new(move |path: &str, data: Vec| f.lock().unwrap().push((path.to_string(), data))); + let job = Job { project: p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: s, output: om, path, sink: Some(&*sink), options }; + let r = export(&job, &mut |pr| !(cancel && pr.done > 0)).map_err(|e| e.to_string()); + (r, files) +} + +fn run(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, path: &str) -> Files { + let (r, files) = run_with(p, cid, s, om, path, JobOptions::default(), false); + r.expect("export"); + files +} + +fn png(files: &Files, i: usize) -> image::RgbaImage { + let f = files.lock().unwrap(); + let pngs: Vec<&(String, Vec)> = f.iter().filter(|(p, _)| p.ends_with(".png")).collect(); + let mut pngs = pngs; + pngs.sort_by(|a, b| a.0.cmp(&b.0)); + image::load_from_memory(&pngs[i].1).expect("png").to_rgba8() +} + +fn row(img: &image::RgbaImage, y: u32) -> Vec { + (0..img.width()).flat_map(|x| img.get_pixel(x, y).0).collect() +} + +#[test] +fn field_render_interleaves_two_times() { + let (p, cid) = project(FrameRate::new(25, 1), |_, _| {}); + let om = OutputModule::for_format(OutputFormat::PngSequence); + // Frame 10 at 25 fps with fields: 0.40 s and 0.42 s. + for (fr, upper) in [(FieldRender::UpperFirst, true), (FieldRender::LowerFirst, false)] { + let s = RenderSettings { field_render: fr, ..custom(0.4, 0.44) }; + let fields = png(&run(&p, cid, &s, &om, "/f/field_[#####].png"), 0); + // Progressive references at 50 fps: frames 20 (0.40 s) and 21 (0.42 s). + let r = RenderSettings { frame_rate: Some(FrameRate::new(50, 1)), ..custom(0.4, 0.44) }; + let refs = run(&p, cid, &r, &om, "/f/ref_[#####].png"); + let (a, b) = (png(&refs, 0), png(&refs, 1)); + assert_ne!(row(&a, 10), row(&b, 10), "the bar moved between the fields"); + for y in 0..H { + let first = (y % 2 == 0) == upper; + assert_eq!(row(&fields, y), row(if first { &a } else { &b }, y), "{fr:?}: line {y}"); + } + } +} + +#[test] +fn pulldown_follows_the_cadence() { + let ntsc = FrameRate::new(30000, 1001); + let (p, cid) = project(ntsc, |_, _| {}); + let om = OutputModule::for_format(OutputFormat::PngSequence); + let five = 5.0 * 1001.0 / 30000.0; + let s = RenderSettings { field_render: FieldRender::UpperFirst, pulldown: Pulldown::Wssww, ..custom(0.0, five) }; + let out = run(&p, cid, &s, &om, "/p/pd_[#####].png"); + assert_eq!(out.lock().unwrap().len(), 5); + // Film frames at 23.976 fps. + let r = RenderSettings { frame_rate: Some(FrameRate::new(24000, 1001)), ..custom(0.0, 0.2) }; + let film = run(&p, cid, &r, &om, "/p/film_[#####].png"); + let mut pattern = String::new(); + for k in 0..5 { + let (a, b) = Pulldown::Wssww.fields(k as u64).unwrap(); + pattern.push(if a == b { 'W' } else { 'S' }); + let img = png(&out, k); + let (fa, fb) = (png(&film, a as usize), png(&film, b as usize)); + for y in 0..H { + assert_eq!(row(&img, y), row(if y % 2 == 0 { &fa } else { &fb }, y), "frame {k} line {y}"); + } + } + assert_eq!(pattern, "WSSWW"); +} + +#[test] +fn crop_roi_and_resize() { + let (p, cid) = project(FrameRate::new(25, 1), |_, _| {}); + let s = custom(0.4, 0.44); + let full = png(&run(&p, cid, &s, &OutputModule::for_format(OutputFormat::PngSequence), "/c/full_[#####].png"), 0); + let mut om = OutputModule::for_format(OutputFormat::PngSequence); + om.crop = Crop { enabled: true, top: 4, left: 10, bottom: 6, right: 2, ..Default::default() }; + let c = png(&run(&p, cid, &s, &om, "/c/crop_[#####].png"), 0); + assert_eq!((c.width(), c.height()), (52, 38)); + for (x, y) in [(0, 0), (20, 11), (51, 37), (3, 30)] { + assert_eq!(c.get_pixel(x, y), full.get_pixel(x + 10, y + 4), "({x}, {y})"); + } + // Region of interest at half resolution. + om.crop = Crop { enabled: true, use_roi: true, roi: Some([16.0, 8.0, 32.0, 32.0]), ..Default::default() }; + let half = RenderSettings { resolution: 0.5, ..s.clone() }; + let r = png(&run(&p, cid, &half, &om, "/c/roi_[#####].png"), 0); + assert_eq!((r.width(), r.height()), (16, 16)); + // Resize (fixed size, low and high quality) after the crop. + om.crop = Crop::default(); + for q in [ResizeQuality::Low, ResizeQuality::High] { + om.resize = Resize { enabled: true, width: 32, height: 20, lock_aspect: false, quality: q }; + let z = png(&run(&p, cid, &s, &om, "/c/resize_[#####].png"), 0); + assert_eq!((z.width(), z.height()), (32, 20)); + // The red block (bottom left) survives the resize. + let px = z.get_pixel(4, 18).0; + assert!(px[0] > 150 && px[1] < 80, "{q:?}: {px:?}"); + } + om.resize.lock_aspect = true; + let z = png(&run(&p, cid, &s, &om, "/c/aspect_[#####].png"), 0); + assert_eq!((z.width(), z.height()), (32, 24)); +} + +#[test] +fn alpha_only_and_premultiplied() { + let (p, cid) = project(FrameRate::new(25, 1), |p, comp| { + // A half-transparent green layer over the right half. + let g = p.add_item("Green", Label::Green, None, ItemKind::Solid(Solid { color: [0.0, 1.0, 0.0], width: 32, height: 48, pixel_aspect: 1.0 })); + let mut l = build::layer(p, comp, "Green", LayerSource::Solid { item: g }, (32, 48), None); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([48.0, 24.0, 0.0]); + l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0); + comp.layers.insert(0, l); + }); + let s = custom(0.0, 0.04); + let mut om = OutputModule::for_format(OutputFormat::PngSequence); + om.channels = Channels::Alpha; + let a = png(&run(&p, cid, &s, &om, "/a/alpha_[#####].png"), 0); + assert_eq!(a.get_pixel(60, 2).0[0], 128, "half-opaque green"); + assert_eq!(a.get_pixel(20, 2).0[0], 0, "transparent"); + assert_eq!(a.get_pixel(8, 44).0[0], 255, "opaque block"); + om.channels = Channels::Rgba; + let straight = png(&run(&p, cid, &s, &om, "/a/s_[#####].png"), 0); + om.alpha_mode = AlphaMode::Premultiplied; + let premul = png(&run(&p, cid, &s, &om, "/a/p_[#####].png"), 0); + assert_eq!(straight.get_pixel(60, 2).0, [0, 255, 0, 128]); + assert_eq!(premul.get_pixel(60, 2).0, [0, 128, 0, 128]); +} + +#[test] +fn solo_and_guide_layer_overrides() { + let (p, cid) = project(FrameRate::new(25, 1), |p, comp| { + let g = p.add_item("Guide", Label::Green, None, ItemKind::Solid(Solid { color: [0.0, 0.0, 1.0], width: 8, height: 8, pixel_aspect: 1.0 })); + let mut l = build::layer(p, comp, "Guide", LayerSource::Solid { item: g }, (8, 8), None); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([56.0, 44.0, 0.0]); + l.switches.guide = true; + comp.layers.insert(0, l); + // Solo the moving bar. + comp.layers[1].switches.solo = true; + }); + let om = OutputModule::for_format(OutputFormat::PngSequence); + let s = custom(0.4, 0.44); + let cur = png(&run(&p, cid, &s, &om, "/s/cur_[#####].png"), 0); + assert_eq!(cur.get_pixel(8, 44).0[0], 0, "solo: the red block is hidden"); + assert_eq!(cur.get_pixel(56, 44).0[2], 0, "guide layers off by default"); + let off = RenderSettings { solo: CurrentOrOff::AllOff, guide_layers: CurrentOrOff::Current, ..s.clone() }; + let all = png(&run(&p, cid, &off, &om, "/s/off_[#####].png"), 0); + assert_eq!(all.get_pixel(8, 44).0[0], 255, "Solo Switches: All Off"); + // (with solo off the guide renders too: Guide Layers: Current Settings) + assert_eq!(all.get_pixel(56, 44).0[2], 255); +} + +#[test] +fn render_logs() { + let (p, cid) = project(FrameRate::new(25, 1), |_, _| {}); + let om = OutputModule::for_format(OutputFormat::PngSequence); + let s = RenderSettings { field_render: FieldRender::UpperFirst, name: "DV-ish".into(), ..custom(0.0, 0.2) }; + let log_of = |files: &Files| { + files.lock().unwrap().iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(p, d)| (p.clone(), String::from_utf8(d.clone()).unwrap())) + }; + // Errors Only + success: no log. + let (r, files) = run_with(&p, cid, &s, &om, "/l/a_[#####].png", JobOptions::default(), false); + assert!(r.unwrap().log.is_none()); + assert!(log_of(&files).is_none()); + // Plus Per Frame Info: settings and one line per frame. + let opts = JobOptions { log: RenderLog::PlusPerFrameInfo, label: "#1 Comp".into(), ..Default::default() }; + let (r, files) = run_with(&p, cid, &s, &om, "/l/b_[#####].png", opts, false); + let r = r.unwrap(); + let (path, text) = log_of(&files).expect("log"); + assert_eq!(r.log.as_deref(), Some(path.as_str())); + assert_eq!(path, "/l/b_RenderLog.txt"); + assert!(text.contains("Item: #1 Comp") && text.contains("Result: Done (5 frames"), "{text}"); + assert!(text.contains("Field Render: Upper Field First") && text.contains("Template: DV-ish") && text.contains("Format: PNG Sequence"), "{text}"); + assert_eq!(text.lines().filter(|l| l.contains("rendered in")).count(), 5, "{text}"); + assert!(text.contains("Frame 0 (0.000 s): rendered in") && text.contains("Frame 4 (0.160 s)"), "{text}"); + // Plus Settings: no per-frame lines. + let opts = JobOptions { log: RenderLog::PlusSettings, ..Default::default() }; + let (_, files) = run_with(&p, cid, &s, &om, "/l/c.gif", opts, false); + let (_, text) = log_of(&files).expect("log"); + assert!(text.contains("Render Settings:") && !text.contains("rendered in"), "{text}"); + // Errors Only + failure (the composition is gone): the error is logged. + let gif = OutputModule::for_format(OutputFormat::Gif); + let (r, files) = run_with(&p, ItemId(424_242), &s, &gif, "/l/d.gif", JobOptions::default(), false); + assert!(r.is_err()); + let (path, text) = log_of(&files).expect("error log"); + assert_eq!(path, "/l/d_RenderLog.txt"); + assert!(text.contains("Result: Failed") && text.contains("Error: composition not found") && !text.contains("Render Settings:"), "{text}"); + // A user stop is not an error: no log. + let (r, files) = run_with(&p, cid, &s, &gif, "/l/e.gif", JobOptions::default(), true); + assert!(r.is_err()); + assert!(log_of(&files).is_none()); +} + +/// The primary folder holds `limit` bytes; overflow folders are unlimited. +struct Quota { + limit: u64, + used: Mutex, +} + +impl StorageQuota for Quota { + fn has_room(&self, path: &str, bytes: u64) -> bool { + if !path.starts_with("/primary/") { + return true; + } + let mut u = self.used.lock().unwrap(); + if *u + bytes > self.limit { + return false; + } + *u += bytes; + true + } +} + +#[test] +fn storage_overflow_moves_files_to_the_overflow_folder() { + let (p, cid) = project(FrameRate::new(25, 1), |_, _| {}); + let om = OutputModule::for_format(OutputFormat::PngSequence); + let s = RenderSettings { skip_existing: false, ..custom(0.0, 0.4) }; + let q = Quota { limit: 1, used: Mutex::new(0) }; + let opts = JobOptions { storage: Some(&q), overflow: vec!["/overflow".into()], log: RenderLog::PlusSettings, ..Default::default() }; + let (r, files) = run_with(&p, cid, &s, &om, "/primary/seq_[#####].png", opts, false); + let r = r.unwrap(); + assert_eq!(r.overflow.len(), 10, "{:?}", r.overflow); + let f = files.lock().unwrap(); + assert_eq!(f.iter().filter(|(p, _)| p.starts_with("/overflow/seq_") && p.ends_with(".png")).count(), 10); + let log = f.iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(_, d)| String::from_utf8_lossy(d).to_string()).unwrap(); + assert!(log.contains("Storage overflow: 10 file(s)"), "{log}"); + drop(f); + // Use Storage Overflow off: everything stays in place. + let off = RenderSettings { storage_overflow: false, ..s }; + let q = Quota { limit: 1, used: Mutex::new(0) }; + let opts = JobOptions { storage: Some(&q), overflow: vec!["/overflow".into()], ..Default::default() }; + let (r, files) = run_with(&p, cid, &off, &om, "/primary/seq_[#####].png", opts, false); + assert!(r.unwrap().overflow.is_empty()); + assert!(files.lock().unwrap().iter().all(|(p, _)| p.starts_with("/primary/"))); +} + +#[test] +fn wav_and_aiff_channels_and_formats() { + let (p, cid) = project(FrameRate::new(25, 1), |_, _| {}); + let s = custom(0.0, 0.5); + let mut om = OutputModule::for_format(OutputFormat::Wav); + om.audio_channels = 1; + om.audio_format = AudioFormat::S24; + om.audio_sample_rate = 44_100; + let files = run(&p, cid, &s, &om, "/w/a.wav"); + let d = files.lock().unwrap()[0].1.clone(); + assert_eq!(&d[..4], b"RIFF"); + let u16_at = |o: usize| u16::from_le_bytes([d[o], d[o + 1]]); + let u32_at = |o: usize| u32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]); + assert_eq!((u16_at(20), u16_at(22), u32_at(24), u16_at(32), u16_at(34)), (1, 1, 44_100, 3, 24)); + assert_eq!(u32_at(40), 22_050 * 3, "half a second of mono 24-bit"); + om.audio_channels = 2; + om.audio_format = AudioFormat::F32; + let files = run(&p, cid, &s, &om, "/w/b.wav"); + let d = files.lock().unwrap()[0].1.clone(); + let u16_at = |o: usize| u16::from_le_bytes([d[o], d[o + 1]]); + assert_eq!((u16_at(20), u16_at(22), u16_at(32), u16_at(34)), (3, 2, 8, 32), "IEEE float stereo"); + // AIFF has no float: 24-bit. + let mut aiff = OutputModule::for_format(OutputFormat::Aiff); + aiff.audio_format = AudioFormat::F32; + let files = run(&p, cid, &s, &aiff, "/w/c.aif"); + let d = files.lock().unwrap()[0].1.clone(); + assert_eq!(&d[8..12], b"AIFF"); + assert_eq!(u16::from_be_bytes([d[26], d[27]]), 24, "sample size"); +} diff --git a/crates/export/tests/roundtrip.rs b/crates/export/tests/roundtrip.rs index 1ade594f..c6810cb0 100644 --- a/crates/export/tests/roundtrip.rs +++ b/crates/export/tests/roundtrip.rs @@ -55,7 +55,7 @@ fn settings() -> RenderSettings { fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report { let s = settings(); let path = path.to_string_lossy().to_string(); - let job = Job { project: p, footage, expr: None, accel: None, comp: cid, settings: &s, output: om, path: &path, sink: None }; + let job = Job { project: p, footage, expr: None, accel: None, comp: cid, settings: &s, output: om, path: &path, sink: None, options: Default::default() }; let mut last = Progress::default(); let mut calls = 0; let r = export(&job, &mut |pr| { @@ -167,7 +167,18 @@ fn h264_odd_size_is_cropped_even() { let comp = p.comp(cid).expect("comp"); assert_eq!(s.output_size(comp).0 % 2, 1); let path = d.join("odd.mp4").to_string_lossy().to_string(); - let job = Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None }; + let job = Job { + project: &p, + footage: &NoFootage, + expr: None, + accel: None, + comp: cid, + settings: &s, + output: &om, + path: &path, + sink: None, + options: Default::default(), + }; let r = export(&job, &mut |_| true).expect("export"); assert_eq!((r.width % 2, r.height % 2), (0, 0)); } @@ -287,7 +298,18 @@ fn cancel_stops_early() { let om = OutputModule::for_format(OutputFormat::PngSequence); let s = settings(); let path = d.join("c_[#####].png").to_string_lossy().to_string(); - let job = Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None }; + let job = Job { + project: &p, + footage: &NoFootage, + expr: None, + accel: None, + comp: cid, + settings: &s, + output: &om, + path: &path, + sink: None, + options: Default::default(), + }; let r = export(&job, &mut |_| false); assert!(matches!(r, Err(effectcraft_export::ExportError::Cancelled))); let written = std::fs::read_dir(&d).map(|r| r.count()).unwrap_or(0); diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index b5de7714..4161abc7 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -40,9 +40,25 @@ impl Exporter for FileExporter { output: &job.item.output, path: job.path, sink: self.sink.as_deref(), + options: effectcraft_export::JobOptions { + log: job.item.log, + label: job.label.clone(), + storage: job.storage, + overflow: job.project.render_prefs.overflow_folders.clone(), + }, }; match effectcraft_export::export(&j, &mut |p| progress(p.done, p.total)) { - Ok(r) => Ok(ExportResult { path: r.path, frames: r.frames, width: r.width, height: r.height, bytes: r.bytes, seconds: r.seconds, audio: r.audio }), + Ok(r) => Ok(ExportResult { + path: r.path, + frames: r.frames, + width: r.width, + height: r.height, + bytes: r.bytes, + seconds: r.seconds, + audio: r.audio, + log: r.log, + overflow: r.overflow, + }), Err(effectcraft_export::ExportError::Cancelled) => Err(effectcraft_engine::render_queue::CANCELLED.into()), Err(e) => Err(e.to_string()), } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 470c4edc..bf8fcc2a 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -8,6 +8,7 @@ pub mod build; pub mod essential; pub mod props; pub mod render_queue; +pub mod render_templates; pub mod styles; pub mod tracking; @@ -931,11 +932,25 @@ pub struct Project { /// The Render Queue (Composition ▸ Add to Render Queue). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub render_queue: Vec, + /// Render Settings / Output Module templates and their defaults. + #[serde(default, skip_serializing_if = "render_templates::RenderTemplates::is_default")] + pub render_templates: render_templates::RenderTemplates, + /// Render Queue preferences (Notify, storage overflow folders). + #[serde(default, skip_serializing_if = "render_queue::RenderQueuePrefs::is_default")] + pub render_prefs: render_queue::RenderQueuePrefs, } impl Default for Project { fn default() -> Self { - Project { schema: SCHEMA_VERSION, settings: ProjectSettings::default(), items: BTreeMap::new(), next_id: 1, render_queue: Vec::new() } + Project { + schema: SCHEMA_VERSION, + settings: ProjectSettings::default(), + items: BTreeMap::new(), + next_id: 1, + render_queue: Vec::new(), + render_templates: Default::default(), + render_prefs: Default::default(), + } } } diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index d0f0c91f..35aed0be 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -84,38 +84,329 @@ impl ProxyUse { } } +/// Render Settings ▸ Field Render: interlaced output, each frame woven from two fields rendered +/// half a frame apart. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum FieldRender { + #[default] + Off, + /// The upper field (lines 0, 2, 4…) holds the earlier time. + UpperFirst, + LowerFirst, +} + +impl FieldRender { + pub const ALL: [FieldRender; 3] = [FieldRender::Off, FieldRender::UpperFirst, FieldRender::LowerFirst]; + pub fn label(self) -> &'static str { + match self { + FieldRender::Off => "Off", + FieldRender::UpperFirst => "Upper Field First", + FieldRender::LowerFirst => "Lower Field First", + } + } + pub fn parse(s: &str) -> Option { + match norm(s).as_str() { + "off" | "none" | "progressive" => Some(FieldRender::Off), + "upper" | "upperfirst" | "upperfieldfirst" => Some(FieldRender::UpperFirst), + "lower" | "lowerfirst" | "lowerfieldfirst" => Some(FieldRender::LowerFirst), + _ => None, + } + } +} + +fn norm(s: &str) -> String { + s.to_ascii_lowercase().replace([' ', '_', '-', '.', ':', '&'], "") +} + +/// Render Settings ▸ 3:2 Pulldown: with field rendering, four film frames (at 4/5 of the output +/// rate) spread over five interlaced frames in 2:3 cadence. The phase names which of the five +/// frames are Whole (both fields from one film frame) or Split. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Pulldown { + #[default] + Off, + Wssww, + Sswww, + Swwws, + Wwwss, + Wwssw, +} + +/// Film frame (within a group of four) of each of the ten fields of the 2:3 cadence. +const CADENCE: [i64; 10] = [0, 0, 1, 1, 1, 2, 2, 3, 3, 3]; + +impl Pulldown { + pub const ALL: [Pulldown; 6] = [Pulldown::Off, Pulldown::Wssww, Pulldown::Sswww, Pulldown::Swwws, Pulldown::Wwwss, Pulldown::Wwssw]; + pub fn label(self) -> &'static str { + match self { + Pulldown::Off => "Off", + Pulldown::Wssww => "WSSWW", + Pulldown::Sswww => "SSWWW", + Pulldown::Swwws => "SWWWS", + Pulldown::Wwwss => "WWWSS", + Pulldown::Wwssw => "WWSSW", + } + } + pub fn parse(s: &str) -> Option { + let n = norm(s); + if matches!(n.as_str(), "off" | "none") { + return Some(Pulldown::Off); + } + Pulldown::ALL.into_iter().find(|p| p.label().eq_ignore_ascii_case(&n)) + } + /// Offset (in frames) into the WWSSW base cadence. + fn rotation(self) -> Option { + Some(match self { + Pulldown::Off => return None, + Pulldown::Wwssw => 0, + Pulldown::Wssww => 1, + Pulldown::Sswww => 2, + Pulldown::Swwws => 3, + Pulldown::Wwwss => 4, + }) + } + /// The film frames (counted from the first film frame of the render) of the first and + /// second field of output frame `k`. + pub fn fields(self, k: u64) -> Option<(i64, i64)> { + let r = self.rotation()?; + let film = |f: i64| f.div_euclid(10) * 4 + CADENCE[f.rem_euclid(10) as usize]; + let f0 = 2 * (k as i64 + r); + let base = film(2 * r); + Some((film(f0) - base, film(f0 + 1) - base)) + } + /// The W/S pattern of five consecutive output frames. + pub fn pattern(self) -> String { + (0..5).filter_map(|k| self.fields(k).map(|(a, b)| if a == b { 'W' } else { 'S' })).collect() + } +} + +/// A layer switch override (Render Settings ▸ Frame Blending / Motion Blur). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum SwitchOverride { + /// The layer switches and the composition's enable switch. + #[default] + Current, + /// Every layer whose switch is on, whatever the composition's enable switch. + OnForChecked, + OffForAll, +} + +impl SwitchOverride { + pub const ALL: [SwitchOverride; 3] = [SwitchOverride::Current, SwitchOverride::OnForChecked, SwitchOverride::OffForAll]; + pub fn label(self) -> &'static str { + match self { + SwitchOverride::Current => "Current Settings", + SwitchOverride::OnForChecked => "On for Checked Layers", + SwitchOverride::OffForAll => "Off for All Layers", + } + } + pub fn parse(s: &str) -> Option { + match norm(s).as_str() { + "current" | "currentsettings" => Some(SwitchOverride::Current), + "on" | "onforchecked" | "onforcheckedlayers" | "true" => Some(SwitchOverride::OnForChecked), + "off" | "offforall" | "offforalllayers" | "false" => Some(SwitchOverride::OffForAll), + _ => None, + } + } +} + +/// Render Settings ▸ Effects. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum EffectsMode { + #[default] + Current, + AllOn, + AllOff, +} + +impl EffectsMode { + pub const ALL: [EffectsMode; 3] = [EffectsMode::Current, EffectsMode::AllOn, EffectsMode::AllOff]; + pub fn label(self) -> &'static str { + match self { + EffectsMode::Current => "Current Settings", + EffectsMode::AllOn => "All On", + EffectsMode::AllOff => "All Off", + } + } + pub fn parse(s: &str) -> Option { + match norm(s).as_str() { + "current" | "currentsettings" => Some(EffectsMode::Current), + "allon" | "on" => Some(EffectsMode::AllOn), + "alloff" | "off" => Some(EffectsMode::AllOff), + _ => None, + } + } +} + +/// Render Settings ▸ Solo Switches / Guide Layers: the current switches, or all off. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum CurrentOrOff { + #[default] + Current, + AllOff, +} + +impl CurrentOrOff { + pub fn label(self) -> &'static str { + match self { + CurrentOrOff::Current => "Current Settings", + CurrentOrOff::AllOff => "All Off", + } + } + pub fn parse(s: &str) -> Option { + match norm(s).as_str() { + "current" | "currentsettings" | "on" => Some(CurrentOrOff::Current), + "alloff" | "off" => Some(CurrentOrOff::AllOff), + _ => None, + } + } +} + +/// Render Settings ▸ Color Depth. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ColorDepth { + /// The project's bit depth. + #[default] + Current, + Bpc8, + Bpc16, + Bpc32, +} + +impl ColorDepth { + pub const ALL: [ColorDepth; 4] = [ColorDepth::Current, ColorDepth::Bpc8, ColorDepth::Bpc16, ColorDepth::Bpc32]; + pub fn label(self) -> &'static str { + match self { + ColorDepth::Current => "Current Settings", + ColorDepth::Bpc8 => "8 bits per channel", + ColorDepth::Bpc16 => "16 bits per channel", + ColorDepth::Bpc32 => "32 bits per channel", + } + } + pub fn parse(s: &str) -> Option { + match norm(s).as_str() { + "current" | "currentsettings" => Some(ColorDepth::Current), + "8" | "8bpc" | "8bitsperchannel" => Some(ColorDepth::Bpc8), + "16" | "16bpc" | "16bitsperchannel" => Some(ColorDepth::Bpc16), + "32" | "32bpc" | "32bitsperchannel" | "float" => Some(ColorDepth::Bpc32), + _ => None, + } + } + /// The depth renders use in a project of depth `project`. + pub fn resolve(self, project: crate::BitDepth) -> crate::BitDepth { + match self { + ColorDepth::Current => project, + ColorDepth::Bpc8 => crate::BitDepth::Bpc8, + ColorDepth::Bpc16 => crate::BitDepth::Bpc16, + ColorDepth::Bpc32 => crate::BitDepth::Bpc32, + } + } +} + +/// How one output frame is sampled in time. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FrameSample { + Progressive(Tick), + /// Two fields woven together: `first` goes to the dominant field. + Fields { + first: Tick, + second: Tick, + upper_first: bool, + }, +} + +fn default_true() -> bool { + true +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct RenderSettings { + /// The template these settings came from ("" or "Custom" once edited). + #[serde(skip_serializing_if = "String::is_empty")] + pub name: String, pub quality: RenderQuality, /// Output scale (1 = Full, 0.5 = Half, 1/3 = Third, 0.25 = Quarter). pub resolution: f64, pub time_span: TimeSpan, - /// `None` = use the comp's frame rate. + /// `None` = use the comp's frame rate (Time Sampling ▸ Use comp's frame rate). pub frame_rate: Option, - /// Motion blur for layers with the switch on (and the comp's Enable Motion Blur). + /// Motion blur allowed at all (false = Off for All Layers; see `motion_blur_mode`). pub motion_blur: bool, + /// Motion Blur: Current Settings / On for Checked Layers / Off for All Layers. + pub motion_blur_mode: SwitchOverride, + /// Frame Blending: Current Settings / On for Checked Layers / Off for All Layers. + pub frame_blending: SwitchOverride, + /// Field Render. + pub field_render: FieldRender, + /// 3:2 Pulldown phase (with field rendering). + pub pulldown: Pulldown, + /// Effects: Current Settings / All On / All Off. + pub effects: EffectsMode, + /// Solo Switches: Current Settings / All Off. + pub solo: CurrentOrOff, + /// Guide Layers: Current Settings / All Off. + pub guide_layers: CurrentOrOff, + /// Color Depth. + pub color_depth: ColorDepth, /// Image sequences: skip frames whose file already exists. pub skip_existing: bool, /// Proxy Use (Best Settings: Use No Proxies). pub proxy_use: ProxyUse, + /// Use Storage Overflow: when the output volume is full, continue in the project's overflow + /// folders. + #[serde(default = "default_true")] + pub storage_overflow: bool, } impl Default for RenderSettings { fn default() -> Self { RenderSettings { + name: String::new(), quality: RenderQuality::Best, resolution: 1.0, time_span: TimeSpan::WorkArea, frame_rate: None, motion_blur: true, + motion_blur_mode: SwitchOverride::Current, + frame_blending: SwitchOverride::Current, + field_render: FieldRender::Off, + pulldown: Pulldown::Off, + effects: EffectsMode::Current, + solo: CurrentOrOff::Current, + guide_layers: CurrentOrOff::AllOff, + color_depth: ColorDepth::Current, skip_existing: false, proxy_use: ProxyUse::UseNone, + storage_overflow: true, } } } impl RenderSettings { + /// Effective Motion Blur override. + pub fn motion_blur_override(&self) -> SwitchOverride { + if self.motion_blur { self.motion_blur_mode } else { SwitchOverride::OffForAll } + } + /// How output frame `i` is sampled (field rendering and 3:2 pulldown). + pub fn sample(&self, comp: &Comp, i: u64) -> FrameSample { + let upper_first = match self.field_render { + FieldRender::Off => return FrameSample::Progressive(self.frame_time(comp, i)), + FieldRender::UpperFirst => true, + FieldRender::LowerFirst => false, + }; + let r = self.rate(comp); + if let Some((a, b)) = self.pulldown.fields(i) { + // Film frames at 4/5 of the output rate, from the first one starting in the span. + let film = FrameRate::new(r.num * 4, r.den * 5); + let f0 = Self::ceil_frame(film, self.span(comp).0); + return FrameSample::Fields { first: film.tick_of(f0 + a), second: film.tick_of(f0 + b), upper_first }; + } + let f = self.first_frame(comp) + i as i64; + let t = r.tick_of(f); + let half = Tick((r.tick_of(f + 1).0 - t.0) / 2); + FrameSample::Fields { first: t, second: Tick(t.0 + half.0), upper_first } + } /// The comp-time span `[start, end)` to render. pub fn span(&self, comp: &Comp) -> (Tick, Tick) { let (a, b) = match self.time_span { @@ -162,11 +453,45 @@ impl RenderSettings { _ => "Custom", } } - /// One-line summary shown next to "Render Settings:" (AE shows the template name). + /// One-line summary shown next to "Render Settings:" (the template name, like AE). pub fn summary(&self) -> String { + if !self.name.is_empty() { + return self.name.clone(); + } let q = if self.quality == RenderQuality::Best { "Best Settings" } else { "Draft Settings" }; format!("{q} · {}", self.resolution_label()) } + /// Every setting as `(label, value)` lines (render logs, the settings dialog). + pub fn describe(&self, comp: Option<&Comp>) -> Vec<(&'static str, String)> { + let span = match self.time_span { + TimeSpan::WorkArea => "Work Area Only".to_string(), + TimeSpan::LengthOfComp => "Length of Comp".to_string(), + TimeSpan::Custom { start, end } => format!("Custom ({:.3}s – {:.3}s)", start.seconds(), end.seconds()), + }; + let rate = match (self.frame_rate, comp) { + (Some(r), _) => format!("{:.3} fps", r.as_f64()), + (None, Some(c)) => format!("Use comp's frame rate ({:.3})", c.frame_rate.as_f64()), + (None, None) => "Use comp's frame rate".into(), + }; + vec![ + ("Template", if self.name.is_empty() { "Custom".into() } else { self.name.clone() }), + ("Quality", format!("{:?}", self.quality)), + ("Resolution", format!("{} ({:.3})", self.resolution_label(), self.resolution)), + ("Proxy Use", self.proxy_use.label().into()), + ("Effects", self.effects.label().into()), + ("Solo Switches", self.solo.label().into()), + ("Guide Layers", self.guide_layers.label().into()), + ("Color Depth", self.color_depth.label().into()), + ("Frame Blending", self.frame_blending.label().into()), + ("Field Render", self.field_render.label().into()), + ("3:2 Pulldown", self.pulldown.label().into()), + ("Motion Blur", self.motion_blur_override().label().into()), + ("Time Span", span), + ("Frame Rate", rate), + ("Skip Existing Files", if self.skip_existing { "On" } else { "Off" }.into()), + ("Use Storage Overflow", if self.storage_overflow { "On" } else { "Off" }.into()), + ] + } } /// Output Module ▸ Format. @@ -287,8 +612,30 @@ pub enum Channels { /// Opaque, composited over the comp background colour. #[default] Rgb, - /// With straight (unmatted) alpha. + /// With alpha (straight or premultiplied: [`AlphaMode`]). Rgba, + /// The alpha channel alone, as an opaque greyscale matte. + Alpha, +} + +impl Channels { + pub fn label(self) -> &'static str { + match self { + Channels::Rgb => "RGB", + Channels::Rgba => "RGB + Alpha", + Channels::Alpha => "Alpha", + } + } +} + +/// Output Module ▸ Color: how RGB is stored with alpha. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AlphaMode { + /// Unmatted colour. + #[default] + Straight, + /// Colour multiplied by alpha (matted with black). + Premultiplied, } /// Output Module ▸ Audio Output. @@ -301,6 +648,121 @@ pub enum AudioOutput { Off, } +/// Output Module ▸ Audio Output ▸ sample format (PCM outputs: WAV, AIFF, QuickTime). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AudioFormat { + #[default] + S16, + S24, + /// 32-bit float (WAV, QuickTime; AIFF writes 24-bit). + F32, +} + +impl AudioFormat { + pub fn label(self) -> &'static str { + match self { + AudioFormat::S16 => "16 Bit", + AudioFormat::S24 => "24 Bit", + AudioFormat::F32 => "32 Bit Float", + } + } + pub fn parse(s: &str) -> Option { + match norm(s).as_str() { + "16" | "16bit" | "s16" => Some(AudioFormat::S16), + "24" | "24bit" | "s24" => Some(AudioFormat::S24), + "32" | "32bit" | "32bitfloat" | "f32" | "float" => Some(AudioFormat::F32), + _ => None, + } + } +} + +/// Output Module ▸ Crop (pixels of the rendered frame; negative values add transparent pixels). +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Crop { + pub enabled: bool, + /// Use Region of Interest: crop to `roi` (comp pixels, captured from the viewer's region of + /// interest when the option is turned on) instead of the edge values. + pub use_roi: bool, + pub roi: Option<[f64; 4]>, + pub top: i32, + pub left: i32, + pub bottom: i32, + pub right: i32, +} + +impl Crop { + /// Edges `(top, left, bottom, right)` in pixels of a `w`×`h` frame rendered at `scale`. + pub fn edges(&self, w: u32, h: u32, scale: f64) -> (i32, i32, i32, i32) { + if !self.enabled { + return (0, 0, 0, 0); + } + if self.use_roi + && let Some([x, y, rw, rh]) = self.roi + { + let l = (x * scale).round() as i32; + let t = (y * scale).round() as i32; + let r = w as i32 - ((x + rw) * scale).round() as i32; + let b = h as i32 - ((y + rh) * scale).round() as i32; + return (t, l, b, r); + } + (self.top, self.left, self.bottom, self.right) + } +} + +/// Output Module ▸ Resize quality. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ResizeQuality { + /// Bilinear. + Low, + /// Bicubic. + #[default] + High, +} + +/// Output Module ▸ Resize. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Resize { + pub enabled: bool, + pub width: u32, + pub height: u32, + /// Lock Aspect Ratio: setting one dimension derives the other from the rendered frame. + pub lock_aspect: bool, + pub quality: ResizeQuality, +} + +impl Default for Resize { + fn default() -> Self { + Resize { enabled: false, width: 1920, height: 1080, lock_aspect: true, quality: ResizeQuality::High } + } +} + +/// Output Module ▸ Resize ▸ Resize to: presets. +pub const RESIZE_PRESETS: [(&str, u32, u32); 10] = [ + ("HDTV 1080", 1920, 1080), + ("HDTV 720", 1280, 720), + ("UHD 4K", 3840, 2160), + ("DCI 2K", 2048, 1080), + ("DCI 4K", 4096, 2160), + ("NTSC DV", 720, 480), + ("PAL D1/DV", 720, 576), + ("Square 1080", 1080, 1080), + ("Vertical 1080×1920", 1080, 1920), + ("Web 640×360", 640, 360), +]; + +/// Output Module ▸ Post-Render Action. +pub fn post_render_parse(s: &str) -> Option { + match norm(s).as_str() { + "none" => Some(PostRenderAction::None), + "import" => Some(PostRenderAction::Import), + "importreplace" | "importandreplace" | "importandreplaceusage" | "importreplaceusage" | "replace" => Some(PostRenderAction::ImportAndReplace), + "setproxy" | "proxy" => Some(PostRenderAction::SetProxy), + _ => None, + } +} + /// ProRes flavour. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum ProResProfile { @@ -341,38 +803,133 @@ impl ProResProfile { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct OutputModule { + /// The template this module came from ("" or "Custom" once edited). + #[serde(skip_serializing_if = "String::is_empty")] + pub name: String, pub format: OutputFormat, pub channels: Channels, + /// Color: straight or premultiplied RGB with alpha. + pub alpha_mode: AlphaMode, /// Output path or template (see the module docs). pub output: String, - /// JPEG quality 1–100. + /// JPEG / WebM quality 1–100. pub quality: u8, - /// H.264 target bitrate. + /// H.264 target bitrate (and WebM's when `webm_bitrate`). pub bitrate_kbps: u32, + /// WebM: rate control by `bitrate_kbps` instead of `quality`. + pub webm_bitrate: bool, + /// Movies: frames between key frames (0 = automatic, two seconds). + pub keyframe_interval: u32, pub prores_profile: ProResProfile, + pub crop: Crop, + pub resize: Resize, pub audio: AudioOutput, pub audio_sample_rate: u32, + /// 1 (mono) or 2 (stereo). + pub audio_channels: u8, + pub audio_format: AudioFormat, /// GIF: loop forever. pub gif_loop: bool, + /// Include Project Link: kept for parity; EffectCraft's writers have no place to store a + /// link back to the project, so it changes nothing in the file. + pub include_project_link: bool, } impl Default for OutputModule { fn default() -> Self { OutputModule { + name: String::new(), format: OutputFormat::H264, channels: Channels::Rgb, + alpha_mode: AlphaMode::Straight, output: DEFAULT_TEMPLATE.into(), quality: 90, bitrate_kbps: 10_000, + webm_bitrate: false, + keyframe_interval: 0, prores_profile: ProResProfile::Hq, + crop: Crop::default(), + resize: Resize::default(), audio: AudioOutput::Auto, audio_sample_rate: 48_000, + audio_channels: 2, + audio_format: AudioFormat::S16, gif_loop: true, + include_project_link: true, } } } impl OutputModule { + /// The size of the frames this module writes for frames rendered at `w`×`h` with render + /// scale `scale`: after Crop and Resize (before codec rounding). + pub fn frame_size(&self, w: u32, h: u32, scale: f64) -> (u32, u32) { + let (t, l, b, r) = self.crop.edges(w, h, scale); + let cw = (w as i32 - l - r).max(1) as u32; + let ch = (h as i32 - t - b).max(1) as u32; + if !self.resize.enabled { + return (cw, ch); + } + let (mut rw, mut rh) = (self.resize.width.max(1), self.resize.height.max(1)); + if self.resize.lock_aspect { + // Width rules; height follows the cropped frame's aspect. + rh = ((rw as f64 * ch as f64 / cw as f64).round() as u32).max(1); + } + rw = rw.min(30_000); + rh = rh.min(30_000); + (rw, rh) + } + /// The written frame size of an item (Render Settings resolution, Crop, Resize, codec + /// rounding). + pub fn output_size(&self, comp: &Comp, settings: &RenderSettings) -> (u32, u32) { + let (w, h) = settings.output_size(comp); + let (w, h) = self.frame_size(w, h, settings.resolution.clamp(0.01, 4.0)); + self.format.coded_size(w, h) + } + /// Every setting as `(label, value)` lines (render logs). + pub fn describe(&self) -> Vec<(&'static str, String)> { + let mut v = vec![ + ("Template", if self.name.is_empty() { "Custom".into() } else { self.name.clone() }), + ("Format", self.format.label().into()), + ("Channels", self.channels.label().into()), + ("Output To", self.output.clone()), + ]; + if self.channels == Channels::Rgba { + v.insert(3, ("Color", format!("{:?}", self.alpha_mode))); + } + match self.format { + OutputFormat::H264 => v.push(("Bitrate", format!("{} kbps", self.bitrate_kbps))), + OutputFormat::ProRes => v.push(("Codec", self.prores_profile.label().into())), + OutputFormat::WebM if self.webm_bitrate => v.push(("Bitrate", format!("{} kbps", self.bitrate_kbps))), + OutputFormat::WebM | OutputFormat::JpegSequence => v.push(("Quality", self.quality.to_string())), + _ => {} + } + if self.crop.enabled { + let c = &self.crop; + v.push(( + "Crop", + if c.use_roi { format!("Region of Interest {:?}", c.roi) } else { format!("T {} L {} B {} R {}", c.top, c.left, c.bottom, c.right) }, + )); + } + if self.resize.enabled { + let r = &self.resize; + v.push(("Resize", format!("{}×{} ({:?} quality{})", r.width, r.height, r.quality, if r.lock_aspect { ", aspect locked" } else { "" }))); + } + if self.format.supports_audio() { + v.push(( + "Audio Output", + format!( + "{:?} · {} Hz · {} · {}", + self.audio, + self.audio_sample_rate, + if self.audio_channels == 1 { "Mono" } else { "Stereo" }, + self.audio_format.label() + ), + )); + } + v.push(("Include Project Link", if self.include_project_link { "On" } else { "Off" }.into())); + v + } pub fn for_format(format: OutputFormat) -> OutputModule { let mut m = OutputModule { format, ..Default::default() }; if format.is_sequence() { @@ -384,7 +941,7 @@ impl OutputModule { pub fn set_format(&mut self, format: OutputFormat) { let old = self.format; self.format = format; - if !format.supports_alpha() { + if !format.supports_alpha() && self.channels == Channels::Rgba { self.channels = Channels::Rgb; } if self.output.contains("[fileExtension]") { @@ -406,10 +963,10 @@ impl OutputModule { } /// One-line summary shown next to "Output Module:". pub fn summary(&self) -> String { - let ch = match self.channels { - Channels::Rgb => "RGB", - Channels::Rgba => "RGB + Alpha", - }; + if !self.name.is_empty() { + return self.name.clone(); + } + let ch = self.channels.label(); match self.format { OutputFormat::H264 => format!("H.264 · {} kbps", self.bitrate_kbps), OutputFormat::ProRes => format!( @@ -478,6 +1035,85 @@ pub struct RenderQueueItem { /// Post-Render Action of the first output module. #[serde(default, skip_serializing_if = "PostRenderAction::is_none")] pub post_render: PostRenderAction, + /// The Log menu: what the render log next to the output records. + #[serde(default, skip_serializing_if = "RenderLog::is_default")] + pub log: RenderLog, +} + +/// Render Queue item ▸ Log. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum RenderLog { + /// A log only when the render fails. + #[default] + ErrorsOnly, + /// Always: the result plus every Render Settings and Output Module setting. + PlusSettings, + /// Also one line per rendered frame. + PlusPerFrameInfo, +} + +impl RenderLog { + pub const ALL: [RenderLog; 3] = [RenderLog::ErrorsOnly, RenderLog::PlusSettings, RenderLog::PlusPerFrameInfo]; + pub fn is_default(&self) -> bool { + *self == RenderLog::ErrorsOnly + } + pub fn label(self) -> &'static str { + match self { + RenderLog::ErrorsOnly => "Errors Only", + RenderLog::PlusSettings => "Plus Settings", + RenderLog::PlusPerFrameInfo => "Plus Per Frame Info", + } + } + pub fn parse(s: &str) -> Option { + match norm(s).as_str() { + "errors" | "errorsonly" => Some(RenderLog::ErrorsOnly), + "settings" | "plussettings" => Some(RenderLog::PlusSettings), + "perframe" | "plusperframe" | "plusperframeinfo" | "frames" => Some(RenderLog::PlusPerFrameInfo), + _ => None, + } + } +} + +/// The render log's path for an output: next to it, `_RenderLog.txt` (a sequence's +/// frame-number run is dropped). +pub fn log_path(output: &str) -> String { + let p = std::path::Path::new(output); + let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); + let mut stem: String = stem.replace("_[#####]", "").replace("[#####]", ""); + if let Some(i) = stem.find('#') { + let n = stem[i..].chars().take_while(|c| *c == '#').count(); + stem.replace_range(i..i + n, ""); + } + let stem = stem.trim_end_matches(['_', '.', ' ']); + let name = format!("{}_RenderLog.txt", if stem.is_empty() { "Render" } else { stem }); + match p.parent().filter(|d| !d.as_os_str().is_empty()) { + Some(d) => d.join(name).to_string_lossy().to_string(), + None => name, + } +} + +/// Storage hook (Render Settings ▸ Use Storage Overflow): whether `bytes` more fit on the volume +/// holding `path`. The desktop app has no quota by default; tests and hosts with quotas supply +/// one. +pub trait StorageQuota: Send + Sync { + fn has_room(&self, path: &str, bytes: u64) -> bool; +} + +/// Project-wide Render Queue preferences. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct RenderQueuePrefs { + /// Notify when the render finishes (a sound and a notification in the desktop app). + pub notify: bool, + /// Storage overflow folders, used in order when the output volume is full. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub overflow_folders: Vec, +} + +impl RenderQueuePrefs { + pub fn is_default(&self) -> bool { + *self == RenderQueuePrefs::default() + } } /// What happens after an item renders (Output Module ▸ Post-Render Action). @@ -521,6 +1157,7 @@ impl RenderQueueItem { last_output: None, extra_outputs: vec![], post_render: PostRenderAction::None, + log: RenderLog::ErrorsOnly, } } /// Every output module, the first one first. @@ -612,6 +1249,90 @@ mod tests { assert_eq!(ProResProfile::from_name("hq"), Some(ProResProfile::Hq)); } + #[test] + fn pulldown_cadence() { + assert_eq!(Pulldown::Wwssw.pattern(), "WWSSW"); + for p in &Pulldown::ALL[1..] { + assert_eq!(p.pattern(), p.label(), "{p:?}"); + } + // 2:3 cadence: ten fields carry four film frames; every output frame starts on film 0. + for p in &Pulldown::ALL[1..] { + let mut fields = vec![]; + for k in 0..10 { + let (a, b) = p.fields(k).unwrap(); + fields.extend([a, b]); + } + assert_eq!(fields[0], 0, "{p:?}"); + assert!(fields.windows(2).all(|w| w[1] == w[0] || w[1] == w[0] + 1), "{p:?}: {fields:?}"); + // 20 fields = 8 film frames' worth of cadence (a phase may start or end mid-frame). + assert!((7..=8).contains(&(*fields.last().unwrap() - fields[0])), "{p:?} {fields:?}"); + // Each film frame appears in 2 or 3 consecutive fields, alternating. + let mut runs = vec![]; + let mut n = 1; + for w in fields.windows(2) { + if w[1] == w[0] { + n += 1; + } else { + runs.push(n); + n = 1; + } + } + assert!(runs[1..].iter().all(|r| *r == 2 || *r == 3), "{p:?}: {runs:?}"); + assert!(runs[1..].windows(2).all(|w| w[0] != w[1]), "2 and 3 alternate: {p:?}: {runs:?}"); + } + assert_eq!(Pulldown::parse("sswww"), Some(Pulldown::Sswww)); + // With field rendering at 29.97, film frames are 23.976 fps. + let c = Comp::new(64, 48, FrameRate::new(30000, 1001), Tick::from_seconds_f64(2.0)); + let s = RenderSettings { field_render: FieldRender::UpperFirst, pulldown: Pulldown::Wssww, time_span: TimeSpan::LengthOfComp, ..Default::default() }; + let film = FrameRate::new(24000, 1001); + match s.sample(&c, 1) { + FrameSample::Fields { first, second, upper_first } => { + assert!(upper_first); + assert_eq!((first, second), (film.tick_of(0), film.tick_of(1)), "WSSWW: frame 1 is split between film frames 0 and 1"); + } + f => panic!("{f:?}"), + } + } + + #[test] + fn field_samples_are_half_a_frame_apart() { + let c = Comp::new(64, 48, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0)); + let s = RenderSettings { field_render: FieldRender::LowerFirst, time_span: TimeSpan::LengthOfComp, ..Default::default() }; + assert_eq!(s.sample(&c, 3), FrameSample::Fields { first: Tick::from_seconds_f64(0.12), second: Tick::from_seconds_f64(0.14), upper_first: false }); + let p = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }; + assert_eq!(p.sample(&c, 3), FrameSample::Progressive(Tick::from_seconds_f64(0.12))); + } + + #[test] + fn crop_and_resize_sizes() { + let c = Comp::new(1920, 1080, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0)); + let rs = RenderSettings::default(); + let mut m = OutputModule::default(); + assert_eq!(m.output_size(&c, &rs), (1920, 1080)); + m.crop = Crop { enabled: true, top: 10, left: 20, bottom: 30, right: 41, ..Default::default() }; + assert_eq!(m.output_size(&c, &rs), (1858, 1040), "H.264 rounds the odd width down"); + m.format = OutputFormat::PngSequence; + assert_eq!(m.output_size(&c, &rs), (1859, 1040)); + // Region of interest, at half resolution. + m.crop = Crop { enabled: true, use_roi: true, roi: Some([100.0, 50.0, 800.0, 400.0]), ..Default::default() }; + let half = RenderSettings { resolution: 0.5, ..Default::default() }; + assert_eq!(m.output_size(&c, &half), (400, 200)); + // Negative crop pads. + m.crop = Crop { enabled: true, top: -10, bottom: -10, ..Default::default() }; + assert_eq!(m.output_size(&c, &rs), (1920, 1100)); + // Resize: fixed, and with the aspect locked to the (cropped) frame. + m.crop = Crop::default(); + m.resize = Resize { enabled: true, width: 1280, height: 999, lock_aspect: false, quality: ResizeQuality::High }; + assert_eq!(m.output_size(&c, &rs), (1280, 999)); + m.resize.lock_aspect = true; + assert_eq!(m.output_size(&c, &rs), (1280, 720)); + m.crop = Crop { enabled: true, left: 480, right: 480, ..Default::default() }; + assert_eq!(m.output_size(&c, &rs), (1280, 1440), "960×1080 cropped, then 1280 wide at 8:9"); + assert_eq!(log_path("/out/Main_[#####].png"), "/out/Main_RenderLog.txt"); + assert_eq!(log_path("/out/a.mov"), "/out/a_RenderLog.txt"); + assert_eq!(log_path("seq###.tif"), "seq_RenderLog.txt"); + } + #[test] fn spans() { let mut c = Comp::new(100, 50, FrameRate::new(25, 1), Tick::from_seconds_f64(4.0)); diff --git a/crates/project/src/render_templates.rs b/crates/project/src/render_templates.rs new file mode 100644 index 00000000..cf52b000 --- /dev/null +++ b/crates/project/src/render_templates.rs @@ -0,0 +1,323 @@ +//! Render Settings and Output Module templates (Edit ▸ Templates ▸ Render Settings… / Output +//! Module…): the built-in presets, templates saved with the project, and the defaults new +//! Render Queue items, pre-renders and proxies start from. +//! +//! Built-ins always exist; a saved template with a built-in's name overrides it. Templates +//! carry their name in [`RenderSettings::name`] / [`OutputModule::name`], which the Render Queue +//! shows like After Effects does. + +use serde::{Deserialize, Serialize}; + +use crate::render_queue::*; +use effectcraft_time::FrameRate; + +/// A default slot (the Defaults section of the Templates dialogs). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum TemplateSlot { + /// New Render Queue items rendering a movie. + Movie, + /// Single-frame renders (Composition ▸ Save Frame As ▸ File…). + Still, + /// Composition ▸ Pre-render…. + PreRender, + /// File ▸ Create Proxy ▸ Movie…. + MovieProxy, + /// File ▸ Create Proxy ▸ Still…. + StillProxy, +} + +impl TemplateSlot { + pub const ALL: [TemplateSlot; 5] = [TemplateSlot::Movie, TemplateSlot::Still, TemplateSlot::PreRender, TemplateSlot::MovieProxy, TemplateSlot::StillProxy]; + pub fn label(self) -> &'static str { + match self { + TemplateSlot::Movie => "Movie Default", + TemplateSlot::Still => "Frame Default", + TemplateSlot::PreRender => "Pre-Render Default", + TemplateSlot::MovieProxy => "Movie Proxy Default", + TemplateSlot::StillProxy => "Still Proxy Default", + } + } + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "movie" | "moviedefault" => Some(TemplateSlot::Movie), + "still" | "frame" | "framedefault" | "stilldefault" => Some(TemplateSlot::Still), + "prerender" | "prerenderdefault" => Some(TemplateSlot::PreRender), + "movieproxy" | "movieproxydefault" => Some(TemplateSlot::MovieProxy), + "stillproxy" | "stillproxydefault" => Some(TemplateSlot::StillProxy), + _ => None, + } + } +} + +/// Which template list. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TemplateKind { + RenderSettings, + OutputModule, +} + +impl TemplateKind { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "rendersettings" | "settings" | "render" => Some(TemplateKind::RenderSettings), + "outputmodule" | "output" | "module" => Some(TemplateKind::OutputModule), + _ => None, + } + } +} + +/// Defaults per slot (template names). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TemplateDefaults { + pub render_settings: Vec<(TemplateSlot, String)>, + pub output_modules: Vec<(TemplateSlot, String)>, +} + +impl Default for TemplateDefaults { + fn default() -> Self { + use TemplateSlot::*; + let s = |v: &[(TemplateSlot, &str)]| v.iter().map(|(k, n)| (*k, n.to_string())).collect(); + TemplateDefaults { + render_settings: s(&[ + (Movie, "Best Settings"), + (Still, "Current Settings"), + (PreRender, "Best Settings"), + (MovieProxy, "Draft Settings"), + (StillProxy, "Best Settings"), + ]), + output_modules: s(&[ + (Movie, "H.264 - Match Render Settings - 15 Mbps"), + (Still, "PNG Sequence"), + (PreRender, "Lossless with Alpha"), + (MovieProxy, "Lossless with Alpha"), + (StillProxy, "PNG Sequence with Alpha"), + ]), + } + } +} + +/// Templates saved with the project, plus the defaults. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct RenderTemplates { + #[serde(skip_serializing_if = "Vec::is_empty")] + pub render_settings: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub output_modules: Vec, + pub defaults: TemplateDefaults, +} + +/// The built-in Render Settings templates. +pub fn builtin_render_settings() -> Vec { + let best = RenderSettings { + name: "Best Settings".into(), + frame_blending: SwitchOverride::OnForChecked, + motion_blur_mode: SwitchOverride::OnForChecked, + ..RenderSettings::default() + }; + let draft = RenderSettings { + name: "Draft Settings".into(), + quality: RenderQuality::Draft, + resolution: 0.5, + proxy_use: ProxyUse::CurrentSettings, + frame_blending: SwitchOverride::Current, + motion_blur_mode: SwitchOverride::Current, + ..RenderSettings::default() + }; + let dv = + RenderSettings { name: "DV Settings".into(), field_render: FieldRender::LowerFirst, frame_rate: Some(FrameRate::new(30000, 1001)), ..best.clone() }; + let multi = RenderSettings { name: "Multi-Machine Settings".into(), skip_existing: true, ..best.clone() }; + let current = RenderSettings { + name: "Current Settings".into(), + proxy_use: ProxyUse::CurrentSettings, + guide_layers: CurrentOrOff::Current, + ..RenderSettings::default() + }; + vec![best, draft, dv, multi, current] +} + +fn om(name: &str, format: OutputFormat) -> OutputModule { + let mut m = OutputModule::for_format(format); + m.name = name.into(); + m +} + +/// The built-in Output Module templates. +pub fn builtin_output_modules() -> Vec { + let mut v = vec![]; + let mut hq = om("High Quality", OutputFormat::ProRes); + hq.prores_profile = ProResProfile::Hq; + v.push(hq); + let mut hqa = om("High Quality with Alpha", OutputFormat::ProRes); + hqa.prores_profile = ProResProfile::P4444; + hqa.channels = Channels::Rgba; + hqa.alpha_mode = AlphaMode::Premultiplied; + v.push(hqa); + // Lossless: the highest-fidelity movie codec this build writes (ProRes 4444 XQ). + let mut ll = om("Lossless", OutputFormat::ProRes); + ll.prores_profile = ProResProfile::P4444Xq; + v.push(ll); + let mut lla = om("Lossless with Alpha", OutputFormat::ProRes); + lla.prores_profile = ProResProfile::P4444Xq; + lla.channels = Channels::Rgba; + v.push(lla); + for mbps in [5u32, 15, 40] { + let mut h = om(&format!("H.264 - Match Render Settings - {mbps} Mbps"), OutputFormat::H264); + h.bitrate_kbps = mbps * 1000; + v.push(h); + } + let mut alpha = om("Alpha Only", OutputFormat::ProRes); + alpha.channels = Channels::Alpha; + v.push(alpha); + v.push(om("AIFF 48kHz", OutputFormat::Aiff)); + v.push(om("WAV 48kHz", OutputFormat::Wav)); + v.push(om("PNG Sequence", OutputFormat::PngSequence)); + let mut pa = om("PNG Sequence with Alpha", OutputFormat::PngSequence); + pa.channels = Channels::Rgba; + v.push(pa); + let mut ta = om("TIFF Sequence with Alpha", OutputFormat::TiffSequence); + ta.channels = Channels::Rgba; + v.push(ta); + v.push(om("JPEG Sequence", OutputFormat::JpegSequence)); + let mut exr = om("OpenEXR Sequence with Alpha", OutputFormat::ExrSequence); + exr.channels = Channels::Rgba; + v.push(exr); + v.push(om("Animated GIF", OutputFormat::Gif)); + v.push(om("WebM (VP9 + Opus)", OutputFormat::WebM)); + let mut wa = om("WebM with Alpha", OutputFormat::WebM); + wa.channels = Channels::Rgba; + v.push(wa); + v +} + +fn same(a: &str, b: &str) -> bool { + a.trim().eq_ignore_ascii_case(b.trim()) +} + +impl RenderTemplates { + pub fn is_default(&self) -> bool { + *self == RenderTemplates::default() + } + /// Every Render Settings template: built-ins (unless overridden), then saved ones. + pub fn render_settings_list(&self) -> Vec { + let mut v: Vec = + builtin_render_settings().into_iter().filter(|b| !self.render_settings.iter().any(|u| same(&u.name, &b.name))).collect(); + v.extend(self.render_settings.iter().cloned()); + v + } + pub fn output_module_list(&self) -> Vec { + let mut v: Vec = builtin_output_modules().into_iter().filter(|b| !self.output_modules.iter().any(|u| same(&u.name, &b.name))).collect(); + v.extend(self.output_modules.iter().cloned()); + v + } + pub fn render_settings(&self, name: &str) -> Option { + self.render_settings_list().into_iter().find(|t| same(&t.name, name)) + } + pub fn output_module(&self, name: &str) -> Option { + self.output_module_list().into_iter().find(|t| same(&t.name, name)) + } + pub fn is_builtin(kind: TemplateKind, name: &str) -> bool { + match kind { + TemplateKind::RenderSettings => builtin_render_settings().iter().any(|t| same(&t.name, name)), + TemplateKind::OutputModule => builtin_output_modules().iter().any(|t| same(&t.name, name)), + } + } + /// Save (or replace) a template under `name`. + pub fn save_render_settings(&mut self, name: &str, mut s: RenderSettings) { + s.name = name.trim().to_string(); + self.render_settings.retain(|t| !same(&t.name, name)); + self.render_settings.push(s); + } + pub fn save_output_module(&mut self, name: &str, mut m: OutputModule) { + m.name = name.trim().to_string(); + self.output_modules.retain(|t| !same(&t.name, name)); + self.output_modules.push(m); + } + /// Delete a saved template (a built-in it overrode comes back). Built-ins themselves can't + /// be deleted. + pub fn delete(&mut self, kind: TemplateKind, name: &str) -> Result<(), String> { + let before = (self.render_settings.len(), self.output_modules.len()); + match kind { + TemplateKind::RenderSettings => self.render_settings.retain(|t| !same(&t.name, name)), + TemplateKind::OutputModule => self.output_modules.retain(|t| !same(&t.name, name)), + } + if before != (self.render_settings.len(), self.output_modules.len()) { + return Ok(()); + } + if Self::is_builtin(kind, name) { Err(format!("“{name}” is a built-in template")) } else { Err(format!("no template “{name}”")) } + } + /// The default template name of a slot. + pub fn default_name(&self, kind: TemplateKind, slot: TemplateSlot) -> String { + let list = match kind { + TemplateKind::RenderSettings => &self.defaults.render_settings, + TemplateKind::OutputModule => &self.defaults.output_modules, + }; + list.iter().find(|(s, _)| *s == slot).map(|(_, n)| n.clone()).unwrap_or_else(|| { + let d = TemplateDefaults::default(); + let l = if kind == TemplateKind::RenderSettings { d.render_settings } else { d.output_modules }; + l.into_iter().find(|(s, _)| *s == slot).map(|(_, n)| n).unwrap_or_default() + }) + } + pub fn set_default(&mut self, kind: TemplateKind, slot: TemplateSlot, name: &str) -> Result<(), String> { + let exists = match kind { + TemplateKind::RenderSettings => self.render_settings(name).is_some(), + TemplateKind::OutputModule => self.output_module(name).is_some(), + }; + if !exists { + return Err(format!("no template “{name}”")); + } + let list = match kind { + TemplateKind::RenderSettings => &mut self.defaults.render_settings, + TemplateKind::OutputModule => &mut self.defaults.output_modules, + }; + list.retain(|(s, _)| *s != slot); + list.push((slot, name.trim().to_string())); + Ok(()) + } + /// The Render Settings a slot starts from (a missing template falls back to the built-in + /// default). + pub fn default_render_settings(&self, slot: TemplateSlot) -> RenderSettings { + self.render_settings(&self.default_name(TemplateKind::RenderSettings, slot)).unwrap_or_else(|| builtin_render_settings().remove(0)) + } + pub fn default_output_module(&self, slot: TemplateSlot) -> OutputModule { + self.output_module(&self.default_name(TemplateKind::OutputModule, slot)).unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtins_defaults_and_round_trip() { + let mut t = RenderTemplates::default(); + let names: Vec = t.render_settings_list().into_iter().map(|s| s.name).collect(); + assert_eq!(names, ["Best Settings", "Draft Settings", "DV Settings", "Multi-Machine Settings", "Current Settings"]); + assert_eq!(t.default_render_settings(TemplateSlot::Movie).name, "Best Settings"); + assert_eq!(t.default_output_module(TemplateSlot::PreRender).channels, Channels::Rgba); + let dv = t.render_settings("dv settings").unwrap(); + assert_eq!(dv.field_render, FieldRender::LowerFirst); + // Save a custom template, make it the movie default, round-trip through JSON. + let mut s = RenderSettings { resolution: 0.25, ..Default::default() }; + s.color_depth = ColorDepth::Bpc16; + t.save_render_settings("Quarter 16", s); + t.set_default(TemplateKind::RenderSettings, TemplateSlot::Movie, "Quarter 16").unwrap(); + let mut m = OutputModule::for_format(OutputFormat::WebM); + m.crop = Crop { enabled: true, top: 4, left: 2, ..Default::default() }; + t.save_output_module("Web Crop", m); + let json = serde_json::to_string(&t).unwrap(); + let back: RenderTemplates = serde_json::from_str(&json).unwrap(); + assert_eq!(back, t); + assert_eq!(back.default_render_settings(TemplateSlot::Movie).color_depth, ColorDepth::Bpc16); + assert_eq!(back.output_module("web crop").unwrap().crop.top, 4); + assert!(t.delete(TemplateKind::RenderSettings, "Best Settings").is_err()); + t.delete(TemplateKind::OutputModule, "Web Crop").unwrap(); + assert!(t.output_module("Web Crop").is_none()); + // Overriding a built-in, then deleting the override, restores it. + t.save_output_module("High Quality", OutputModule::for_format(OutputFormat::Gif)); + assert_eq!(t.output_module("High Quality").unwrap().format, OutputFormat::Gif); + t.delete(TemplateKind::OutputModule, "High Quality").unwrap(); + assert_eq!(t.output_module("High Quality").unwrap().format, OutputFormat::ProRes); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 912f2e68..77dfc39f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -73,6 +73,8 @@ pub enum Dialog { Precompose, /// Layer ▸ Layer Styles ▸ Layer Style Options… LayerStyles, + /// Edit ▸ Templates ▸ Render Settings… / Output Module…. + RenderTemplates, } /// Host hooks provided by the native app (file pickers etc.). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index eed7e639..18add8dc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -506,8 +506,18 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu } "app.templates" => { let kind = p.get("kind").and_then(Value::as_str).unwrap_or("renderSettings"); - let title = if kind == "outputModule" { "Output Module Templates" } else { "Render Settings Templates" }; - crate::panels::dialogs::info(app, title, "Render and output templates are managed from the Render Queue panel (Window ▸ Render Queue)."); + crate::panels::rq_templates::open(app, ctx, kind); + Value::Null + } + "renderQueue.notify" => { + // Render finished with Notify on: a toast, the dock/taskbar attention request, and the + // host's system sound (the desktop app handles `renderQueue.notify`). + let msg = p.get("message").and_then(Value::as_str).unwrap_or("Render finished").to_string(); + app.toast = Some((msg, now)); + ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational)); + if let Some(f) = app.hooks.app_action.as_ref() { + f("renderQueue.notify"); + } Value::Null } "app.find" => { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index cabc8703..8992b69a 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -129,6 +129,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) { Dialog::LayerStyles => super::layer_styles_dialog::show(app, ctx, &t), Dialog::TrackTarget => super::tracker::target_dialog(app, ctx, &t), Dialog::TrackApply => super::tracker::apply_dialog(app, ctx, &t), + Dialog::RenderTemplates => super::rq_templates::show(app, ctx, &t), } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d0fb3a3e..740ced10 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -28,6 +28,7 @@ pub mod properties; pub mod puppet_tool; pub mod render_queue; pub mod roto_tool; +pub mod rq_templates; pub mod script_console; pub mod settings; pub mod shortcut_editor; diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index c02090bc..ec0853a1 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -4,10 +4,18 @@ //! Output Module and Output To rows, and a status footer. //! //! Every control dispatches an engine `renderQueue.*` command (so agents can do the same) and -//! registers an automation id: `renderQueue.render`, `renderQueue.stop`, -//! `renderQueue.item..{row,twirl,render,renderSettings,outputModule,outputTo,outputPath}`. +//! registers an automation id: `renderQueue.render`, `renderQueue.stop`, `renderQueue.notify`, +//! `renderQueue.item..{row,twirl,render,renderSettings,outputModule,outputTo,outputPath,log}`. +//! +//! The Render Settings and Output Module menus list the templates first (Edit ▸ Templates), then +//! every setting; entries needing numbers (custom time span and frame rate, crop, resize, bitrate, +//! key frame interval, Make Template) open a form that runs the same command. -use effectcraft_engine::project::render_queue::{AudioOutput, Channels, OutputFormat, ProResProfile, RenderQuality, RenderQueueItem, RenderStatus, TimeSpan}; +use effectcraft_engine::project::render_queue::{ + AlphaMode, AudioFormat, AudioOutput, Channels, ColorDepth, CurrentOrOff, EffectsMode, FieldRender, OutputFormat, PostRenderAction, ProResProfile, Pulldown, + RESIZE_PRESETS, RenderLog, RenderQuality, RenderQueueItem, RenderStatus, ResizeQuality, SwitchOverride, TimeSpan, +}; +use effectcraft_engine::project::render_templates::RenderTemplates; use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::{Value, json}; @@ -53,42 +61,82 @@ fn status_color(t: &Tokens, s: &RenderStatus) -> Color32 { } } -/// Render Settings menu: (label, params). -fn settings_menu(it: &RenderQueueItem) -> Vec<(String, Value)> { +fn mark(on: bool, l: &str) -> String { + if on { format!("✓ {l}") } else { format!(" {l}") } +} + +/// Render Settings menu: (label, params). `{"form": …}` entries open a form. +fn settings_menu(it: &RenderQueueItem, templates: &RenderTemplates) -> Vec<(String, Value)> { let s = &it.settings; - let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") }; - let mut v = vec![ - (mark(s.quality == RenderQuality::Best, "Best Settings"), json!({"quality": "best"})), - (mark(s.quality == RenderQuality::Draft, "Draft Settings"), json!({"quality": "draft"})), - ("-".into(), Value::Null), - ]; + let mut v = vec![]; + for t in templates.render_settings_list() { + v.push((mark(s.name == t.name, &t.name), json!({"template": t.name}))); + } + v.push((" Make Template…".into(), json!({"form": "makeRenderTemplate"}))); + v.push(("-".into(), Value::Null)); + v.push((mark(s.quality == RenderQuality::Best, "Quality: Best"), json!({"quality": "best"}))); + v.push((mark(s.quality == RenderQuality::Draft, "Quality: Draft"), json!({"quality": "draft"}))); for (l, k) in [("Full", "full"), ("Half", "half"), ("Third", "third"), ("Quarter", "quarter")] { v.push((mark(s.resolution_label() == l, &format!("Resolution: {l}")), json!({"resolution": k}))); } v.push(("-".into(), Value::Null)); + for (u, k) in effectcraft_engine::project::render_queue::ProxyUse::ALL.iter().zip(["current", "all", "comp", "none"]) { + v.push((mark(s.proxy_use == *u, &format!("Proxy Use: {}", u.label())), json!({"proxyUse": k}))); + } + for (e, k) in EffectsMode::ALL.iter().zip(["current", "allOn", "allOff"]) { + v.push((mark(s.effects == *e, &format!("Effects: {}", e.label())), json!({"effects": k}))); + } + for (e, k) in [(CurrentOrOff::Current, "current"), (CurrentOrOff::AllOff, "allOff")] { + v.push((mark(s.solo == e, &format!("Solo Switches: {}", e.label())), json!({"solo": k}))); + } + for (e, k) in [(CurrentOrOff::Current, "current"), (CurrentOrOff::AllOff, "allOff")] { + v.push((mark(s.guide_layers == e, &format!("Guide Layers: {}", e.label())), json!({"guideLayers": k}))); + } + for (d, k) in ColorDepth::ALL.iter().zip(["current", "8", "16", "32"]) { + v.push((mark(s.color_depth == *d, &format!("Color Depth: {}", d.label())), json!({"colorDepth": k}))); + } + v.push(("-".into(), Value::Null)); + for (m, k) in SwitchOverride::ALL.iter().zip(["current", "onForChecked", "offForAll"]) { + v.push((mark(s.frame_blending == *m, &format!("Frame Blending: {}", m.label())), json!({"frameBlending": k}))); + } + for (m, k) in SwitchOverride::ALL.iter().zip(["current", "onForChecked", "offForAll"]) { + v.push((mark(s.motion_blur_override() == *m, &format!("Motion Blur: {}", m.label())), json!({"motionBlur": k}))); + } + for (f, k) in FieldRender::ALL.iter().zip(["off", "upper", "lower"]) { + v.push((mark(s.field_render == *f, &format!("Field Render: {}", f.label())), json!({"fieldRender": k}))); + } + if s.field_render != FieldRender::Off { + for pd in Pulldown::ALL { + v.push((mark(s.pulldown == pd, &format!("3:2 Pulldown: {}", pd.label())), json!({"pulldown": pd.label()}))); + } + } + v.push(("-".into(), Value::Null)); v.push((mark(s.time_span == TimeSpan::WorkArea, "Time Span: Work Area Only"), json!({"timeSpan": "workArea"}))); v.push((mark(s.time_span == TimeSpan::LengthOfComp, "Time Span: Length of Comp"), json!({"timeSpan": "comp"}))); - v.push(("-".into(), Value::Null)); + v.push((mark(matches!(s.time_span, TimeSpan::Custom { .. }), "Time Span: Custom…"), json!({"form": "timeSpan"}))); v.push((mark(s.frame_rate.is_none(), "Frame Rate: Use comp's frame rate"), json!({"frameRate": null}))); for fps in [12.0, 15.0, 23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 60.0] { let on = s.frame_rate.is_some_and(|r| (r.as_f64() - fps).abs() < 0.01); v.push((mark(on, &format!("Frame Rate: {fps}")), json!({"frameRate": fps}))); } + v.push((" Frame Rate: Custom…".into(), json!({"form": "frameRate"}))); v.push(("-".into(), Value::Null)); - v.push((mark(s.motion_blur, "Motion Blur: On for Checked Layers"), json!({"motionBlur": !s.motion_blur}))); v.push((mark(s.skip_existing, "Skip Existing Files"), json!({"skipExisting": !s.skip_existing}))); - v.push(("-".into(), Value::Null)); - for (u, k) in effectcraft_engine::project::render_queue::ProxyUse::ALL.iter().zip(["current", "all", "comp", "none"]) { - v.push((mark(s.proxy_use == *u, &format!("Proxy Use: {}", u.label())), json!({"proxyUse": k}))); - } + v.push((mark(s.storage_overflow, "Use Storage Overflow"), json!({"storageOverflow": !s.storage_overflow}))); v } -/// Output Module menu: (label, params). -fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String, Value)> { +/// Output Module menu: (label, params). `{"form": …}` entries open a form. +fn output_menu(it: &RenderQueueItem, available: &[OutputFormat], templates: &RenderTemplates) -> Vec<(String, Value)> { let o = &it.output; - let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") }; let mut v = Vec::new(); + for t in templates.output_module_list() { + if available.contains(&t.format) { + v.push((mark(o.name == t.name, &t.name), json!({"template": t.name}))); + } + } + v.push((" Make Template…".into(), json!({"form": "makeOutputTemplate"}))); + v.push(("-".into(), Value::Null)); for f in OutputFormat::ALL { if available.contains(&f) { v.push((mark(o.format == f, &format!("Format: {}", f.label())), json!({"format": format!("{f:?}")}))); @@ -99,6 +147,13 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String, if o.format.supports_alpha() { v.push((mark(o.channels == Channels::Rgba, "Channels: RGB + Alpha"), json!({"channels": "rgba"}))); } + if !o.format.is_audio_only() { + v.push((mark(o.channels == Channels::Alpha, "Channels: Alpha"), json!({"channels": "alpha"}))); + } + if o.channels == Channels::Rgba { + v.push((mark(o.alpha_mode == AlphaMode::Straight, "Color: Straight (Unmatted)"), json!({"color": "straight"}))); + v.push((mark(o.alpha_mode == AlphaMode::Premultiplied, "Color: Premultiplied (Matted)"), json!({"color": "premultiplied"}))); + } match o.format { OutputFormat::ProRes => { v.push(("-".into(), Value::Null)); @@ -108,14 +163,18 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String, } OutputFormat::H264 => { v.push(("-".into(), Value::Null)); - for kbps in [2_000, 5_000, 10_000, 20_000, 40_000] { + for kbps in [2_000, 5_000, 10_000, 15_000, 20_000, 40_000] { v.push((mark(o.bitrate_kbps == kbps, &format!("Bitrate: {} Mbps", kbps / 1000)), json!({"bitrate": kbps}))); } } 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}))); + v.push((mark(o.quality == q && !o.webm_bitrate, &format!("Quality: {q}")), json!({"quality": q, "webmBitrate": false}))); + } + if o.format == OutputFormat::WebM { + v.push((mark(o.webm_bitrate, &format!("Target Bitrate ({} kbps)…", o.bitrate_kbps)), json!({"form": "bitrate"}))); + v.push((mark(o.keyframe_interval > 0, "Key Frame Interval…"), json!({"form": "keyframes"}))); } } OutputFormat::Gif => { @@ -124,20 +183,131 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String, } _ => {} } + if !o.format.is_audio_only() { + v.push(("-".into(), Value::Null)); + v.push((mark(!o.crop.enabled, "Crop: Off"), json!({"crop": false}))); + v.push((mark(o.crop.enabled && o.crop.use_roi, "Crop: Use Region of Interest"), json!({"crop": {"useRoi": true}}))); + v.push((mark(o.crop.enabled && !o.crop.use_roi, "Crop: Edges…"), json!({"form": "crop"}))); + v.push((mark(!o.resize.enabled, "Resize: Off"), json!({"resize": false}))); + for (n, w, h) in RESIZE_PRESETS { + let on = o.resize.enabled && o.resize.width == w && o.resize.height == h && !o.resize.lock_aspect; + v.push((mark(on, &format!("Resize to: {n} ({w}×{h})")), json!({"resize": {"preset": n}}))); + } + v.push((mark(o.resize.enabled, "Resize to: Custom…"), json!({"form": "resize"}))); + v.push(( + mark(o.resize.lock_aspect, "Resize: Lock Aspect Ratio"), + json!({"resize": {"enabled": o.resize.enabled, "lockAspect": !o.resize.lock_aspect}}), + )); + for (q, k) in [(ResizeQuality::Low, "low"), (ResizeQuality::High, "high")] { + v.push((mark(o.resize.quality == q, &format!("Resize Quality: {q:?}")), json!({"resize": {"enabled": o.resize.enabled, "quality": k}}))); + } + } + v.push(("-".into(), Value::Null)); + for (a, k) in [ + (PostRenderAction::None, "none"), + (PostRenderAction::Import, "import"), + (PostRenderAction::ImportAndReplace, "importAndReplace"), + (PostRenderAction::SetProxy, "setProxy"), + ] { + v.push((mark(it.post_render == a, &format!("Post-Render Action: {}", a.label())), json!({"postRenderAction": k}))); + } + v.push((mark(o.include_project_link, "Include Project Link"), json!({"includeProjectLink": !o.include_project_link}))); if o.format.supports_audio() { v.push(("-".into(), Value::Null)); for (a, l) in [(AudioOutput::Auto, "auto"), (AudioOutput::On, "on"), (AudioOutput::Off, "off")] { let label = match a { AudioOutput::Auto => "Audio Output: Auto", - AudioOutput::On => "Audio Output: On (48 kHz stereo)", + AudioOutput::On => "Audio Output: On", AudioOutput::Off => "Audio Output: Off", }; v.push((mark(o.audio == a, label), json!({"audio": l}))); } + for sr in [44_100u32, 48_000, 96_000] { + v.push((mark(o.audio_sample_rate == sr, &format!("Sample Rate: {:.1} kHz", sr as f64 / 1000.0)), json!({"sampleRate": sr}))); + } + v.push((mark(o.audio_channels == 1, "Audio Channels: Mono"), json!({"audioChannels": "mono"}))); + v.push((mark(o.audio_channels != 1, "Audio Channels: Stereo"), json!({"audioChannels": "stereo"}))); + if matches!(o.format, OutputFormat::ProRes | OutputFormat::Wav | OutputFormat::Aiff) { + for f in [AudioFormat::S16, AudioFormat::S24, AudioFormat::F32] { + v.push((mark(o.audio_format == f, &format!("Sample Format: {}", f.label())), json!({"audioFormat": f.label()}))); + } + } } v } +/// Open the form behind a `{"form": …}` menu entry. +fn open_menu_form(app: &mut EffectcraftApp, it: &RenderQueueItem, form: &str) { + use crate::panels::forms::{Field, form as open}; + let id = it.id; + let o = &it.output; + match form { + "makeRenderTemplate" => open( + app, + "Make Render Settings Template", + "renderQueue.saveTemplate", + json!({"kind": "renderSettings", "item": id}), + vec![Field::text("name", "Template name", "")], + ), + "makeOutputTemplate" => open( + app, + "Make Output Module Template", + "renderQueue.saveTemplate", + json!({"kind": "outputModule", "item": id}), + vec![Field::text("name", "Template name", "")], + ), + "timeSpan" => { + let comp = app.session.project.comp(it.comp); + let (a, b) = comp.map(|c| it.settings.span(c)).unwrap_or_default(); + open( + app, + "Custom Time Span", + "renderQueue.setRenderSettings", + json!({"item": id, "timeSpan": "custom"}), + vec![Field::num("start", "Start (s)", a.seconds()), Field::num("duration", "Duration (s)", (b.seconds() - a.seconds()).max(0.04))], + ) + } + "frameRate" => { + let r = app.session.project.comp(it.comp).map(|c| it.settings.rate(c).as_f64()).unwrap_or(30.0); + open(app, "Use This Frame Rate", "renderQueue.setRenderSettings", json!({"item": id}), vec![Field::num("frameRate", "Frames per second", r)]) + } + "crop" => open( + app, + "Crop", + "renderQueue.setOutputModule", + json!({"item": id}), + vec![ + Field::num("cropTop", "Top", o.crop.top as f64), + Field::num("cropLeft", "Left", o.crop.left as f64), + Field::num("cropBottom", "Bottom", o.crop.bottom as f64), + Field::num("cropRight", "Right", o.crop.right as f64), + ], + ), + "resize" => open( + app, + "Resize to", + "renderQueue.setOutputModule", + json!({"item": id}), + vec![Field::num("resizeWidth", "Width", o.resize.width as f64), Field::num("resizeHeight", "Height", o.resize.height as f64)], + ), + "bitrate" => open( + app, + "WebM Target Bitrate", + "renderQueue.setOutputModule", + json!({"item": id, "webmBitrate": true}), + vec![Field::num("bitrate", "Bitrate (kbps)", o.bitrate_kbps as f64)], + ), + "keyframes" => open( + app, + "Key Frame Interval", + "renderQueue.setOutputModule", + json!({"item": id}), + vec![Field::num("keyframeInterval", "Frames between key frames (0 = every 2 s)", o.keyframe_interval as f64)], + ), + _ => {} + } +} + const OUTPUT_TEMPLATES: [&str; 4] = [ "[compName].[fileExtension]", "[compName]_[width]x[height].[fileExtension]", @@ -166,6 +336,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let rendering = app.session.is_rendering(); let available = app.session.exporter.as_ref().map(|e| e.formats()).unwrap_or_default(); let mut actions: Vec<(&'static str, Value)> = Vec::new(); + let mut forms: Vec<(RenderQueueItem, String)> = Vec::new(); let state_id = egui::Id::new("rq-ui"); let (mut open, mut selected): (Vec, Option) = ui.data(|d| d.get_temp(state_id).unwrap_or_default()); @@ -194,6 +365,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("renderQueue.render", json!({"wait": false}))); } app.auto.add("renderQueue.render", render_b, "Render"); + // Notify (sound + notification when the queue finishes). + let notify = app.session.project.render_prefs.notify; + let nb = Rect::from_min_size(pos2(render_b.min.x - 8.0 - 2.0 * bw - 8.0, render_b.min.y + 4.0), vec2(18.0, 18.0)); + if widgets::checkbox(ui, nb, notify, &t, egui::Id::new("rq-notify")).clicked() { + actions.push(("renderQueue.setNotify", json!({"notify": !notify}))); + } + p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, "Notify", Tokens::ui(11.5), t.text_dim); + app.auto.add("renderQueue.notify", nb, if notify { "Notify: on" } else { "Notify: off" }); if rendering { if widgets::text_button(ui, stop_b, "Stop", false, &t, egui::Id::new("rq-stop")).clicked() { actions.push(("renderQueue.stop", json!({}))); @@ -201,8 +380,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("renderQueue.stop", stop_b, "Stop"); } // Progress bar. - let bar = - Rect::from_min_max(pos2(x0, render_b.min.y + 4.0), pos2(if rendering { stop_b.min.x - 16.0 } else { render_b.min.x - 16.0 }, render_b.max.y - 4.0)); + let bar = Rect::from_min_max(pos2(x0, render_b.min.y + 4.0), pos2(nb.min.x - 16.0, render_b.max.y - 4.0)); p.rect_filled(bar, 3.0, t.field_bg); p.rect_stroke(bar, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); let frac = progress.as_ref().filter(|p| p.total > 0).map(|p| (p.done as f32 / p.total as f32).clamp(0.0, 1.0)).unwrap_or(0.0); @@ -355,22 +533,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H)); y += ROW_H; lp.rect_filled(r, 0.0, t.panel_bg); + let templates = &app.session.project.render_templates; let (label, summary, key, menu): (&str, String, &str, Vec<(String, Value)>) = if sub == 0 { - ("Render Settings:", it.settings.summary(), "renderSettings", settings_menu(it)) + ("Render Settings:", it.settings.summary(), "renderSettings", settings_menu(it, templates)) } else { - ("Output Module:", it.output.summary(), "outputModule", output_menu(it, &available)) + ("Output Module:", it.output.summary(), "outputModule", output_menu(it, &available, templates)) }; lp.text(pos2(cx_num - 6.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); let dd = Rect::from_min_size(pos2(ix + 70.0, r.min.y + 3.0), vec2(220.0, ROW_H - 6.0)); let did = egui::Id::new(("rq-dd", it.id, sub)); - if widgets::dropdown(ui, dd, &summary, &t, did).clicked() && !rendering { + let shown: String = if summary.chars().count() > 31 { summary.chars().take(30).chain(std::iter::once('…')).collect() } else { summary.clone() }; + if widgets::dropdown(ui, dd, &shown, &t, did).clicked() && !rendering { widgets::open_popup(ui, did); } let labels: Vec = menu.iter().map(|(l, _)| l.clone()).collect(); if let Some(i) = widgets::popup_menu(ui, did, dd.left_bottom(), &labels, None) { let mut params = menu[i].1.clone(); - params["item"] = json!(it.id); - actions.push((if sub == 0 { "renderQueue.setRenderSettings" } else { "renderQueue.setOutputModule" }, params)); + if let Some(f) = params.get("form").and_then(Value::as_str) { + forms.push((it.clone(), f.to_string())); + } else { + params["item"] = json!(it.id); + actions.push((if sub == 0 { "renderQueue.setRenderSettings" } else { "renderQueue.setOutputModule" }, params)); + } } app.auto.add(&aid(key), dd, &summary); if sub == 0 { @@ -387,6 +571,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ); lp.text(pos2(dd.max.x + 14.0, r.center().y), Align2::LEFT_CENTER, info, Tokens::ui(11.0), t.text_faint); } + // Log: Errors Only / Plus Settings / Plus Per Frame Info. + let ld = Rect::from_min_size(pos2(r.max.x - 190.0, r.min.y + 3.0), vec2(150.0, ROW_H - 6.0)); + lp.text(pos2(ld.min.x - 6.0, r.center().y), Align2::RIGHT_CENTER, "Log:", Tokens::ui(11.5), t.text_dim); + let lid = egui::Id::new(("rq-log", it.id)); + if widgets::dropdown(ui, ld, it.log.label(), &t, lid).clicked() && !rendering { + widgets::open_popup(ui, lid); + } + let llabels: Vec = RenderLog::ALL.iter().map(|l| mark(it.log == *l, l.label())).collect(); + if let Some(i) = widgets::popup_menu(ui, lid, ld.left_bottom(), &llabels, None) { + actions.push(("renderQueue.setLog", json!({"item": it.id, "log": RenderLog::ALL[i].label()}))); + } + app.auto.add(&aid("log"), ld, it.log.label()); continue; } // Output To. @@ -490,6 +686,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { open.retain(|id| queue.iter().any(|i| i.id == *id)); ui.data_mut(|d| d.insert_temp(state_id, (open, selected))); let ctx = ui.ctx().clone(); + for (it, f) in forms { + open_menu_form(app, &it, &f); + } for (id, params) in actions { if let Err(e) = crate::menus::invoke(app, &ctx, id, params) { app.ui.status = e; diff --git a/crates/ui-egui/src/panels/rq_templates.rs b/crates/ui-egui/src/panels/rq_templates.rs new file mode 100644 index 00000000..c60c3009 --- /dev/null +++ b/crates/ui-egui/src/panels/rq_templates.rs @@ -0,0 +1,166 @@ +//! Edit ▸ Templates ▸ Render Settings… / Output Module…: the Defaults (which template new +//! Render Queue items, frames, pre-renders and proxies start from) and the template list +//! (settings of the selected template; Duplicate, Delete, New from a Render Queue item). +//! +//! Every action runs a `renderQueue.*Template*` engine command. Automation ids: +//! `templates.default.`, `templates.select`, `templates.duplicate`, `templates.delete`, +//! `templates.newName`, `templates.saveFromItem`, `templates.ok`. + +use effectcraft_engine::project::render_templates::{RenderTemplates, TemplateKind, TemplateSlot}; +use egui::{Color32, vec2}; +use serde_json::{Value, json}; + +use crate::theme::Tokens; +use crate::{Dialog, EffectcraftApp}; + +#[derive(Clone, Default)] +struct State { + output: bool, + selected: String, + new_name: String, +} + +fn state_id() -> egui::Id { + egui::Id::new("rq-templates-dialog") +} + +/// Open the dialog for `kind` (`renderSettings` | `outputModule`). +pub fn open(app: &mut EffectcraftApp, ctx: &egui::Context, kind: &str) { + let output = TemplateKind::parse(kind) == Some(TemplateKind::OutputModule); + let t = &app.session.project.render_templates; + let k = if output { TemplateKind::OutputModule } else { TemplateKind::RenderSettings }; + let selected = t.default_name(k, TemplateSlot::Movie); + ctx.data_mut(|d| d.insert_temp(state_id(), State { output, selected, new_name: String::new() })); + app.dialog = Some(Dialog::RenderTemplates); +} + +fn names(t: &RenderTemplates, output: bool) -> Vec { + if output { t.output_module_list().into_iter().map(|m| m.name).collect() } else { t.render_settings_list().into_iter().map(|r| r.name).collect() } +} + +pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let mut st: State = ctx.data(|d| d.get_temp(state_id())).unwrap_or_default(); + let kind = if st.output { "outputModule" } else { "renderSettings" }; + let k = if st.output { TemplateKind::OutputModule } else { TemplateKind::RenderSettings }; + let templates = app.session.project.render_templates.clone(); + let list = names(&templates, st.output); + if !list.contains(&st.selected) { + st.selected = list.first().cloned().unwrap_or_default(); + } + let mut actions: Vec<(&'static str, Value)> = vec![]; + let mut close = false; + let title = if st.output { "Output Module Templates" } else { "Render Settings Templates" }; + super::dialogs::modal(ctx, title, vec2(600.0, 560.0), t, |ui| { + ui.label(egui::RichText::new("Defaults").font(Tokens::semibold(12.5)).color(t.text)); + egui::Grid::new("tpl-defaults").num_columns(2).spacing(vec2(14.0, 6.0)).show(ui, |ui| { + for slot in TemplateSlot::ALL { + ui.label(slot.label()); + let cur = templates.default_name(k, slot); + let mut pick = cur.clone(); + let r = egui::ComboBox::from_id_salt(("tpl-def", slot.label())).selected_text(&cur).width(300.0).show_ui(ui, |ui| { + for n in &list { + ui.selectable_value(&mut pick, n.clone(), n); + } + }); + app.auto.add(&format!("templates.default.{slot:?}"), r.response.rect, &cur); + if pick != cur { + actions.push(("renderQueue.setTemplateDefault", json!({"kind": kind, "slot": format!("{slot:?}"), "name": pick}))); + } + ui.end_row(); + } + }); + ui.add_space(12.0); + ui.separator(); + ui.label(egui::RichText::new("Settings").font(Tokens::semibold(12.5)).color(t.text)); + ui.horizontal(|ui| { + ui.label("Settings Name:"); + let r = egui::ComboBox::from_id_salt("tpl-select").selected_text(&st.selected).width(300.0).show_ui(ui, |ui| { + for n in &list { + ui.selectable_value(&mut st.selected, n.clone(), n); + } + }); + app.auto.add("templates.select", r.response.rect, &st.selected); + }); + let builtin = RenderTemplates::is_builtin(k, &st.selected) + && !match k { + TemplateKind::RenderSettings => templates.render_settings.iter().any(|r| r.name.eq_ignore_ascii_case(&st.selected)), + TemplateKind::OutputModule => templates.output_modules.iter().any(|m| m.name.eq_ignore_ascii_case(&st.selected)), + }; + // The selected template's settings. + let lines: Vec<(&'static str, String)> = if st.output { + templates.output_module(&st.selected).map(|m| m.describe()).unwrap_or_default() + } else { + templates.render_settings(&st.selected).map(|r| r.describe(None)).unwrap_or_default() + }; + egui::Frame::new().fill(t.field_bg).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + ui.set_min_height(180.0); + ui.set_width(ui.available_width()); + egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { + egui::Grid::new("tpl-lines").num_columns(2).spacing(vec2(12.0, 2.0)).show(ui, |ui| { + for (k, v) in &lines { + ui.label(egui::RichText::new(*k).color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(v).size(11.5)); + ui.end_row(); + } + }); + }); + }); + ui.horizontal(|ui| { + let r = ui.button("Duplicate"); + app.auto.add("templates.duplicate", r.rect, "Duplicate"); + if r.clicked() { + let mut n = format!("{} copy", st.selected); + let mut i = 2; + while list.iter().any(|x| x.eq_ignore_ascii_case(&n)) { + n = format!("{} copy {i}", st.selected); + i += 1; + } + actions.push(("renderQueue.saveTemplate", json!({"kind": kind, "name": n, "from": st.selected}))); + st.selected = n; + } + let r = ui.add_enabled(!builtin, egui::Button::new("Delete")); + app.auto.add("templates.delete", r.rect, "Delete"); + if r.clicked() { + actions.push(("renderQueue.deleteTemplate", json!({"kind": kind, "name": st.selected}))); + } + if builtin { + ui.label(egui::RichText::new("built-in").color(t.text_faint).size(11.0)); + } + }); + ui.add_space(8.0); + ui.separator(); + // New template from the selected (or only) Render Queue item. + let queue = &app.session.project.render_queue; + ui.horizontal(|ui| { + ui.label("New from Render Queue item:"); + let r = ui.add(egui::TextEdit::singleline(&mut st.new_name).hint_text("template name").desired_width(180.0)); + app.auto.add("templates.newName", r.rect, &st.new_name); + let item = queue.last().map(|i| i.id); + let ok = item.is_some() && !st.new_name.trim().is_empty(); + let r = ui.add_enabled(ok, egui::Button::new(format!("Save from #{}", queue.len().max(1)))); + app.auto.add("templates.saveFromItem", r.rect, "Save from item"); + if r.clicked() + && let Some(id) = item + { + actions.push(("renderQueue.saveTemplate", json!({"kind": kind, "name": st.new_name.trim(), "item": id}))); + st.selected = st.new_name.trim().to_string(); + st.new_name.clear(); + } + }); + ui.add_space(12.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)); + app.auto.add("templates.ok", r.rect, "OK"); + close = r.clicked(); + }); + }); + for (id, p) in actions { + if let Err(e) = app.session.execute(id, p) { + app.ui.status = e.to_string(); + } + } + ctx.data_mut(|d| d.insert_temp(state_id(), st)); + if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + app.dialog = None; + } +} diff --git a/crates/ui-egui/tests/render_queue_snapshot.rs b/crates/ui-egui/tests/render_queue_snapshot.rs index 5029a5f2..41ce13d2 100644 --- a/crates/ui-egui/tests/render_queue_snapshot.rs +++ b/crates/ui-egui/tests/render_queue_snapshot.rs @@ -41,3 +41,26 @@ fn render_queue_panel_snapshot() { } img.save(&out).unwrap(); } + +/// Edit ▸ Templates ▸ Output Module… (`RQ_TEMPLATES_SNAPSHOT=/abs/out.png`). +#[test] +#[ignore] +fn templates_dialog_snapshot() { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("renderQueue.add", json!({"output": "/renders/[compName].[fileExtension]"})).unwrap(); + s.execute("renderQueue.saveTemplate", json!({"kind": "outputModule", "name": "Web Delivery", "item": s.project.render_queue[0].id, "params": {"format": "webm", "resize": {"preset": "HDTV 720"}}})) + .unwrap(); + let kind = std::env::var("RQ_TEMPLATES_KIND").unwrap_or_else(|_| "outputModule".into()); + s.execute("app.templates", json!({"kind": kind})).unwrap(); + let mut app = Some(EffectcraftApp::new(s)); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| app.take().expect("app")); + h.run_steps(4); + assert!(h.state().dialog.is_some(), "the Templates dialog is open"); + let img = h.render().expect("render"); + let out = std::env::var("RQ_TEMPLATES_SNAPSHOT").unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out/rq_templates.png").into()); + if let Some(d) = std::path::Path::new(&out).parent() { + std::fs::create_dir_all(d).unwrap(); + } + img.save(&out).unwrap(); +} diff --git a/docs/parity.md b/docs/parity.md index 8fc67b9a..2b30c576 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -21,7 +21,7 @@ Tracking 88%, Effects 85%, Web 65%. What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; GPU -rendering of 3D runs and adjustment layers; Render Queue field render, crop/resize and templates; +rendering of 3D runs and adjustment layers; approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; OpenType features and variable font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; ScriptUI; Lumetri Scopes, Footage, Media Browser and Metadata panels; Content-Aware Fill; face tracking; and @@ -110,7 +110,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Area | Weighted parity | Remaining (agent-hours) | Biggest gaps | |---|---|---|---| | Layers | 88% | 4.8 | frame blending, collapse transformations, slip edit | -| Output | 90% | 3.0 | multiple output modules, pre-render (WebM with VP9 alpha + Opus and WAV/AIFF audio-only landed: VP9 key + inter frames with motion search, loop filter and rate control; Opus CELT-only) | +| Output | 97% | 0.8 | Render Settings complete (field render + 3:2 pulldown, effects/solo/guide/depth/blending/blur overrides, time sampling, storage overflow), Output Module crop/ROI/resize, alpha modes, post-render actions, PCM formats, templates with defaults, render logs, Notify (M10.2); WebM VP9 key + inter frames with motion search, loop filter and rate control. Left: Opus is CELT-only, Photoshop sequence output, overflow for movies only checks at file creation | | Audio | 85% | 0.5 | audio to keyframes | | 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 | ≈ 90% | 0.8 | scripting covers the documented core object model (AUT-2, M14.4: `app`, project items, comps, layers, properties and keyframes, text documents, markers, render queue, Script Console, `effectcraft-cli script`, MCP `run_script`); still missing: ScriptUI panels/dialogs, `.jsxbin`, sockets | diff --git a/docs/web.md b/docs/web.md index 041ef76c..72eb75ff 100644 --- a/docs/web.md +++ b/docs/web.md @@ -207,5 +207,3 @@ every request and reply, a render through an in-process offload). - Cancelling an analysis drops its partial result (the worker is terminated). - Stored media count against the origin's storage quota; `effectcraft.listStored()` shows usage, `removeStored` frees it (there is no storage manager in the UI yet). -- `engine.execute` rejects the spread Render Settings / Output Module keys of `renderQueue.add` - (`resolution`, `timeSpan`…) through its strict parameter check; use `ui.menu.invoke` as above.