collective

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

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.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 alldiff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs--- a/crates/engine/src/commands/render_queue.rs+++ b/crates/engine/src/commands/render_queue.rs@@ -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         ),diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs--- a/crates/engine/src/render_queue.rs+++ b/crates/engine/src/render_queue.rs@@ -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,diff --git a/crates/engine/src/rq_tests.rs b/crates/engine/src/rq_tests.rs--- a/crates/engine/src/rq_tests.rs+++ b/crates/engine/src/rq_tests.rs@@ -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); } diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs--- a/crates/engine/src/tests_m145.rs+++ b/crates/engine/src/tests_m145.rs@@ -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);+}diff --git a/crates/export/README.md b/crates/export/README.mdnew file mode 100644--- /dev/null+++ b/crates/export/README.md@@ -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.diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs--- a/crates/export/src/encode.rs+++ b/crates/export/src/encode.rs@@ -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)?;diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs--- a/crates/export/src/lib.rs+++ b/crates/export/src/lib.rs@@ -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?,diff --git a/crates/export/src/out.rs b/crates/export/src/out.rs--- a/crates/export/src/out.rs+++ b/crates/export/src/out.rs@@ -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);+    }+}diff --git a/crates/export/src/uncompressed.rs b/crates/export/src/uncompressed.rs--- a/crates/export/src/uncompressed.rs+++ b/crates/export/src/uncompressed.rs@@ -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());     } }diff --git a/crates/export/tests/it/uncompressed_mov.rs b/crates/export/tests/it/uncompressed_mov.rs--- a/crates/export/tests/it/uncompressed_mov.rs+++ b/crates/export/tests/it/uncompressed_mov.rs@@ -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})");+        }+    }+}diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl--- a/crates/gpu/src/shaders/advanced3d.wgsl+++ b/crates/gpu/src/shaders/advanced3d.wgsl@@ -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;diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs--- a/crates/project/src/render_queue.rs+++ b/crates/project/src/render_queue.rs@@ -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));diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs--- a/crates/render/src/three_d/adv/shade.rs+++ b/crates/render/src/three_d/adv/shade.rs@@ -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.diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs--- a/crates/render/src/three_d/adv/tests.rs+++ b/crates/render/src/three_d/adv/tests.rs@@ -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 anddiff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml--- a/crates/text/Cargo.toml+++ b/crates/text/Cargo.toml@@ -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"diff --git a/crates/text/tests/it/main.rs b/crates/text/tests/it/main.rs--- a/crates/text/tests/it/main.rs+++ b/crates/text/tests/it/main.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;diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs--- a/crates/ui-egui/src/panels/render_queue.rs+++ b/crates/ui-egui/src/panels/render_queue.rs@@ -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()})));             }diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md--- a/docs/file-format-parity.md+++ b/docs/file-format-parity.md@@ -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 /diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -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 |diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md--- a/docs/target-app-parity.md+++ b/docs/target-app-parity.md@@ -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** | |