fix(analysis): bound tracker regions, rescaled source frames and analysis schedules

This commit is contained in:
Dylan M. Taylor
2026-11-03 15:28:38 +00:00
parent 7870b5916a
commit a81f8217cb
6 changed files with 63 additions and 9 deletions
+3 -3
View File
@@ -316,7 +316,7 @@ impl Session {
if l.switches.adjustment {
return Err("3D Camera Tracker needs footage, not an adjustment layer".into());
}
let times = analysis_times(c, l);
let times = analysis_times(c, l, crate::warp::MAX_ANALYSIS_FRAMES);
if times.len() < 2 {
return Err("the layer must be at least two frames long".into());
}
@@ -518,8 +518,8 @@ pub fn placed(project: &Project, cid: ItemId, lid: LayerId, uid: Uid) -> Option<
let k0 = solve.first_solved()?;
let comp_size = [comp.width as f64, comp.height as f64];
let world = solve.world(comp_size, solve.frames[k0].focal * scale);
let mut times = analysis_times(comp, layer);
times.truncate(solve.frames.len());
// Only the frames the solve has: not the whole layer's schedule, then cut.
let times = analysis_times(comp, layer, solve.frames.len());
Some(Placed { solve, world, scale, times, comp_size })
}
+8
View File
@@ -196,6 +196,9 @@ pub(crate) struct Work {
pub times: Vec<Tick>,
}
/// Most pixels a source frame is brought to for analysis (2 GiB as float RGBA).
const MAX_SOURCE_PIXELS: u64 = 1 << 27;
/// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels).
/// Footage at its native size is used straight from the footage source (no copy unless its
/// Cineon Settings convert it).
@@ -223,6 +226,11 @@ pub(crate) fn source_frame(
// Footage decoded at another size: bring it to layer pixels.
let w = (buf.img.width as f64 / buf.scale).round().max(1.0) as u32;
let h = (buf.img.height as f64 / buf.scale).round().max(1.0) as u32;
// The scale comes from the footage's saved width alone: a decoded frame of another
// aspect would give a height far beyond anything the layer shows.
if u64::from(w) * u64::from(h) > MAX_SOURCE_PIXELS {
return None;
}
let off = [buf.offset[0] / buf.scale, buf.offset[1] / buf.scale];
return Some((Arc::new(effectcraft_raster::resample(&buf.img, w, h)), off));
}
+27 -4
View File
@@ -255,14 +255,19 @@ async fn run_work(w: WarpWork, shared: &WarpShared) {
s.finished = true;
}
/// Frames to analyse: every comp frame from the layer's In to its Out point (inside the comp).
pub(crate) fn analysis_times(comp: &effectcraft_project::Comp, layer: &Layer) -> Vec<Tick> {
/// Most frames one analysis schedules (4.8 hours at 60 fps): the schedule is made from saved
/// timing, before any frame is analysed.
pub(crate) const MAX_ANALYSIS_FRAMES: usize = 1 << 20;
/// Frames to analyse: every comp frame from the layer's In to its Out point (inside the comp),
/// at most `limit` of them.
pub(crate) fn analysis_times(comp: &effectcraft_project::Comp, layer: &Layer, limit: usize) -> Vec<Tick> {
let fd = comp.frame_duration();
let lo = comp.frame_rate.snap_nearest(layer.in_point.max(Tick::ZERO));
let hi = layer.out_point.min(comp.duration);
let mut t = lo;
let mut v = vec![];
while t < hi {
while t < hi && v.len() < limit.min(MAX_ANALYSIS_FRAMES) {
v.push(t);
t += fd;
}
@@ -319,7 +324,7 @@ impl Session {
if l.switches.adjustment {
return Err("Warp Stabilizer needs footage, not an adjustment layer".into());
}
let times = analysis_times(c, l);
let times = analysis_times(c, l, MAX_ANALYSIS_FRAMES);
if times.len() < 2 {
return Err("the layer must be at least two frames long".into());
}
@@ -429,3 +434,21 @@ impl Session {
changed
}
}
#[cfg(test)]
mod schedule_tests {
use effectcraft_project::{Comp, LayerSource, Project, build};
use effectcraft_time::{FrameRate, Tick};
use super::{MAX_ANALYSIS_FRAMES, analysis_times};
#[test]
fn a_long_layer_schedules_at_most_the_limit() {
let mut p = Project::default();
// A million seconds at 1000 fps: 10⁹ frames.
let c = Comp::new(4, 4, FrameRate::new(1000, 1), Tick::from_seconds_f64(1e6));
let l = build::layer(&mut p, &c, "N", LayerSource::Null, (4, 4), None);
assert_eq!(analysis_times(&c, &l, usize::MAX).len(), MAX_ANALYSIS_FRAMES);
assert_eq!(analysis_times(&c, &l, 10).len(), 10);
}
}
+2 -2
View File
@@ -479,7 +479,7 @@ pub(crate) fn analysis_times(comp: &effectcraft_project::Comp, layer: &Layer, di
Direction::Forward | Direction::FrameForward => {
let mut t = start.unwrap_or(now).clamp(lo, hi);
let stop = if dir == Direction::FrameForward { t + fd } else { end.unwrap_or(hi).min(hi) };
while t <= stop && t <= hi {
while t <= stop && t <= hi && times.len() < crate::warp::MAX_ANALYSIS_FRAMES {
times.push(t);
t += fd;
}
@@ -487,7 +487,7 @@ pub(crate) fn analysis_times(comp: &effectcraft_project::Comp, layer: &Layer, di
Direction::Backward | Direction::FrameBackward => {
let mut t = end.unwrap_or(now).clamp(lo, hi);
let stop = if dir == Direction::FrameBackward { t - fd } else { start.unwrap_or(lo).max(lo) };
while t >= stop && t >= lo {
while t >= stop && t >= lo && times.len() < crate::warp::MAX_ANALYSIS_FRAMES {
times.push(t);
t -= fd;
}
+12
View File
@@ -405,6 +405,9 @@ const SMALL_MOTION: f64 = 1.0;
/// Lowest coarse NCC score of the best match for which the fine stage may search only ±1.
const RELIABLE_SCORE: f64 = 0.8;
/// Largest feature or search region side (layer pixels) a track point works with.
pub const MAX_REGION_SIDE: f64 = 4096.0;
/// Per-point tracking state.
#[derive(Clone, Debug)]
struct PointState {
@@ -445,6 +448,15 @@ impl PointState {
}
fn new(spec: PointSpec, frame: &Frame, opts: &TrackOptions) -> PointState {
// Regions come from saved projects and commands: each frame's crop and correlation
// search grow with them, so they are kept to a side a real feature never exceeds.
let side = |v: f64| if v.is_finite() { v.clamp(0.0, MAX_REGION_SIDE) } else { 0.0 };
let spec = PointSpec {
feature_size: spec.feature_size.map(side),
search_size: spec.search_size.map(side),
search_offset: spec.search_offset.map(|v| if v.is_finite() { v.clamp(-MAX_REGION_SIDE, MAX_REGION_SIDE) } else { 0.0 }),
..spec
};
let c = [spec.center[0] + frame.offset[0], spec.center[1] + frame.offset[1]];
let mut st = PointState { spec, center: c, shape: [1.0, 0.0], velocity: [0.0; 2], templates: vec![], levels: 0 };
let range = st.range();
+11
View File
@@ -328,3 +328,14 @@ fn featureless_regions_stay_put() {
}
}
}
#[test]
fn saved_regions_larger_than_any_feature_are_bounded() {
let img = Image::new(8, 8);
// A search region of 10⁹ pixels a side (and a non-finite one) on an 8×8 frame: the crop
// would hold 10¹⁸ samples.
for size in [1e9, f64::INFINITY, f64::NAN] {
let mut tr = Tracker::new(TrackOptions::default(), &[spec([4.0, 4.0], size, size)], &Frame::new(&img));
let _ = tr.step(&Frame::new(&img));
}
}