collective

Browse / github.com/storytold/filmcraft / patch

gpu: 10-bit video uploads as its codes, with no half-float conversion

gpu: 10-bit video uploads as its codes, with no half-float conversion ## What Every 10- and 12-bit Y'CbCr plane was converted to half floats per sample (a table lookup each) before upload: half of the CPU time of 4K HDR playback, on the frame workers. - Where the adapter has 16-bit normalized textures (Vulkan, Metal, DX12), those planes upload as they are (R16Unorm, sampled as code / 65535). The app, the export renderer and the tests request the feature where the adapter has it (filmcraft_gpu::optional_features); elsewhere (WebGPU) planes are converted as before. - The frame workers leave such planes unconverted (prepare_with). A plane converted anyway uploads

Status
open
Score
0.00
Adopters
0 other operators
Author
zsoli69 (imported from pull request #3215, open)
Base
10d626ccc4888e0f020f9ca314c2bc9148ded4f4
Size
+162 −35 in 11 files
Review
pending
Shared
2026-10-28
Id
c50675638a90ef490c09684b

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock apps/filmcraft/src/graphics.rs crates/gpu/Cargo.toml crates/gpu/src/blend_tests.rs crates/gpu/src/export_renderer.rs crates/gpu/src/lib.rs crates/gpu/src/tests.rs crates/ui-egui/src/app.rs crates/ui-egui/src/app/gpu.rs crates/ui-egui/src/frames.rs docs/architecture.md

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -2187,6 +2187,7 @@ dependencies = [ name = "filmcraft-gpu" version = "0.13.0" dependencies = [+ "bytemuck",  "filmcraft-color",  "filmcraft-export",  "filmcraft-frame",diff --git a/apps/filmcraft/src/graphics.rs b/apps/filmcraft/src/graphics.rs--- a/apps/filmcraft/src/graphics.rs+++ b/apps/filmcraft/src/graphics.rs@@ -439,6 +439,14 @@ fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option<B     // Pro: the device is lost and the window never opens. `WGPU_VALIDATION_INDIRECT_CALL=1` turns     // the check back on.     create.instance_descriptor.flags = create.instance_descriptor.flags.difference(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();+    // the compositor's optional features (16-bit normalized textures: 10-bit video uploads as+    // its codes) wherever the adapter has them+    let device_descriptor = create.device_descriptor.clone();+    create.device_descriptor = Arc::new(move |adapter| {+        let mut d = device_descriptor(adapter);+        d.required_features |= filmcraft_gpu::optional_features(adapter);+        d+    });     match mode {         // eframe's default (`WGPU_BACKEND`, else PRIMARY | GL), except on Windows: there it         // includes GL. Creating that backend can crash inside AMD's atio6axx.dll before adapterdiff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml--- a/crates/gpu/Cargo.toml+++ b/crates/gpu/Cargo.toml@@ -17,6 +17,7 @@ filmcraft-project = { workspace = true } filmcraft-render = { workspace = true } filmcraft-time = { workspace = true } wgpu = { version = "30", default-features = false, features = ["wgsl"] }+bytemuck = "1" log = { workspace = true } pollster = "1" rayon = { workspace = true }diff --git a/crates/gpu/src/blend_tests.rs b/crates/gpu/src/blend_tests.rs--- a/crates/gpu/src/blend_tests.rs+++ b/crates/gpu/src/blend_tests.rs@@ -13,7 +13,7 @@ use filmcraft_render::plan::execute_cpu; pub(crate) fn device() -> Option<(wgpu::Device, wgpu::Queue)> {     let instance = wgpu::Instance::default();     let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?;-    pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()+    pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { required_features: optional_features(&adapter), ..Default::default() })).ok() }  pub(crate) fn yuv_frame(w: u32, h: u32) -> Arc<VideoFrame> {diff --git a/crates/gpu/src/export_renderer.rs b/crates/gpu/src/export_renderer.rs--- a/crates/gpu/src/export_renderer.rs+++ b/crates/gpu/src/export_renderer.rs@@ -11,7 +11,7 @@ //! the CPU renderer; a `FramePlan::Image` plan (HDR / wide gamut / anything the plan sends to the //! CPU) is returned unchanged — it already is the CPU image. -use crate::{GpuCompositor, prepare};+use crate::{GpuCompositor, prepare_with}; use filmcraft_project::{ItemId, Project}; use filmcraft_render::plan::{FramePlan, plan_frame}; use filmcraft_render::{Image, RenderOptions, SourceProvider};@@ -113,7 +113,8 @@ impl ExportRenderer {                 }                 let instance = wgpu::Instance::default();                 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?;-                let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;+                let desc = wgpu::DeviceDescriptor { required_features: crate::optional_features(&adapter), ..Default::default() };+                let (device, queue) = pollster::block_on(adapter.request_device(&desc)).ok()?;                 // wgpu's default handler panics on a validation or out-of-memory error; log it and                 // send the remaining frames to the CPU instead.                 device.on_uncaptured_error(std::sync::Arc::new(|e: wgpu::Error| mark_device_failed(&format!("GPU error: {e}"))));@@ -140,7 +141,8 @@ impl ExportRenderer {                 if device_failed() {                     return None;                 }-                let prep = prepare(&plan);+                // 10-bit planes as their codes where the device takes 16-bit normalized textures+                let prep = prepare_with(&plan, self.compositor.uploads_raw_16bit());                 let (w, h, px) = self.compositor.render_export_prepared(&plan, Some(&prep))?;                 // an error reported while this frame was drawn leaves its pixels untrusted                 if device_failed() {diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs--- a/crates/gpu/src/lib.rs+++ b/crates/gpu/src/lib.rs@@ -94,6 +94,19 @@ pub struct GpuCompositor {     pub cpu_transitions: u64,     /// Total bytes uploaded (stats).     pub uploaded_bytes: u64,+    /// 16-bit Y'CbCr planes upload as they are (`R16Unorm`, [`OPTIONAL_FEATURES`]) instead of+    /// being converted to half floats first.+    raw16: bool,+}++/// Device features the compositor uses when the adapter has them; request them with+/// [`optional_features`]. `TEXTURE_FORMAT_16BIT_NORM`: 10- and 12-bit Y'CbCr planes upload as+/// their 16-bit codes, with no per-sample conversion on the CPU.+pub const OPTIONAL_FEATURES: wgpu::Features = wgpu::Features::TEXTURE_FORMAT_16BIT_NORM;++/// The [`OPTIONAL_FEATURES`] `adapter` has, for its `DeviceDescriptor::required_features`.+pub fn optional_features(adapter: &wgpu::Adapter) -> wgpu::Features {+    adapter.features() & OPTIONAL_FEATURES }  fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {@@ -255,6 +268,15 @@ pub fn prepare_frame(f: &VideoFrame) -> Option<Prepared> {  /// Convert every layer of a plan for upload. Thread-safe and GPU-free: run it on a worker. pub fn prepare(plan: &FramePlan) -> PreparedPlan {+    prepare_with(plan, false)+}++/// [`prepare`] for a compositor that uploads 16-bit planes as they are+/// ([`GpuCompositor::uploads_raw_16bit`]): those are left unconverted. A mismatch is harmless:+/// a converted plane uploads as half floats on any device, an unconverted one is converted at+/// upload where the device has no 16-bit normalized textures.+pub fn prepare_with(plan: &FramePlan, raw16: bool) -> PreparedPlan {+    let prepare_layers = |layers: &[PlanLayer]| prepare_layers(layers, raw16);     match plan {         FramePlan::Layers { layers, .. } => prepare_layers(layers),         FramePlan::Image(img) => PreparedPlan { image: Some(Prepared { planes: vec![f32_to_f16_bytes(&img.px)] }), ..Default::default() },@@ -271,8 +293,9 @@ pub fn prepare(plan: &FramePlan) -> PreparedPlan {     } } -fn prepare_layers(layers: &[PlanLayer]) -> PreparedPlan {-    PreparedPlan { layers: layers.iter().map(|l| prepare_frame(&l.frame)).collect(), ..Default::default() }+fn prepare_layers(layers: &[PlanLayer], raw16: bool) -> PreparedPlan {+    let prepare = |f: &VideoFrame| if raw16 && matches!(f.data, PixelData::Yuv16 { .. }) { None } else { prepare_frame(f) };+    PreparedPlan { layers: layers.iter().map(|l| prepare(&l.frame)).collect(), ..Default::default() } }  impl GpuCompositor {@@ -427,9 +450,16 @@ impl GpuCompositor {             gpu_transitions: 0,             cpu_transitions: 0,             uploaded_bytes: 0,+            raw16: device.features().contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM),         }     } +    /// Whether 16-bit Y'CbCr planes upload as they are (the device has 16-bit normalized+    /// textures): pass it to [`prepare_with`].+    pub fn uploads_raw_16bit(&self) -> bool {+        self.raw16+    }+     fn target(         device: &wgpu::Device,         slot: &mut Option<(wgpu::Texture, wgpu::TextureView, (u32, u32))>,@@ -549,25 +579,36 @@ impl GpuCompositor {                 let (sx, sy) = chroma.shifts();                 let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy));                 let scale = (1u32 << bits) as f32;-                let owned;-                let p = match prep {-                    Some(p) => p,-                    None => {-                        owned = Prepared { planes: planes.iter().chain(alpha.as_ref()).map(|p| codes_to_f16_bytes(p, *bits)).collect() };-                        &owned-                    }+                // (full-resolution alpha plane: its codes span 0..2^bits - 1, hence its scale)+                let (y, u, v, a, code_scale, alpha_scale) = if prep.is_none() && self.raw16 {+                    // the codes as they are: R16Unorm samples are code / 65535+                    let plane = |s: &mut Self, w, h, p: &[u16]| s.plane_texture(w, h, wgpu::TextureFormat::R16Unorm, bytemuck::cast_slice(p), 2);+                    let y = plane(self, w, h, &planes[0]);+                    let u = plane(self, cw, ch, &planes[1]);+                    let v = plane(self, cw, ch, &planes[2]);+                    let a = alpha.as_ref().map(|a| plane(self, w, h, a));+                    (y, u, v, a, 65535.0, 65535.0 / (scale - 1.0))+                } else {+                    let owned;+                    let p = match prep {+                        Some(p) => p,+                        None => {+                            owned = Prepared { planes: planes.iter().chain(alpha.as_ref()).map(|p| codes_to_f16_bytes(p, *bits)).collect() };+                            &owned+                        }+                    };+                    let y = self.plane_texture(w, h, wgpu::TextureFormat::R16Float, &p.planes[0], 2);+                    let u = self.plane_texture(cw, ch, wgpu::TextureFormat::R16Float, &p.planes[1], 2);+                    let v = self.plane_texture(cw, ch, wgpu::TextureFormat::R16Float, &p.planes[2], 2);+                    let a = alpha.as_ref().and_then(|_| p.planes.get(3)).map(|b| self.plane_texture(w, h, wgpu::TextureFormat::R16Float, b, 2));+                    (y, u, v, a, scale, scale / (scale - 1.0))                 };-                let y = self.plane_texture(w, h, wgpu::TextureFormat::R16Float, &p.planes[0], 2);-                let u = self.plane_texture(cw, ch, wgpu::TextureFormat::R16Float, &p.planes[1], 2);-                let v = self.plane_texture(cw, ch, wgpu::TextureFormat::R16Float, &p.planes[2], 2);-                // (full-resolution alpha plane: its codes span 0..2^bits - 1, hence the scale)-                let a = alpha.as_ref().and_then(|_| p.planes.get(3)).map(|b| self.plane_texture(w, h, wgpu::TextureFormat::R16Float, b, 2));-                let alpha_scale = if a.is_some() { scale / (scale - 1.0) } else { 0.0 };+                let alpha_scale = if a.is_some() { alpha_scale } else { 0.0 };                 let _ = Chroma::C420;                 Uploaded {                     views: [y, u, v, a.unwrap_or_else(dummy)],                     kind: 2,-                    code_scale: scale,+                    code_scale,                     alpha_scale,                     chroma: (cw, ch),                     last_used: self.clock,@@ -576,7 +617,12 @@ impl GpuCompositor {             }         };         self.uploads.insert(key, up);-        // keep the most recent uploads (enough for several stacked layers + playback lookahead)+        self.evict_uploads();+        key+    }++    /// Keep the most recent uploads (enough for several stacked layers + playback lookahead).+    fn evict_uploads(&mut self) {         if self.uploads.len() > 24 {             let mut v: Vec<(u64, (usize, u32, u32))> = self.uploads.iter().map(|(k, u)| (u.last_used, *k)).collect();             v.sort_unstable();@@ -584,7 +630,6 @@ impl GpuCompositor {                 self.uploads.remove(&k);             }         }-        key     }      /// The source description of an uploaded frame.@@ -1159,7 +1204,7 @@ fn wait_mapped(device: &wgpu::Device) -> Option<()> { /// Shader source: kind (0 RGBA8 sRGB, 1 linear premultiplied RGBA, 2 YUV), sampling scales, dimensions, color. struct SrcInfo {     kind: u32,-    /// Texture sample -> code units: 255 for R8Unorm, 2^bits for R16Float.+    /// Texture sample -> code units: 255 for R8Unorm, 2^bits for R16Float, 65535 for R16Unorm.     code_scale: f32,     /// YUV quantization levels: 2^bits; R8Unorm's decoding scale is one less.     code_levels: f32,diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs--- a/crates/gpu/src/tests.rs+++ b/crates/gpu/src/tests.rs@@ -6,7 +6,7 @@ use filmcraft_render::plan::execute_cpu; fn device() -> Option<(wgpu::Device, wgpu::Queue)> {     let instance = wgpu::Instance::default();     let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?;-    pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()+    pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { required_features: optional_features(&adapter), ..Default::default() })).ok() }  #[test]@@ -39,6 +39,50 @@ fn yuv_code_range_uniforms() {     assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); } +/// 10-bit 4:2:0 with an alpha plane uploads as its codes (`R16Unorm`) where the device has 16-bit+/// normalized textures, with the same picture as the half-float upload and the CPU; plans+/// prepared for such a compositor leave those planes unconverted.+#[test]+fn raw_16bit_planes_match_half_floats() {+    let Some((dev, q)) = device() else {+        eprintln!("no GPU adapter; skipping");+        return;+    };+    let mut raw = GpuCompositor::new(&dev, &q);+    if !raw.uploads_raw_16bit() {+        eprintln!("no 16-bit normalized textures on this adapter; skipping");+        return;+    }+    let (w, h) = (64u32, 16u32);+    let (cw, ch) = (w / 2, h / 2);+    let y: Vec<u16> = (0..w * h).map(|i| (64 + (i % w) * 876 / w) as u16).collect();+    // (gentle chroma ramps: the shader's bilinear chroma vs the CPU's differ on steep ones)+    let u: Vec<u16> = (0..cw * ch).map(|i| (448 + (i / cw) * 64 / ch) as u16).collect();+    let v: Vec<u16> = (0..cw * ch).map(|i| (600 - (i % cw) * 64 / cw) as u16).collect();+    let a: Vec<u16> = (0..w * h).map(|i| ((i / w) * 1023 / (h - 1)) as u16).collect();+    let frame = Arc::new(VideoFrame {+        width: w,+        height: h,+        data: PixelData::Yuv16 { planes: [Arc::new(y), Arc::new(u), Arc::new(v)], chroma: Chroma::C420, bits: 10, alpha: Some(Arc::new(a)) },+        color: filmcraft_color::ColorInfo::REC709,+        par: (1, 1),+        pts: Default::default(),+    });+    let plan = FramePlan::Layers { width: w as usize, height: h as usize, layers: vec![PlanLayer::new(frame, Affine::IDENTITY, 1.0, Blend::Normal)] };+    let skipped = prepare_with(&plan, true);+    assert!(skipped.layers.iter().all(Option::is_none), "raw 16-bit planes are not converted");+    raw.composite_prepared(&plan, Some(&skipped));+    let (_, _, raw_px) = raw.read_output().expect("raw readback");+    let mut half = GpuCompositor::new(&dev, &q);+    half.composite_prepared(&plan, Some(&prepare(&plan)));+    let (_, _, half_px) = half.read_output().expect("half-float readback");+    let cpu = execute_cpu(&plan).over_black_rgba8();+    let max = |a: &[u8], b: &[u8]| a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);+    assert!(max(&raw_px, &half_px) <= 1, "raw vs half floats: {}", max(&raw_px, &half_px));+    let (p99, mean, _) = super::blend_tests::stats8(&cpu, &raw_px, &vec![true; (w * h) as usize]);+    assert!(p99 <= 6 && mean < 1.5, "raw vs CPU: p99 {p99}, mean {mean}");+}+ #[test] fn yuv_code_range_gpu_matches_cpu() {     let Some((dev, q)) = device() else {@@ -295,16 +339,26 @@ fn prepared_upload_matches_inline_conversion() {         ],     };     let image = FramePlan::Image(filmcraft_render::Image { w: w as usize, h: h as usize, px: f32_layer });+    // Both upload modes: half floats (forced, so devices with 16-bit normalized textures cover it+    // too) and the codes as they are where the device has them. A plan is prepared for the mode+    // of the compositor that draws it, as the frame workers do (`prepare_with` with+    // `uploads_raw_16bit`); mixing the modes is a different picture by up to a level, which+    // `raw_16bit_planes_match_half_floats` bounds (it is exact on some GPUs, not on lavapipe).+    let modes: &[bool] = if GpuCompositor::new(&dev, &q).uploads_raw_16bit() { &[false, true] } else { &[false] };     for plan in [layers, image] {-        let mut a = GpuCompositor::new(&dev, &q);-        a.composite(&plan);-        let inline = a.read_output().expect("readback");-        let mut b = GpuCompositor::new(&dev, &q);-        let prep = prepare(&plan);-        assert!(prep.bytes() > 0);-        b.composite_prepared(&plan, Some(&prep));-        let prepared = b.read_output().expect("readback");-        assert!(inline == prepared, "prepared upload differs");+        for &raw16 in modes {+            let mut a = GpuCompositor::new(&dev, &q);+            a.raw16 = raw16;+            a.composite(&plan);+            let inline = a.read_output().expect("readback");+            let mut b = GpuCompositor::new(&dev, &q);+            b.raw16 = raw16;+            let prep = prepare_with(&plan, raw16);+            assert!(prep.bytes() > 0);+            b.composite_prepared(&plan, Some(&prep));+            let prepared = b.read_output().expect("readback");+            assert!(inline == prepared, "prepared upload differs (raw 16-bit uploads: {raw16})");+        }     } } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs--- a/crates/ui-egui/src/app.rs+++ b/crates/ui-egui/src/app.rs@@ -177,6 +177,8 @@ impl FilmcraftApp {                 self.session.previews.clone(),                 FrameServer::default_workers(),             ));+            // a new server starts with raw 16-bit planes off: keep what the live compositor takes+            self.frames.set_raw_16bit(self.gpu.as_ref().is_some_and(|g| g.compositor.uploads_raw_16bit()));             self.textures.clear();             self.thumb_used.clear();             self.tl.reset_media_caches();diff --git a/crates/ui-egui/src/app/gpu.rs b/crates/ui-egui/src/app/gpu.rs--- a/crates/ui-egui/src/app/gpu.rs+++ b/crates/ui-egui/src/app/gpu.rs@@ -23,6 +23,7 @@ impl FilmcraftApp {         match made {             Ok(compositor) if !broken.load(std::sync::atomic::Ordering::Relaxed) => {                 let max_texture = rs.device.limits().max_texture_dimension_2d;+                self.frames.set_raw_16bit(compositor.uploads_raw_16bit());                 self.gpu = Some(GpuState { render_state: rs, compositor, texture: None, last_key: None, size: (0, 0), last_ms: 0.0, broken, max_texture });             }             _ => log::error!("GPU compositor failed to initialise; compositing on the CPU"),@@ -32,6 +33,7 @@ impl FilmcraftApp {     /// Drop the GPU compositor after a GPU error; the monitors composite on the CPU from then on.     fn disable_gpu(&mut self, why: &str) {         log::error!("GPU compositor disabled: {why}");+        self.frames.set_raw_16bit(false);         if let Some(g) = self.gpu.take()             && let Some(id) = g.texture         {diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs--- a/crates/ui-egui/src/frames.rs+++ b/crates/ui-egui/src/frames.rs@@ -165,6 +165,9 @@ struct Shared {     workers: usize,     /// Always-on counters for `perf.stats`.     stats: Mutex<FrameStats>,+    /// The GPU compositor uploads 16-bit planes as they are: plans leave them unconverted+    /// ([`filmcraft_gpu::prepare_with`]).+    raw16: AtomicBool,     /// Set (under the queue lock) when the [`FrameServer`] is dropped: the workers return.     stop: AtomicBool, }@@ -477,6 +480,7 @@ impl FrameServer {             render_cost: Mutex::new(0.0),             workers: workers.max(1),             stats: Mutex::new(FrameStats::default()),+            raw16: AtomicBool::new(false),             stop: AtomicBool::new(false),         });         let repaint: Arc<Mutex<Option<Box<dyn Fn() + Send + Sync>>>> = Arc::new(Mutex::new(None));@@ -507,6 +511,12 @@ impl FrameServer {         }     } +    /// Whether the GPU compositor showing the plans uploads 16-bit planes as they are+    /// ([`filmcraft_gpu::GpuCompositor::uploads_raw_16bit`]).+    pub fn set_raw_16bit(&self, on: bool) {+        self.shared.raw16.store(on, Ordering::Relaxed);+    }+     /// Collect a [`JobRecord`] per finished job (benchmarks); `take_records` drains them.     pub fn set_profiling(&self, on: bool) {         self.shared.profiling.store(on, Ordering::Relaxed);@@ -915,7 +925,7 @@ fn run_job(sh: &Shared, job: &Job, pool: &Arc<MediaPool>, services: &Arc<dyn Ser                         loading = filmcraft_media::pending::take();                         if !job.cancel.load(Ordering::Relaxed) && !loading {                             // Convert texels for upload here, not on the UI thread when the frame is shown.-                            let prepared = filmcraft_gpu::prepare(&plan);+                            let prepared = filmcraft_gpu::prepare_with(&plan, sh.raw16.load(Ordering::Relaxed));                             sh.plans.lock().unwrap_or_else(PoisonError::into_inner).insert(job.key, GpuPlan { plan, prepared });                         }                         pvdiff --git a/docs/architecture.md b/docs/architecture.md--- a/docs/architecture.md+++ b/docs/architecture.md@@ -355,7 +355,9 @@ file ──► codecs (MP4/MOV, MKV, audio)        demux + decode, GOP-aware see   requests only (a paused frame, render or export decodes them again exactly), and draft plans   hand the GPU box-decimated Y'CbCr planes at the drawn size. `perf.stats` reports the counters.   GPU plans carry their texels already converted for upload (`filmcraft_gpu::prepare`), so the UI-  thread only copies them.+  thread only copies them. Where the adapter has 16-bit normalized textures (Vulkan, Metal,+  DX12; `filmcraft_gpu::optional_features`), 10- and 12-bit planes are left as their codes+  (`prepare_with`) and upload as `R16Unorm`; elsewhere (WebGPU) they are converted to half floats. - **Audio clock.** The desktop app passes a cpal output (`apps/filmcraft/src/audio.rs`) to the UI as   `AudioOut`. While playing, the samples played by the sound card drive the playhead and video follows.   Without an audio device, playback falls back to the wall clock. `PlaybackMeter` counts timeline