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 all
diff --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.md
new 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 and
diff --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** | |
 
