render: anchor resolution_in to the sequence resolution

Preview renders at proxy size while a paused frame renders full-res, so
anchoring resolution_in to the render target made every sequence-pixel
effect (shape size/pos, transform offsets, corner pin points, drop
shadow distance) change apparent size whenever the transport stopped.
Pre-fill resolution_in from the sequence's video params (C++ inserts
the NodeGlobals square resolution at job-build time), covering the
nested generator job inside a merge as well; a node that inserted its
own resolution_in keeps it.
This commit is contained in:
2026-09-10 16:53:55 +08:00
parent 7ce80bf8b3
commit fc90604245
2 changed files with 132 additions and 1 deletions
+47 -1
View File
@@ -164,6 +164,14 @@ pub struct RenderEvalHooks {
/// texture at the output resolution). The graph-sequence driver sets
/// this to the sequence frame size.
pub frame_size: Option<(i32, i32)>,
/// The sequence's square-pixel resolution (C++ the `NodeGlobals`
/// square resolution the shape/polygon generators insert as
/// `resolution_in` at job-build time). Generator jobs anchor their
/// pixel-size params to this — NOT to the render-target size — so a
/// proxy-resolution playback render draws them at the same relative
/// size as a paused full-res frame. `None` (non-sequence renders)
/// falls back to the render-target size.
pub sequence_size: Option<(i32, i32)>,
}
@@ -274,6 +282,7 @@ impl RenderEvalHooks {
use_cache: false,
ticket: None,
frame_size: None,
sequence_size: None,
}
}
@@ -794,6 +803,36 @@ impl RenderEvalHooks {
// nobody knows better.
let size = size.or(self.frame_size).unwrap_or((1, 1));
// `resolution_in` anchors to the sequence square resolution, not
// the render target (C++ inserts the NodeGlobals square
// resolution into the job at build time): the node params it
// denormalizes (shape size/pos, transform offsets, corner pin
// points, drop shadow distance) are all sequence-pixel values,
// so a proxy-size playback render must resolve them against the
// same resolution as a paused full-res frame, or the effect
// visibly changes size whenever the transport stops. Pre-filling
// the row wins over `run_effect`'s frame-size auto-fill; a node
// that inserted its own `resolution_in` keeps it.
let mut sequence_row;
let params = if self.sequence_size.is_some()
&& !payload.params.contains_key("resolution_in")
&& compiled
.translated
.uniforms
.iter()
.any(|u| u.name == "resolution_in")
{
let (w, h) = self.sequence_size.unwrap();
sequence_row = payload.params.clone();
sequence_row.insert(
"resolution_in".to_string(),
NodeValue::Vec2([w as f64, h as f64]),
);
&sequence_row
} else {
&payload.params
};
let dst = match ctx.create_texture(size.0.max(1), size.1.max(1)) {
Ok(t) => t,
Err(err) => {
@@ -808,7 +847,7 @@ impl RenderEvalHooks {
let result = run_effect(
&ctx,
&compiled,
&payload.params,
params,
&inputs,
dst,
size,
@@ -1441,6 +1480,13 @@ pub fn render_graph_frame(
let mut traverser = oak_node::traverser::Traverser::new();
let mut hooks = RenderEvalHooks::new();
hooks.frame_size = Some(size);
// The generators' `resolution_in` anchor (C++ NodeGlobals square
// resolution): the sequence's native size, so proxy-size playback and
// full-res paused frames draw generated layers identically.
hooks.sequence_size = sequence
.video_params
.first()
.map(|p| (p.width.max(1), p.height.max(1)));
let perf = std::env::var_os("OAK_PERF").is_some();
let mut perf_collect = perf.then(std::time::Instant::now);
let mut perf_collect_ms = 0.0f64;
+85
View File
@@ -639,3 +639,88 @@ fn chromakey_job_keys_green_with_ociobased_stub() {
let _ = std::fs::remove_file(&path);
}
/// Generator sizing is anchored to the SEQUENCE resolution (C++ inserts
/// the NodeGlobals square resolution as `resolution_in` at job-build
/// time), not the render target: a proxy-size playback render and a
/// full-res paused frame must draw the shape at the same relative size,
/// or the generated layer visibly changes size whenever the transport
/// stops. A 96px rectangle on the default 1920x1080 sequence spans 5%
/// of the width at BOTH 192x108 and 768x432 (the pre-fix
/// target-anchored behavior drew 50% vs 12.5%).
#[test]
fn shape_generator_size_is_sequence_relative() {
if oak_core::backend::GpuContext::shared().is_none() {
eprintln!("skipping shape_generator_size_is_sequence_relative: no GPU adapter");
return;
}
let path = clip_path("shape_seqrel");
oak_codec::testmedia::write_test_clip_solid(&path, 64, 64, 10, 10, [0.0, 0.0, 1.0, 1.0])
.expect("blue clip generation");
let (project, seq) = build_effect_project(
(&path.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1)),
|p, footage, clip| {
let (ecore, ebehavior) = oak_node::factory::Factory::global()
.create_any("org.olivevideoeditor.Olive.shape")
.expect("shape factory entry");
let effect = p.graph.add_node(ecore, ebehavior);
p.graph.disconnect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1);
// The shape module is crate-private; the input ids are the C++
// kBaseInput/kSizeInput strings.
p.graph
.connect(footage, effect, "base_in", -1)
.expect("connect footage to shape base");
p.graph
.connect(effect, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)
.expect("connect shape to clip");
p.graph.get_mut(effect).unwrap().core.set_standard_value(
"size_in",
-1,
oak_node::value::NodeValue::Vec2([96.0, 96.0]),
);
effect
},
);
// The fraction of the middle row covered by the opaque red square.
let red_fraction = |width: i32, height: i32| -> f32 {
let tex = oak_render::eval::render_graph_frame(
&project,
seq,
Rational::new(0, 1),
(width, height),
PixelFormat::F32,
)
.expect("shape render");
let Texture::Cpu(frame) = &tex else {
panic!("graph render produced a non-CPU texture");
};
let stride = frame.linesize_bytes() as usize;
let y = (height / 2) as usize;
let mut red = 0usize;
for x in 0..width as usize {
let off = y * stride + x * 16;
let r = f32::from_le_bytes(frame.data[off..off + 4].try_into().unwrap());
let g = f32::from_le_bytes(frame.data[off + 4..off + 8].try_into().unwrap());
let a = f32::from_le_bytes(frame.data[off + 12..off + 16].try_into().unwrap());
if r > 0.9 && g < 0.1 && a > 0.9 {
red += 1;
}
}
red as f32 / width as f32
};
let small = red_fraction(192, 108);
let large = red_fraction(768, 432);
assert!(
(small - large).abs() < 0.02,
"the shape's relative size must not depend on the render target: {small} at 192px vs {large} at 768px"
);
assert!(
(small - 0.05).abs() < 0.02,
"96px on the 1920px sequence is 5%%, got {small}"
);
let _ = std::fs::remove_file(&path);
}