diff --git a/crates/opentake-render/src/gpu/compositor.rs b/crates/opentake-render/src/gpu/compositor.rs index 9d9440c..7790111 100644 --- a/crates/opentake-render/src/gpu/compositor.rs +++ b/crates/opentake-render/src/gpu/compositor.rs @@ -358,8 +358,15 @@ impl Compositor { affine1_nat: [ draw.affine[4] as f32, draw.affine[5] as f32, - tex.width as f32, - tex.height as f32, + // Source natural size the affine was built with — NOT the + // decoded texture resolution. The preview decodes at a + // downscaled max_size, so `tex.width/height` here mismatched + // the affine's nat and rendered the layer shrunk into the + // bottom-left corner, jittering as the texture size varied + // (#125). UV (crop_uv) samples the texture 0..1, so its actual + // pixel size is irrelevant to geometry. + draw.nat_size.0 as f32, + draw.nat_size.1 as f32, ], canvas_op_flags: [ size.width as f32, diff --git a/crates/opentake-render/src/plan/build.rs b/crates/opentake-render/src/plan/build.rs index de426fc..4684272 100644 --- a/crates/opentake-render/src/plan/build.rs +++ b/crates/opentake-render/src/plan/build.rs @@ -290,6 +290,10 @@ fn eval_layer<'a>( source: &plan.source, source_frame, affine, + // Carry the SAME natural size the affine was built with (above) so the + // shader's quad lands in the right place regardless of the (possibly + // downscaled) decoded texture resolution (#125). + nat_size: plan.nat_size, crop_uv, opacity, needs_premultiply: plan.needs_premultiply, diff --git a/crates/opentake-render/src/plan/types.rs b/crates/opentake-render/src/plan/types.rs index 2676134..4d6919b 100644 --- a/crates/opentake-render/src/plan/types.rs +++ b/crates/opentake-render/src/plan/types.rs @@ -134,6 +134,14 @@ pub struct LayerDraw<'a> { /// `[a, b, c, d, tx, ty]`; coordinate frame: origin bottom-left, y up, unit = /// pixels (SPEC §1.3 projection convention). pub affine: [f64; 6], + /// Source natural size (pixels) the `affine` was built against — the shader + /// scales its `[0,1]` quad by THIS to recover source-pixel space before + /// applying `affine`. It MUST be the value passed to `affine_transform` + /// (`ClipPlan.nat_size`), NOT the decoded texture's resolution: the preview + /// decodes at a downscaled `max_size`, so a texture-size proxy mismatches the + /// affine and renders the layer shrunk into a corner (and jittering as the + /// texture size varies). SPEC §1.3 / §3.3. + pub nat_size: (f64, f64), /// Source-texture UV sub-rect (folded from `crop_at(f)`), `(u0, v0, u1, v1)` /// in `[0, 1]`. SPEC §3.4. pub crop_uv: (f64, f64, f64, f64), diff --git a/crates/opentake-render/tests/gpu_downscaled_nat.rs b/crates/opentake-render/tests/gpu_downscaled_nat.rs new file mode 100644 index 0000000..4ee0e79 --- /dev/null +++ b/crates/opentake-render/tests/gpu_downscaled_nat.rs @@ -0,0 +1,112 @@ +//! Regression test for #125 — the preview decodes source frames at a downscaled +//! `max_size`, so a layer's GPU texture is SMALLER than its source natural size. +//! +//! The shader scales its `[0,1]` quad by the SOURCE natural size (the value the +//! affine was built with), NOT by the decoded texture's resolution. If the +//! compositor feeds the texture size into the `nat` uniform instead, a full-canvas +//! clip renders shrunk into the bottom-left corner (and jitters as the per-frame +//! decoded size varies). This test composites a 4×4 texture for a 16×16 source on +//! a 16×16 canvas and asserts the layer still fills the whole frame. +//! +//! HARD CONSTRAINT: skips gracefully (eprintln + early return) when no GPU adapter +//! is available — never fails on GPU absence. + +use std::rc::Rc; + +use opentake_domain::{Clip, ClipType, Point, Timeline, Track, Transform}; +use opentake_render::gpu::texture::upload_rgba; +use opentake_render::source::DecodedFrame; +use opentake_render::wgpu; +use opentake_render::{ + build_render_plan, Compositor, GpuTexture, RenderDevice, RenderSize, SourceMetrics, + TextureResolver, TextureSource, +}; + +const RS: RenderSize = RenderSize { + width: 16, + height: 16, +}; + +/// Source display size is 16×16 (== canvas); the decoded texture will be 4×4. +struct Metrics; +impl SourceMetrics for Metrics { + fn natural_size(&self, _r: &str) -> Option<(u32, u32)> { + Some((16, 16)) + } +} + +/// Resolves to a 4×4 solid texture — a quarter of the source/canvas size per +/// axis, exactly the downscale the preview applies (decode `max_size` < source). +struct SmallTexResolver<'d> { + device: &'d wgpu::Device, + queue: &'d wgpu::Queue, + rgba: [u8; 4], +} +impl TextureResolver for SmallTexResolver<'_> { + fn resolve(&mut self, _s: &TextureSource, _f: i64) -> Option> { + let mut buf = vec![0u8; 4 * 4 * 4]; + for px in buf.chunks_exact_mut(4) { + px.copy_from_slice(&self.rgba); + } + let frame = DecodedFrame::new(4, 4, buf, true); // premultiplied solid + Some(Rc::new(upload_rgba( + self.device, + self.queue, + &frame, + false, + Some("small"), + ))) + } +} + +#[test] +fn downscaled_texture_full_canvas_still_fills_whole_frame() { + let dev = match RenderDevice::try_new() { + Ok(d) => d, + Err(e) => { + eprintln!( + "[skip] downscaled_texture_full_canvas_still_fills_whole_frame: no GPU ({e})" + ); + return; + } + }; + + let mut tl = Timeline::new(); + tl.fps = 30; + tl.width = 16; + tl.height = 16; + let mut clip = Clip::new("c0", "asset", 0, 10); + clip.transform = Transform::from_top_left(Point { x: 0.0, y: 0.0 }, 1.0, 1.0); + let mut track = Track::new("t0", ClipType::Video); + track.clips.push(clip); + tl.tracks.push(track); + + let plan = build_render_plan(&tl, RS, &Metrics); + let fp = plan.frame(&tl, 0); + assert_eq!(fp.draws.len(), 1); + // The draw must carry the SOURCE natural size (16×16), independent of the 4×4 + // decoded texture — this is the contract the fix restores. + assert_eq!(fp.draws[0].nat_size, (16.0, 16.0)); + + let compositor = Compositor::new(&dev.device); + let mut resolver = SmallTexResolver { + device: &dev.device, + queue: &dev.queue, + rgba: [40, 200, 80, 255], + }; + let frame = compositor + .render_to_rgba(&dev.device, &dev.queue, RS, &fp, &mut resolver) + .expect("render"); + + // A full-canvas clip with a downscaled texture must fill EVERY pixel with the + // source color. Before the fix the content rendered only in the bottom-left + // 4×4 quarter (quad scaled by tex 4 instead of nat 16), leaving the rest + // opaque black — so any pixel being black catches the regression. + for (i, px) in frame.rgba.chunks_exact(4).enumerate() { + assert_eq!( + px, + &[40, 200, 80, 255], + "pixel {i} must be the source color — a full-canvas downscaled texture must fill the whole frame (#125)" + ); + } +}