Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions crates/opentake-render/src/gpu/compositor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions crates/opentake-render/src/plan/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions crates/opentake-render/src/plan/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
112 changes: 112 additions & 0 deletions crates/opentake-render/tests/gpu_downscaled_nat.rs
Original file line number Diff line number Diff line change
@@ -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<Rc<GpuTexture>> {
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)"
);
}
}
Loading