Browse / github.com/storytold/effectcraft / patch
Export: add 16-bpc uncompressed QuickTime intermediates
Export: add 16-bpc uncompressed QuickTime intermediates Uncompressed QuickTime exports currently convert rendered frames to 8-bit pixels, losing precision before another compositor or finishing tool can read them. This adds **Uncompressed 16-bpc MOV** (`format: "uncompressed16"`, alias `b64a`) to the Render Queue and command API, converting float pixels directly to full-range, big-endian 16-bit ARGB. RGB over the composition background, straight/premultiplied RGB + Alpha, alpha-only output and PCM audio are supported. Format serialization, output bit-depth filenames, storage estimates, command documentation and roadmap gap 22 are updated. Uncompre
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- campbellcody (imported from pull request #2424, open)
- Base
362474a843ddb67555d4c2d19ad6a590388aad0e- Size
- +487 −56 in 21 files
- Review
- pending
- Shared
- 2026-10-28
- Id
3ab1a0b2048f357086275ea6
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/commands/render_queue.rs crates/engine/src/render_queue.rs crates/engine/src/rq_tests.rs crates/engine/src/tests_m145.rs crates/export/README.md crates/export/src/encode.rs crates/export/src/lib.rs crates/export/src/out.rs crates/export/src/uncompressed.rs crates/export/tests/it/uncompressed_mov.rs crates/gpu/src/shaders/advanced3d.wgsl crates/project/src/render_queue.rs crates/render/src/three_d/adv/shade.rs crates/render/src/three_d/adv/tests.rs crates/text/Cargo.toml crates/text/tests/it/main.rs crates/ui-egui/src/panels/render_queue.rs docs/file-format-parity.md docs/gaps.md docs/target-app-parity.md
@@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (open-issue count, crash list and localization share re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 (26.5.0.89)+> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-25 · **Change:** minor (16-bpc uncompressed QuickTime export, part of gap 22; readiness estimates unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in@@ -117,6 +117,10 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-25: Uncompressed 16-bpc QuickTime (`b64a`) export preserves rendered precision without+ an 8-bit intermediate, with RGB, straight/premultiplied alpha, alpha-only output and PCM audio.+ Part of gap 22; AVI and lossless-compressed intermediate codecs remain open.+ - 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar path into an editable Path 1 with the same geometry, as After Effects does; it used to apply to mask RotoBezier only, so a parametric shape path could not be converted at all@@ -203,7 +203,7 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) { } /// The `format` parameter's values.-const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff";+const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|uncompressed16|webm|png|jpeg|tiff|exr|gif|wav|aiff"; /// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest /// (captured by `crop.useRoi`).@@ -999,7 +999,7 @@ pub fn specs() -> Vec<CommandSpec> { "Add to Render Queue", ["Composition"], ["Cmd+M", "Cmd+Shift+/"],- "{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|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?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",+ "{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|uncompressed16|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?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}", can_add, add ),@@ -1019,7 +1019,7 @@ pub fn specs() -> Vec<CommandSpec> { "Output Module Settings...", [], None,- "{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, 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?}",+ "{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|uncompressed16|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, 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 ),@@ -178,6 +178,7 @@ pub fn output_bits(format: OutputFormat, depth: effectcraft_project::BitDepth) - OutputFormat::ExrSequence => 32, OutputFormat::PngSequence | OutputFormat::TiffSequence => project.min(16), OutputFormat::ProRes => 10,+ OutputFormat::Uncompressed16 => 16, OutputFormat::Wav | OutputFormat::Aiff => 16, _ => 8, }@@ -208,6 +209,7 @@ pub fn bytes_per_frame(om: &OutputModule, w: u32, h: u32, fps: f64) -> f64 { let bpp = if om.channels == effectcraft_project::render_queue::Channels::Rgba { 4 } else { 3 }; ((w as u64 * bpp).next_multiple_of(2) * h as u64) as f64 }+ OutputFormat::Uncompressed16 => w as f64 * h as f64 * 8.0, OutputFormat::WebM => mbps(8.0) * px / fps, OutputFormat::Gif => w as f64 * h as f64 * 0.5, OutputFormat::Wav | OutputFormat::Aiff => om.audio_sample_rate as f64 * 4.0 / fps,@@ -186,7 +186,7 @@ fn without_exporter() { s.execute("renderQueue.add", json!({})).unwrap(); assert!(!s.is_enabled("renderQueue.render")); let f = s.execute("renderQueue.formats", json!({})).unwrap();- assert_eq!(f["formats"].as_array().unwrap().len(), 13);+ assert_eq!(f["formats"].as_array().unwrap().len(), 14); assert_eq!(f["formats"][0]["available"], false); } @@ -798,3 +798,24 @@ fn replace_with_precomp_swaps_every_use_of_the_item_for_one_new_comp() { assert!(s.execute("file.replaceWithPrecomp", json!({"item": other.0})).is_err()); assert!(s.execute("file.replaceWithPrecomp", json!({"item": 999_999})).is_err()); }++#[test]+fn uncompressed16_output_is_agent_drivable_and_estimated_at_full_precision() {+ use effectcraft_project::BitDepth;+ use effectcraft_project::render_queue::{OutputFormat, OutputModule};+ let mut s = session();+ solid(&mut s, "#ff0000");+ s.execute("renderQueue.add", json!({"format": "uncompressed16", "channels": "rgba", "output": "Intermediate.mov"})).unwrap();+ let item = &s.project.render_queue[0];+ assert_eq!(item.output.format, OutputFormat::Uncompressed16);+ assert_eq!(crate::render_queue::bytes_per_frame(&item.output, 33, 20, 24.0), 5280.0);+ for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] {+ assert_eq!(crate::render_queue::output_bits(item.output.format, depth), 16);+ }+ let old = OutputModule::for_format(OutputFormat::Uncompressed);+ assert_eq!(crate::render_queue::bytes_per_frame(&old, 33, 20, 24.0), 2000.0);+ s.execute("renderQueue.setOutputModule", json!({"index": 1, "format": "uncompressed", "channels": "rgb"})).unwrap();+ assert_eq!(s.project.render_queue[0].output.format, OutputFormat::Uncompressed);+ s.execute("renderQueue.setOutputModule", json!({"index": 1, "format": "b64a", "channels": "alpha"})).unwrap();+ assert_eq!(s.project.render_queue[0].output.format, OutputFormat::Uncompressed16);+}new file mode 100644@@ -0,0 +1,43 @@+# EffectCraft export++Render Queue output modules, frame processing and movie/image/audio export in pure Rust.+Codec implementations are accessed through the workspace's FilmCraft and EffectCraft crates.++## Uncompressed QuickTime intermediates++- `uncompressed`: existing 8-bit RGB/ARGB (`raw `), including odd-width row padding.+- `uncompressed16`: full-range 16-bit ARGB (`b64a`), big-endian samples in A, R, G, B order,+ with PCM audio. Select **Uncompressed 16-bpc MOV** in the Render Queue or pass+ `{"format":"uncompressed16"}` to `renderQueue.add` / `renderQueue.setOutputModule`.++The 16-bpc path converts rendered float pixels directly, without an 8-bit intermediate.+RGB composites over the composition background with opaque alpha; RGB + Alpha respects the+Straight/Premultiplied setting; Alpha writes the matte into RGB with opaque alpha. Values are+rounded to 0–65535 and clipped to that range. Render Settings and project color depth still+apply: choose a 16/32-bpc project for source precision beyond 8 bits. This is integer output,+so it does not preserve HDR values outside 0–1.++The renderer supplies the project's output-space values. As with the existing raw MOV output,+no color-profile or alpha-association metadata is embedded; interpret those settings explicitly+in the receiving application. RGB also occupies 64 bits per pixel. At 1080p30 the video alone+is about 498 MB/s. Frames are limited to 256 MiB (including 7680×4320 at 16 bpc); one full-precision+frame is processed at a time to bound export working memory.++In-memory outputs (including browser downloads) are limited to 512 MiB and grow through+fallible allocations; oversized output returns an error. Native filesystem output streams+to disk without this file-size limit.++## Specifications used++- Apple **Technical Q&A QA1114**, edition **2002-02-07**,+ [48 bit & 64 bit Pixel Format support in QuickTime](https://developer.apple.com/library/archive/qa/qa2001/qa1114.html):+ `b64a`, component order, unsigned full-range 16-bit big-endian samples.+- Apple **QuickTime File Format**, online reference accessed **2026-10-25**,+ [Video sample description](https://developer.apple.com/documentation/quicktime-file-format/video_sample_description)+ and [Video sample data](https://developer.apple.com/documentation/quicktime-file-format/video_sample_data):+ visual sample descriptions and packed uncompressed RGB samples.++No third-party codec implementation was used as a source. Tests inspect stored sample values+and, when installed, invoke ffmpeg/ffprobe as external decode and metadata oracles. They cover+precision, odd dimensions, frame timing, channel modes and PCM audio. The external decode check+does not establish compatibility with every finishing application.@@ -1,5 +1,5 @@ //! Movie export: FilmCraft H.264, HEVC (`effectcraft-hevcenc`) and AV1 (`effectcraft-av1enc`)-//! → MP4 (+ AAC), ProRes and uncompressed `raw ` → MOV (+ PCM), muxed by+//! → MP4 (+ AAC), ProRes and uncompressed `raw ` / `b64a` → MOV (+ PCM), muxed by //! FilmCraft's ISO BMFF / QuickTime writer. use std::io::Write;@@ -28,6 +28,7 @@ pub(crate) struct Packet { pub(crate) trait VideoEncoder { fn sample_entry(&self) -> SampleEntry;+ /// Packed RGBA8, or big-endian RGBA16 for the uncompressed 16-bpc encoder. fn encode(&mut self, rgba: &[u8], index: u64) -> Result<Vec<Packet>>; fn flush(&mut self) -> Result<Vec<Packet>>; /// Edit-list media start (B-frame delay) in the track timescale.@@ -253,7 +254,7 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St let rate = job.settings.rate(comp); let fmt = job.output.format; let channels = match job.output.channels {- Channels::Rgba if !matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) => Channels::Rgb,+ Channels::Rgba if !matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed | OutputFormat::Uncompressed16) => Channels::Rgb, c => c, }; let mut venc: Box<dyn VideoEncoder> = match fmt {@@ -261,20 +262,27 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St OutputFormat::Hevc => Box::new(crate::hevc_av1::Hevc::new(w, h, rate, job.output)?), OutputFormat::Av1 => Box::new(crate::hevc_av1::Av1::new(w, h, rate, job.output)?), OutputFormat::Uncompressed => Box::new(crate::uncompressed::Raw::new(w, h, channels == Channels::Rgba)?),+ OutputFormat::Uncompressed16 => Box::new(crate::uncompressed::Raw::new16(w, h)?), _ => Box::new(ProRes::new(w, h, job.output.prores_profile, if channels == Channels::Alpha { Channels::Rgb } else { channels })), };- let brand = if matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) { Brand::Mov } else { Brand::Mp4 };- let batch = batch_size();- let render = |_: u64, img: effectcraft_raster::Image| -> Vec<u8> { job.pixels(&img, comp, channels, w, h) };+ let brand = if matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed | OutputFormat::Uncompressed16) { Brand::Mov } else { Brand::Mp4 };+ // Full-precision frames are large: keep just one rendered/packed frame in flight.+ let batch = if fmt == OutputFormat::Uncompressed16 { 1 } else { batch_size() };+ let render = |_: u64, img: effectcraft_raster::Image| -> Result<Vec<u8>> {+ if fmt == OutputFormat::Uncompressed16 {+ crate::uncompressed::pixels16(&img, comp.background, channels, job.output.alpha_mode)+ } else {+ Ok(job.pixels(&img, 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);- let first: Vec<Vec<u8>> = job.frames(comp, (0..first_end).collect(), render).await;+ let first: Vec<Result<Vec<u8>>> = job.frames(comp, (0..first_end).collect(), render).await; let mut pending = Vec::new();- for (k, px) in first.iter().enumerate() {- pending.extend(venc.encode(px, k as u64)?);+ for (k, px) in first.into_iter().enumerate() {+ pending.extend(venc.encode(&px?, k as u64)?); }- drop(first); st.advance(first_end)?; let path = job.place(job.path, 1);@@ -358,9 +366,9 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St let mut i = first_end; while i < total { let end = (i + batch).min(total);- let frames: Vec<Vec<u8>> = job.frames(comp, (i..end).collect(), render).await;- for (k, px) in frames.iter().enumerate() {- let ps = venc.encode(px, i + k as u64)?;+ let frames: Vec<Result<Vec<u8>>> = job.frames(comp, (i..end).collect(), render).await;+ for (k, px) in frames.into_iter().enumerate() {+ let ps = venc.encode(&px?, i + k as u64)?; write_video(&mut mux, ps)?; } write_audio_until(&mut mux, frame_end_time(end), &mut cursor)?;@@ -5,6 +5,7 @@ //! | 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 | //! | Uncompressed | QuickTime MOV (`raw `) | 8-bit RGB (depth 24, rows padded to even bytes) or ARGB (depth 32) | RGB + Alpha (straight or premultiplied) | PCM 16/24-bit or 32-bit float, mono/stereo |+//! | Uncompressed 16-bpc | QuickTime MOV (`b64a`) | 16-bit full-range ARGB, big-endian | RGB + Alpha (straight or premultiplied) | 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 | — |@@ -194,7 +195,7 @@ async fn export_with(cx: &Cx<'_>, progress: &mut dyn FnMut(&Progress) -> bool, t st.advance(0)?; let (w, h) = output_size(comp, cx.settings, cx.output); let mut report = match cx.output.format {- OutputFormat::H264 | OutputFormat::Hevc | OutputFormat::Av1 | OutputFormat::ProRes | OutputFormat::Uncompressed => {+ OutputFormat::H264 | OutputFormat::Hevc | OutputFormat::Av1 | OutputFormat::ProRes | OutputFormat::Uncompressed | OutputFormat::Uncompressed16 => { encode::movie(cx, comp, w, h, &mut st).await? } OutputFormat::Gif => gif_export(cx, comp, w, h, &mut st).await?,@@ -9,6 +9,9 @@ use crate::{ExportError, Result, io}; /// system. Called once per file; image sequences call it from the render threads. pub type Sink = dyn Fn(&str, Vec<u8>) + Send + Sync; +// Download/sink outputs hold the entire file in memory. Native filesystem output streams.+const MAX_MEMORY_OUTPUT_BYTES: u64 = 512 * 1024 * 1024;+ /// One output file being written. pub(crate) enum Out<'a> { File(BufWriter<std::fs::File>),@@ -56,7 +59,21 @@ impl Write for Out<'_> { fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { match self { Out::File(w) => w.write(buf),- Out::Mem { cur, .. } => cur.write(buf),+ Out::Mem { cur, .. } => {+ if buf.is_empty() {+ return Ok(0);+ }+ let len = u64::try_from(buf.len()).map_err(std::io::Error::other)?;+ let end = cur+ .position()+ .checked_add(len)+ .filter(|end| *end <= MAX_MEMORY_OUTPUT_BYTES)+ .ok_or_else(|| std::io::Error::other("in-memory exports are limited to 512 MiB; render a shorter span or use a filesystem output"))?;+ let end = usize::try_from(end).map_err(std::io::Error::other)?;+ let additional = end.saturating_sub(cur.get_ref().len());+ cur.get_mut().try_reserve_exact(additional).map_err(|e| std::io::Error::other(format!("cannot allocate in-memory export: {e}")))?;+ cur.write(buf)+ } } } fn flush(&mut self) -> std::io::Result<()> {@@ -75,3 +92,23 @@ impl Seek for Out<'_> { } } }++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn oversized_memory_output_returns_an_error_and_remains_usable() {+ let sink = |_: &str, _: Vec<u8>| {};+ let mut out = create(Some(&sink), "intermediate.mov").unwrap();+ // The old Cursor writer panics with capacity overflow before attempting an allocation.+ out.seek(SeekFrom::Start(isize::MAX as u64)).unwrap();+ assert_eq!(out.write(&[]).unwrap(), 0);+ assert!(out.write_all(&[1]).is_err());+ out.seek(SeekFrom::Start(MAX_MEMORY_OUTPUT_BYTES - 1)).unwrap();+ assert!(out.write_all(&[1, 2]).is_err());+ out.seek(SeekFrom::Start(0)).unwrap();+ out.write_all(&[1, 2, 3]).unwrap();+ assert_eq!(out.finish().unwrap(), 3);+ }+}@@ -6,7 +6,13 @@ //! "Uncompressed RGB" formats): pixels are stored top to bottom, components big-endian in //! A R G B / R G B order, and each row is padded to an even number of bytes (only depth 24 at odd //! widths needs it).+//!+//! The 16-bpc variant is `b64a`: full-range, big-endian 16-bit A R G B samples, per+//! Apple Technical Q&A QA1114 (2002-02-07). It always stores four components; RGB and alpha-only+//! output have opaque alpha. See this crate's README for specification references. +use effectcraft_project::render_queue::{AlphaMode, Channels};+use effectcraft_raster::Image; use filmcraft_isobmff::{CodecConfig, FourCc, SampleEntry, VideoParams}; use crate::encode::{Packet, VideoEncoder};@@ -16,6 +22,60 @@ pub(crate) struct Raw { w: u32, h: u32, alpha: bool,+ sixteen: bool,+}++// Allows an 8K ARGB16 frame, while bounding allocations even for hostile frame dimensions.+const MAX_FRAME_BYTES: usize = 256 * 1024 * 1024;++fn frame_buffer(size: usize) -> Result<Vec<u8>> {+ if size > MAX_FRAME_BYTES {+ return Err(ExportError::Unsupported("uncompressed frames are limited to 256 MiB; reduce the output dimensions".into()));+ }+ let mut data = Vec::new();+ data.try_reserve_exact(size).map_err(|e| ExportError::Encode(format!("cannot allocate uncompressed frame ({size} bytes): {e}")))?;+ data.resize(size, 0);+ Ok(data)+}++/// Convert the rendered, premultiplied float image directly to big-endian RGBA16, without+/// passing through RGBA8. Color conversion has already happened in the renderer.+pub(crate) fn pixels16(img: &Image, background: [f32; 3], channels: Channels, mode: AlphaMode) -> Result<Vec<u8>> {+ let pixels = (img.width as usize).checked_mul(img.height as usize).ok_or_else(|| ExportError::Encode("frame too large".into()))?;+ let size = pixels.checked_mul(8).ok_or_else(|| ExportError::Encode("frame too large".into()))?;+ if img.data.len() != pixels || (channels == Channels::Rgb && !background.iter().all(|c| c.is_finite())) {+ return Err(ExportError::Encode("invalid uncompressed 16-bpc frame or background".into()));+ }+ let mut data = frame_buffer(size)?;+ for (p, out) in img.data.iter().zip(data.as_chunks_mut::<8>().0) {+ if !p.iter().all(|c| c.is_finite()) {+ return Err(ExportError::Encode("uncompressed 16-bpc frame contains non-finite color values".into()));+ }+ let [r, g, b, a] = *p;+ let alpha = a.clamp(0.0, 1.0);+ let rgba = match channels {+ Channels::Rgb => {+ let [br, bg, bb] = background;+ let k = 1.0 - alpha;+ [r + br * k, g + bg * k, b + bb * k, 1.0]+ }+ Channels::Rgba if mode == AlphaMode::Straight => {+ if a > 0.0 {+ [r / a, g / a, b / a, alpha]+ } else {+ [0.0; 4]+ }+ }+ Channels::Rgba => [r, g, b, alpha],+ Channels::Alpha => [alpha, alpha, alpha, 1.0],+ };+ for (c, bytes) in rgba.into_iter().zip(out.as_chunks_mut::<2>().0) {+ // Clamping precedes the integer cast, including any overflow from unpremultiplying.+ let v = (c.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16;+ *bytes = v.to_be_bytes();+ }+ }+ Ok(data) } impl Raw {@@ -24,19 +84,46 @@ impl Raw { if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 { return Err(ExportError::Unsupported(format!("uncompressed QuickTime frames must be 1–65535 px, not {w}×{h}"))); }- Ok(Raw { w, h, alpha })+ let raw = Raw { w, h, alpha, sixteen: false };+ raw.frame_bytes()?;+ Ok(raw)+ }++ pub(crate) fn new16(w: u32, h: u32) -> Result<Raw> {+ let mut raw = Self::new(w, h, true)?;+ raw.sixteen = true;+ raw.frame_bytes()?;+ Ok(raw) } fn depth(&self) -> u16 {- if self.alpha { 32 } else { 24 }+ if self.sixteen {+ 64+ } else if self.alpha {+ 32+ } else {+ 24+ } } /// Bytes per stored row (even). fn row_bytes(&self) -> usize {- let n = self.w as usize * if self.alpha { 4 } else { 3 };+ let n = self.w as usize+ * if self.sixteen {+ 8+ } else if self.alpha {+ 4+ } else {+ 3+ }; n + (n & 1) } + fn frame_bytes(&self) -> Result<usize> {+ let size = self.row_bytes().checked_mul(self.h as usize).filter(|n| *n <= MAX_FRAME_BYTES);+ size.ok_or_else(|| ExportError::Unsupported("uncompressed frames are limited to 256 MiB; reduce the output dimensions".into()))+ }+ /// The `raw ` video sample description payload (after the 8-byte box header). fn description(&self) -> Vec<u8> { let mut b = Vec::with_capacity(78);@@ -65,7 +152,7 @@ impl Raw { impl VideoEncoder for Raw { fn sample_entry(&self) -> SampleEntry {- let fourcc = FourCc(*b"raw ");+ let fourcc = FourCc(if self.sixteen { *b"b64a" } else { *b"raw " }); SampleEntry { format: fourcc, data_reference_index: 1,@@ -80,8 +167,19 @@ impl VideoEncoder for Raw { fn encode(&mut self, rgba: &[u8], _index: u64) -> Result<Vec<Packet>> { let (w, h) = (self.w as usize, self.h as usize); let row = self.row_bytes();- let size = row.checked_mul(h).ok_or_else(|| ExportError::Encode("frame too large".into()))?;- let mut data = vec![0u8; size];+ let size = self.frame_bytes()?;+ let input_bpp = if self.sixteen { 8 } else { 4 };+ let expected = w.checked_mul(h).and_then(|n| n.checked_mul(input_bpp)).ok_or_else(|| ExportError::Encode("frame too large".into()))?;+ if rgba.len() != expected {+ return Err(ExportError::Encode(format!("uncompressed frame needs {expected} bytes, received {}", rgba.len())));+ }+ let mut data = frame_buffer(size)?;+ if self.sixteen {+ for (p, o) in rgba.as_chunks::<8>().0.iter().zip(data.as_chunks_mut::<8>().0) {+ *o = [p[6], p[7], p[0], p[1], p[2], p[3], p[4], p[5]];+ }+ return Ok(vec![Packet { data, key: true, cto: 0 }]);+ } for (y, out) in data.chunks_exact_mut(row).enumerate() { let src = rgba.get(y * w * 4..(y + 1) * w * 4).ok_or_else(|| ExportError::Encode("short frame".into()))?; for (p, o) in src.as_chunks::<4>().0.iter().zip(out.chunks_exact_mut(if self.alpha { 4 } else { 3 })) {@@ -128,5 +226,35 @@ mod tests { assert!(Raw::new(0, 1, false).is_err()); assert!(Raw::new(70_000, 1, true).is_err()); assert!(Raw::new(2, 2, true).unwrap().encode(&[0; 4], 0).is_err());+ assert!(Raw::new16(65_535, 65_535).is_err());+ assert!(Raw::new16(2, 2).unwrap().encode(&[0; 4], 0).is_err());+ }++ #[test]+ fn argb16_preserves_low_bytes_and_places_alpha_first() {+ let mut r = Raw::new16(1, 1).unwrap();+ let rgba = [0x12, 0x34, 0xab, 0xcd, 0x01, 0x02, 0x89, 0xab];+ assert_eq!(r.encode(&rgba, 0).unwrap()[0].data, [0x89, 0xab, 0x12, 0x34, 0xab, 0xcd, 0x01, 0x02]);+ let e = r.sample_entry();+ assert_eq!(e.format.0, *b"b64a");+ assert_eq!(e.video.unwrap().depth, 64);+ }++ #[test]+ fn float_conversion_keeps_precision_and_channel_semantics() {+ let img = Image::filled(1, 1, [0.125, 0.0625, 0.03125, 0.5]);+ let values = |channels, mode| -> Vec<u16> {+ pixels16(&img, [0.25; 3], channels, mode).unwrap().as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b)).collect()+ };+ assert_eq!(values(Channels::Rgba, AlphaMode::Straight), [16384, 8192, 4096, 32768]);+ assert_eq!(values(Channels::Rgba, AlphaMode::Premultiplied), [8192, 4096, 2048, 32768]);+ assert_eq!(values(Channels::Rgb, AlphaMode::Straight), [16384, 12288, 10240, 65535]);+ assert_eq!(values(Channels::Alpha, AlphaMode::Straight), [32768, 32768, 32768, 65535]);+ let transparent = Image::filled(1, 1, [0.0; 4]);+ assert_eq!(pixels16(&transparent, [0.0; 3], Channels::Rgba, AlphaMode::Straight).unwrap(), [0; 8]);+ let hdr = Image::filled(1, 1, [-1.0, 2.0, 0.5, 1.0]);+ assert_eq!(pixels16(&hdr, [0.0; 3], Channels::Rgba, AlphaMode::Straight).unwrap(), [0, 0, 255, 255, 128, 0, 255, 255]);+ let bad = Image::filled(1, 1, [f32::NAN, 0.0, 0.0, 1.0]);+ assert!(pixels16(&bad, [0.0; 3], Channels::Rgb, AlphaMode::Straight).is_err()); } }@@ -195,12 +195,21 @@ fn uncompressed_rgba_straight() { #[test] fn uncompressed_with_pcm_audio() {- let d = out_dir("uncompressed-audio");+ check_pcm_audio(OutputFormat::Uncompressed);+}++#[test]+fn uncompressed16_with_pcm_audio() {+ check_pcm_audio(OutputFormat::Uncompressed16);+}++fn check_pcm_audio(format: OutputFormat) {+ let d = out_dir(&format!("uncompressed-audio-{format:?}")); let wav = d.join("tone.wav"); write_tone(&wav, 1.0); let (p, cid) = project(Some(&wav)); let pool = MediaPool::new();- let om = OutputModule { audio: AudioOutput::Auto, audio_format: AudioFormat::S24, ..OutputModule::for_format(OutputFormat::Uncompressed) };+ let om = OutputModule { audio: AudioOutput::Auto, audio_format: AudioFormat::S24, ..OutputModule::for_format(format) }; let path = d.join("comp.mov"); assert!(run(&p, cid, &pool, &om, &path).audio); let bytes = std::fs::read(&path).expect("read");@@ -220,3 +229,77 @@ fn uncompressed_with_pcm_audio() { assert!((left - 0.3536).abs() < 0.01, "left RMS {left}"); } }++/// An independent float-render reference plus an external decoder: both must retain the+/// low eight bits, alpha association, frame order, and odd-sized frame geometry.+#[test]+fn uncompressed16_precision_channels_and_external_decode() {+ use effectcraft_export::render_queue::AlphaMode;+ use effectcraft_project::BitDepth;+ use effectcraft_render::{NoFootage, RenderOpts, Renderer};++ let (mut p, cid) = project(None);+ p.settings.bit_depth = BitDepth::Bpc32;+ for item in p.items.values_mut() {+ if let ItemKind::Solid(s) = &mut item.kind {+ s.color = [0.12345, 0.34567, 0.56789];+ }+ }+ p.comp_mut(cid).unwrap().layers[0].props.prop_mut("transform/opacity").unwrap().value = effectcraft_keyframe::Value::Scalar(43.21);+ let renderer = Renderer::new(&p, &NoFootage, RenderOpts::default());+ for (name, channels, alpha_mode) in [+ ("rgb", Channels::Rgb, AlphaMode::Straight),+ ("straight", Channels::Rgba, AlphaMode::Straight),+ ("premultiplied", Channels::Rgba, AlphaMode::Premultiplied),+ ("alpha", Channels::Alpha, AlphaMode::Straight),+ ] {+ let dir = out_dir(&format!("uncompressed16-{name}"));+ let path = dir.join("comp.mov");+ let om = OutputModule { channels, alpha_mode, ..OutputModule::for_format(OutputFormat::Uncompressed16) };+ let report = run(&p, cid, &NoFootage, &om, &path);+ assert_eq!((report.width, report.height, report.frames), (W, H, FRAMES));+ let bytes = std::fs::read(&path).unwrap();+ let mov = filmcraft_isobmff::open(bytes.as_slice()).unwrap();+ let track = mov.tracks.iter().find(|t| t.kind == TrackKind::Video).unwrap();+ assert_eq!(track.entries[0].format.0, *b"b64a");+ assert_eq!(track.entries[0].video.as_ref().unwrap().depth, 64);+ assert_eq!(track.samples.len(), FRAMES as usize);+ let mut stored_rgba = Vec::new();+ for (k, sample) in track.samples.iter().enumerate() {+ assert_eq!(sample.duration, 1);+ assert_eq!(sample.size, W * H * 8);+ let data = &bytes[sample.offset as usize..][..sample.size as usize];+ let img = renderer.comp_frame(cid, FrameRate::new(10, 1).tick_of(k as i64));+ let background = p.comp(cid).unwrap().background;+ for (src, pixel) in img.data.iter().zip(data.as_chunks::<8>().0) {+ let c: Vec<u16> = pixel.as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b)).collect();+ let rgba = [c[1], c[2], c[3], c[0]];+ let a = src[3] as f64;+ let expected = match channels {+ Channels::Rgb => [+ src[0] as f64 + background[0] as f64 * (1.0 - a),+ src[1] as f64 + background[1] as f64 * (1.0 - a),+ src[2] as f64 + background[2] as f64 * (1.0 - a),+ 1.0,+ ],+ Channels::Rgba if alpha_mode == AlphaMode::Straight && a > 0.0 => [src[0] as f64 / a, src[1] as f64 / a, src[2] as f64 / a, a],+ Channels::Rgba => src.map(f64::from),+ Channels::Alpha => [a, a, a, 1.0],+ };+ for (got, want) in rgba.into_iter().zip(expected) {+ let want = (want.clamp(0.0, 1.0) * 65535.0).round() as u16;+ assert!(got.abs_diff(want) <= 1, "{name}, frame {k}: {got} != {want}");+ stored_rgba.extend_from_slice(&got.to_le_bytes());+ }+ }+ }+ let samples: Vec<u16> = stored_rgba.as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes(*b)).collect();+ assert!(samples.iter().any(|v| v % 257 != 0), "must retain precision beyond 8 bits");+ if let Some(streams) = ffprobe_streams(&path) {+ assert_eq!(streams.trim(), format!("rawvideo|video|b64a|{W}|{H}|rgba64be"));+ let out = Command::new("ffmpeg").args(["-v", "error", "-i"]).arg(&path).args(["-f", "rawvideo", "-pix_fmt", "rgba64le", "-"]).output().unwrap();+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));+ assert_eq!(out.stdout, stored_rgba, "external decode must preserve all 16 bits ({name})");+ }+ }+}@@ -194,6 +194,25 @@ fn tap_hash(x: i32, y: i32) -> u32 { return h; } +fn tap_jitter_at(x: i32, y: i32) -> vec2<f32> {+ let h = tap_hash(x, y);+ return vec2<f32>(f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5, f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5);+}++// shade::tap_jitter: the hash at the four corners of the receiver's 1/8-texel lattice+// cell (cx, cy), blended bilinearly by the receiver's position w in the cell.+fn tap_jitter(cx: i32, cy: i32, w: vec2<f32>, i: i32, j: i32) -> vec2<f32> {+ let x = cx + i * 7919;+ let y = cy + j * 7919;+ let c00 = tap_jitter_at(x, y);+ let c10 = tap_jitter_at(x + 1, y);+ let c01 = tap_jitter_at(x, y + 1);+ let c11 = tap_jitter_at(x + 1, y + 1);+ let a = c00 + (c10 - c00) * w.x;+ let b = c01 + (c11 - c01) * w.x;+ return a + (b - a) * w.y;+}+ const PCF_MAX_TAPS: i32 = 10; const PLANE_SLOPE_MAX: f32 = 64.0; @@ -244,7 +263,8 @@ fn shadow_factor(m: u32, p: vec3<f32>, ng: vec3<f32>, n_l: f32) -> f32 { // shadows off their casters), each texel compared against the receiver plane's // depth at its centre so wide kernels never self-shadow a lit surface, an N×N grid // jittered by an integer hash (bit-identical across backends) of the sub-texel- // cell and tap index, and bilinear compares of the four nearest texel centres.+ // lattice and tap index, blended across the receiver's lattice cell, and bilinear+ // compares of the four nearest texel centers. // extra.y is the wanted penumbra in output pixels; dividing by the receiver's // texel footprint keeps it in output pixels at any map resolution, and the reach // is at least two texels so hard edges ramp over texel steps instead of showing them.@@ -268,16 +288,17 @@ fn shadow_factor(m: u32, p: vec3<f32>, ng: vec3<f32>, n_l: f32) -> f32 { let n = clamp(i32(ceil(reach)) + 1, 5, PCF_MAX_TAPS); let step = reach / f32(n - 1); let half = f32(n - 1) * 0.5;- let cx = i32(floor(u * 8.0));- let cy = i32(floor(v * 8.0));+ let gu = u * 8.0;+ let gv = v * 8.0;+ let cx = i32(floor(gu));+ let cy = i32(floor(gv));+ let wc = vec2<f32>(gu - floor(gu), gv - floor(gv)); var lit = 0.0; for (var j = 0; j < n; j = j + 1) { for (var i = 0; i < n; i = i + 1) {- let h = tap_hash(cx + i * 7919, cy + j * 7919);- let jx = f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5;- let jy = f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5;- let su = u + ((f32(i) - half) + jx) * step;- let sv = v + ((f32(j) - half) + jy) * step;+ let jit = tap_jitter(cx, cy, wc, i, j);+ let su = u + ((f32(i) - half) + jit.x) * step;+ let sv = v + ((f32(j) - half) + jit.y) * step; // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the // four is compared against the receiver plane at that centre. let tu = su - 0.5;@@ -525,15 +525,18 @@ pub enum OutputFormat { /// Uncompressed 8-bit RGB / ARGB (`raw `) in QuickTime (+ PCM audio), for handing to an /// external encoder. Uncompressed,+ /// Uncompressed 16-bit ARGB (`b64a`) in QuickTime (+ PCM audio).+ Uncompressed16, } impl OutputFormat {- pub const ALL: [OutputFormat; 13] = [+ pub const ALL: [OutputFormat; 14] = [ OutputFormat::H264, OutputFormat::Hevc, OutputFormat::Av1, OutputFormat::ProRes, OutputFormat::Uncompressed,+ OutputFormat::Uncompressed16, OutputFormat::WebM, OutputFormat::PngSequence, OutputFormat::JpegSequence,@@ -559,12 +562,13 @@ impl OutputFormat { OutputFormat::Hevc => "HEVC (H.265)", OutputFormat::Av1 => "AV1", OutputFormat::Uncompressed => "QuickTime (Uncompressed)",+ OutputFormat::Uncompressed16 => "Uncompressed 16-bpc MOV", } } pub fn extension(self) -> &'static str { match self { OutputFormat::H264 => "mp4",- OutputFormat::ProRes | OutputFormat::Uncompressed => "mov",+ OutputFormat::ProRes | OutputFormat::Uncompressed | OutputFormat::Uncompressed16 => "mov", OutputFormat::PngSequence => "png", OutputFormat::JpegSequence => "jpg", OutputFormat::TiffSequence => "tif",@@ -580,7 +584,16 @@ impl OutputFormat { matches!(self, OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence) } pub fn is_movie(self) -> bool {- matches!(self, OutputFormat::H264 | OutputFormat::ProRes | OutputFormat::Uncompressed | OutputFormat::WebM | OutputFormat::Hevc | OutputFormat::Av1)+ matches!(+ self,+ OutputFormat::H264+ | OutputFormat::ProRes+ | OutputFormat::Uncompressed+ | OutputFormat::Uncompressed16+ | OutputFormat::WebM+ | OutputFormat::Hevc+ | OutputFormat::Av1+ ) } /// Formats whose video codec options live in [`OutputModule::codec`] (HEVC, AV1). pub fn has_codec_options(self) -> bool {@@ -602,7 +615,7 @@ impl OutputFormat { let even = |v: u32| if v < 2 { 2 } else { v & !1 }; if matches!(self, OutputFormat::H264 | OutputFormat::Hevc) { (even(w), even(h)) } else { (w, h) } }- /// Parse a format name: `h264`, `mp4`, `prores`, `mov`, `uncompressed`, `png`, `jpeg`/`jpg`,+ /// Parse a format name: `h264`, `mp4`, `prores`, `mov`, `uncompressed`/`uncompressed16`, `png`, `jpeg`/`jpg`, /// `tiff`/`tif`, `exr`, `gif`, or a label. pub fn from_name(s: &str) -> Option<OutputFormat> { let n = s.trim().to_ascii_lowercase().replace([' ', '.', '-', '_'], "");@@ -612,6 +625,7 @@ impl OutputFormat { "av1" | "av01" | "mp4av1" => OutputFormat::Av1, "prores" | "mov" | "quicktime" | "quicktimeprores" => OutputFormat::ProRes, "uncompressed" | "quicktimeuncompressed" | "rawvideo" => OutputFormat::Uncompressed,+ "uncompressed16" | "b64a" | "uncompressed16bpcmov" | "quicktime(uncompressed16bpc)" => OutputFormat::Uncompressed16, "png" | "pngsequence" => OutputFormat::PngSequence, "jpg" | "jpeg" | "jpegsequence" => OutputFormat::JpegSequence, "tif" | "tiff" | "tiffsequence" => OutputFormat::TiffSequence,@@ -1682,6 +1696,25 @@ mod tests { assert_eq!(serde_json::from_str::<OutputModule>(&json).unwrap().format, f); } + #[test]+ fn uncompressed16_format_survives_module_serialization() {+ let f = OutputFormat::Uncompressed16;+ for name in ["uncompressed16", "Uncompressed16", "b64a", f.label()] {+ assert_eq!(OutputFormat::from_name(name), Some(f), "{name}");+ }+ assert!(OutputFormat::ALL.contains(&f));+ assert!(f.is_movie() && f.supports_alpha() && f.supports_audio());+ assert_eq!(f.coded_size(3, 5), (3, 5));+ let mut m = OutputModule::for_format(OutputFormat::Uncompressed);+ m.channels = Channels::Rgba;+ m.output = "/out/Comp 1.mov".into();+ m.set_format(f);+ assert_eq!((m.output.as_str(), m.channels), ("/out/Comp 1.mov", Channels::Rgba));+ let json = serde_json::to_string(&m).unwrap();+ assert_eq!(serde_json::from_str::<OutputModule>(&json).unwrap(), m);+ assert_eq!(f.with_extension("Comp 1.mp4"), "Comp 1.mov");+ }+ #[test] fn hevc_av1_codec_options() { assert_eq!(OutputFormat::from_name("H.265"), Some(OutputFormat::Hevc));@@ -217,6 +217,25 @@ fn tap_hash(x: i32, y: i32) -> u32 { h } +/// The jitter of tap (`i`, `j`), each axis in [−0.5, 0.5): [`tap_hash`] values at the four+/// corners of the receiver's 1/8-texel lattice cell (`cx`, `cy`), blended bilinearly by the+/// receiver's position in the cell (`wx`, `wy`). The jitter is continuous in the receiver's+/// map position, so a position that differs between backends in its last bits moves the+/// taps by an amount proportional to that difference and does not switch them to another+/// cell's jitter.+fn tap_jitter(cx: i32, cy: i32, wx: f32, wy: f32, i: i32, j: i32) -> (f32, f32) {+ let corner = |dx: i32, dy: i32| -> (f32, f32) {+ let h = tap_hash(cx.wrapping_add(dx).wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(dy).wrapping_add(j.wrapping_mul(7919)));+ (((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5, (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5)+ };+ let (c00, c10, c01, c11) = (corner(0, 0), corner(1, 0), corner(0, 1), corner(1, 1));+ let ax = c00.0 + (c10.0 - c00.0) * wx;+ let bx = c01.0 + (c11.0 - c01.0) * wx;+ let ay = c00.1 + (c10.1 - c00.1) * wx;+ let by = c01.1 + (c11.1 - c01.1) * wx;+ (ax + (bx - ax) * wy, ay + (by - ay) * wy)+}+ /// Upper bound of the PCF grid's taps per axis. The grid grows with the reach so taps /// stay about one texel apart for moderate penumbrae; the cap bounds the per-fragment /// cost of very wide ones (bilinear taps still overlap and the jitter dithers the rest).@@ -232,8 +251,9 @@ const PLANE_SLOPE_MAX: f32 = 64.0; /// /// PCF over an N×N tap grid (5 ≤ N ≤ [`PCF_MAX_TAPS`]) spread over a reach derived /// from `radius` (the wanted penumbra in output pixels) divided by the receiver's-/// texel footprint, each tap jittered by an integer hash of the receiver's-/// sub-texel cell and the tap index, and each tap comparing against the four+/// texel footprint, each tap jittered by [`tap_jitter`] (an integer hash of the+/// receiver's sub-texel lattice and the tap index, blended across the receiver's+/// lattice cell), and each tap comparing against the four /// nearest texel centres with bilinear weights. The jitter detaches the tap grid /// from the texel axes (axis-aligned binary taps are what stair-step a straight /// shadow edge), and keying it below texel size keeps the dither finer than an@@ -301,13 +321,13 @@ pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], ng: [f32; 3], n let n = (reach.ceil() as i32 + 1).clamp(5, PCF_MAX_TAPS); let step = reach / (n - 1) as f32; let half = (n - 1) as f32 * 0.5;- let (cx, cy) = ((u * 8.0).floor() as i32, (v * 8.0).floor() as i32);+ let (gu, gv) = (u * 8.0, v * 8.0);+ let (cx, cy) = (gu.floor() as i32, gv.floor() as i32);+ let (wx, wy) = (gu - gu.floor(), gv - gv.floor()); let mut lit = 0.0; for j in 0..n { for i in 0..n {- let h = tap_hash(cx.wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(j.wrapping_mul(7919)));- let jx = ((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5;- let jy = (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5;+ let (jx, jy) = tap_jitter(cx, cy, wx, wy, i, j); let (su, sv) = (u + ((i as f32 - half) + jx) * step, v + ((j as f32 - half) + jy) * step); // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the // four is compared against the receiver plane at that centre.@@ -887,6 +887,26 @@ fn penumbra_dithers_along_a_straight_edge() { assert!(distinct(&within) >= 4, "coverage inside one texel takes {} distinct values", distinct(&within)); } +#[test]+fn penumbra_jitter_is_continuous_across_its_lattice() {+ // Receiver pairs 1/5000 texel either side of a line of the 1/8-texel jitter lattice,+ // across u and across v, inside the penumbra. The GPU places a receiver within a few+ // ulps of the CPU, so coverage that jumps at a lattice line differs between the two+ // backends there; both sides of each line must agree within one 8-bit level.+ let (m, texels) = wall_map(64, 1.0, 6.0);+ let f = |p: [f32; 2]| shade::shadow_factor(&m, &texels, [p[0], p[1], 13.5], FACING, 1.0);+ let mut worst = 0.0f32;+ for k in 0..16 {+ let line = k as f32 * 0.125;+ for (a, b) in [([-16.0 + line - 2e-4, 0.3], [-16.0 + line + 2e-4, 0.3]), ([-16.3, line - 2e-4], [-16.3, line + 2e-4])] {+ let (fa, fb) = (f(a), f(b));+ assert!(fa > 0.05 && fa < 0.95, "{a:?} lies in the penumbra: {fa}");+ worst = worst.max((fa - fb).abs());+ }+ }+ assert!(worst < 1.0 / 255.0, "coverage jumps by {worst} across a jitter lattice line");+}+ #[test] fn penumbra_width_is_independent_of_map_resolution() { // One wall (edge at world x = −16) in a 64-texel map at 1 texel per world unit and@@ -20,3 +20,8 @@ harfrust = { workspace = true } unicode-bidi = "0.3.18" # UAX #14 line breaking (Apache-2.0). unicode-linebreak = "0.1.5"++# This startup check needs a fresh process: neighboring tests initialize the global font scan.+[[test]]+name = "system_font_menu"+path = "tests/it/system_font_menu.rs"@@ -1,5 +1,4 @@-//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).+//! General integration cases. The system-font startup check runs in its own test binary. mod script_fallback;-mod system_font_menu; mod vertical_punctuation;@@ -257,7 +257,7 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat], templates: &Ren } 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::Uncompressed | OutputFormat::Wav | OutputFormat::Aiff) {+ if matches!(o.format, OutputFormat::ProRes | OutputFormat::Uncompressed | OutputFormat::Uncompressed16 | OutputFormat::Wav | OutputFormat::Aiff) { for f in [AudioFormat::S16, AudioFormat::S24, AudioFormat::F32] { v.push((mark(o.audio_format == f, &tr_args("Sample Format: {}", &[&(f.label())])), json!({"audioFormat": f.label()}))); }@@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026+> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-25 · **Change:** minor (16-bpc uncompressed QuickTime output, stored-sample and external-decode evidence) · **Target:** Adobe After Effects 2026 Every format After Effects reads or writes, with EffectCraft's support. After Effects' list comes from its installed bundle (26.5.0.89: `Info.plist` `CFBundleDocumentTypes`, `Plug-ins/Format`,@@ -96,15 +96,20 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | Mocha shapes | R (Mocha AE) | own documented JSON | — | Mocha's format is not public | | Vanishing Point `.vpe` | R | none | — | no public specification; the menu entry explains why | -## Render queue outputs (`OutputFormat::ALL`, 13)+## Render queue outputs (`OutputFormat::ALL`, 14) H.264 MP4, HEVC MP4, AV1 MP4, ProRes MOV, uncompressed 8-bit RGB / RGB + Alpha MOV (`raw `, + PCM;-#372), WebM (VP9 or AV1 + Opus), PNG / JPEG / TIFF / OpenEXR sequences, animated GIF, WAV, AIFF.-Missing against After Effects + Media Encoder: 16-bpc uncompressed, AVI and lossless-compressed+#372), uncompressed 16-bit ARGB MOV (`b64a`, + PCM; direct float-to-16-bit output), WebM (VP9 or AV1 + Opus), PNG / JPEG / TIFF / OpenEXR sequences, animated GIF, WAV, AIFF.+Missing against After Effects + Media Encoder: AVI and lossless-compressed single-file video (Animation / PNG codecs), DPX / Targa / Photoshop sequences, MXF, DNxHR, hardware encoders ([hardware-parity.md](hardware-parity.md)), and rendering safely over an existing file (#325). +The 16-bpc writer supports RGB over the comp background, straight/premultiplied RGB + Alpha, and+alpha-only output. Stored samples and external ffmpeg decode are tested, including odd dimensions+and PCM audio. No color-profile or alpha-association metadata is embedded; broader application+interoperability remains unverified. See [export specifications and limits](../crates/export/README.md).+ ## Estimate **≈ 130–230 h**: `.aep` / `.aepx` import 80–160 h (after the owner decision), wiring TGA / DPX /@@ -1,6 +1,6 @@ # Where EffectCraft falls short -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (path-editing layer bounds, #1220; issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89)+> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-25 · **Change:** minor (gap 22: 16-bpc uncompressed QuickTime export; other delivery gaps remain) · **Target:** Adobe After Effects 2026 (26.5.0.89) An honest assessment of how far EffectCraft is from being a real replacement for After Effects, and the work that closes the gap. This document is meant for contributors and agents choosing@@ -78,7 +78,7 @@ are grouped by area; the issue tracker has the rest. | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | | 20 | **Multi-monitor and video output** | #486: any panel can move into a native window of its own (panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so onto another screen, restored with the workspace; still missing: dragging tabs between those windows, app shortcuts while one has the focus, named workspaces remembering them, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) | | 21 | **Small 26.x features** | Proportional Scrubbing (26.2), pasting Illustrator's own clipboard data as shapes (26.3; SVG markup on the clipboard pastes as a shape layer, #738), variable-font filter (26.3), the rebuilt Effect Controls (26.5). Percentage guides (View ▸ Add Percentage Guide, Convert Guides to Percentage / Pixels) and effect colour labels (Effect Controls header bar and right-click Label menu, `effect.setLabel`) are in (#1095), built from the release notes' one-line descriptions and never compared with After Effects: the Timeline's effect rows wear the label as a colour bar on the left edge (#1509), but After Effects' exact UI there is unknown | Users of the current version expect them | 10–20 h left | — · [ui-parity.md](ui-parity.md) |-| 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file intermediates are 8-bit uncompressed QuickTime only (#372 closed by #778: no 16-bpc, AVI or lossless-compressed codec); #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) |+| 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file uncompressed intermediates support 8-bit RGB/ARGB and 16-bit ARGB QuickTime (`b64a`, direct float conversion; stored-sample and external-decode tests); AVI and lossless-compressed codecs remain; #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) | | 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 and #611 closed | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) | | 24 | **Timeline depth requests** | #601 expand precomps in place, #602 reorderable render stack, #604 guide-only effects, #425 keyframe confusion (#355 and #428 closed) | Power-user speed | 15–30 h | G2 · [ui-parity.md](ui-parity.md) | | 25 | **Accessibility** | AccessKit is enabled in eframe and tests read focus labels; the Project panel has After Effects' arrow-key navigation and named, stateful AccessKit nodes (#776), but the other panels have had no screen-reader pass or keyboard navigation audit, and there is no contrast check | Excludes users; After Effects supports screen readers poorly too | 15–30 h | G9 · [ui-parity.md](ui-parity.md) |@@ -335,6 +335,7 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | Gap 22: 16-bpc uncompressed QuickTime export added; channel modes, precision and PCM audio checked against stored samples and external decode; other delivery gaps remain | | 2026-10-16 | minor | Gap 8: #932 closed (Convert To Bezier Path converts a shape layer's parametric path, not only mask RotoBezier) | | 2026-10-13 | patch | G2: layer bounds no longer compete with editable shape/mask path points (#1220) | | 2026-10-12 | minor | Audited against `upstream/main` 470ea7a3: open-issue count and crash list, closed issues removed from rows 1, 3, 5, 6, 8–12, 15–17, 22–24, G8 and G9 (#759 `#include`), localization catalogs recounted (≈ 12% to ≈ 18%); rows 7, 14 and 21 left to open PRs #1102, #792 and #1098 |@@ -1,6 +1,6 @@ # Parity with After Effects -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (issue, catalog and export-format references re-audited against `upstream/main` 470ea7a3; the 10 October re-measure is otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89)+> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-25 · **Change:** minor (16-bpc uncompressed QuickTime output evidence; readiness estimates unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) The authoritative assessment of how close EffectCraft is to After Effects, and how much work is left. [gaps.md](gaps.md) lists every known shortfall one at a time; the@@ -124,7 +124,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for | 3D (Classic and Advanced) | 6% | 82% | 35% | 35–65 | No FBX/USD/STL models, no Substance materials, mesh displacement unverified; no 3D transform gizmos (#392, #347); reflections (#261); camera aperture (#260); #489 and #315 are closed | | Tracking and AI-assisted tools | 5% | 80% | 35% | 35–70 | Object Matte (26.2) has the effect, the viewer tool (click / box), background tracking, Freeze and the 26.5 disk cache, not yet compared with After Effects; Roto Brush (MobileSAM) and face tracking (MediaPipe) unmeasured against After Effects; camera-tracker view (#505) | | Preview and cache | 7% | 92% | 40% | 25–50 | Playback stutters on cached frames (#595, #304); late audio (#504, #335); #329 and #554 are closed; no benchmark against After Effects |-| Render queue and export | 7% | 85% | 55% | 25–45 | No hardware encoding; ProRes 4444 colour tag (#340); rendering over an existing file (#325); #446 and #372 (8-bit uncompressed QuickTime, #778) are closed; HEVC/AV1 without B-frames |+| Render queue and export | 7% | 85% | 55% | 25–45 | No hardware encoding; ProRes 4444 colour tag (#340); rendering over an existing file (#325); #446 and #372 (8-bit uncompressed QuickTime, #778; now also 16-bpc `b64a` with precision/alpha/PCM tests) are closed; HEVC/AV1 without B-frames | | Audio | 2% | 85% | 45% | 6–12 | Audio sync and crackle (#335); audio to keyframes depth | | **Weighted** | 100% | **≈ 91%** | **≈ 50%** | **≈ 360–660** | |