diff --git a/crates/engine/src/analysis/camera_track.rs b/crates/engine/src/analysis/camera_track.rs index 29961f533..2d997ec37 100644 --- a/crates/engine/src/analysis/camera_track.rs +++ b/crates/engine/src/analysis/camera_track.rs @@ -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 }) } diff --git a/crates/engine/src/analysis/tracking.rs b/crates/engine/src/analysis/tracking.rs index 221b28602..c23e088be 100644 --- a/crates/engine/src/analysis/tracking.rs +++ b/crates/engine/src/analysis/tracking.rs @@ -196,6 +196,9 @@ pub(crate) struct Work { pub times: Vec, } +/// 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)); } diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs index 71ee45ee6..8a79a6279 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -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 { +/// 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 { 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); + } +} diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index 77cb74946..78b6540d7 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -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; } diff --git a/crates/track/src/lib.rs b/crates/track/src/lib.rs index 8867bfcb8..3023d5cc0 100644 --- a/crates/track/src/lib.rs +++ b/crates/track/src/lib.rs @@ -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(); diff --git a/crates/track/src/tests.rs b/crates/track/src/tests.rs index c019996f5..707680225 100644 --- a/crates/track/src/tests.rs +++ b/crates/track/src/tests.rs @@ -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)); + } +}