From 5f73e5b5e935aae74587d18f20034f93a85e811e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 17:03:59 +0100 Subject: [PATCH] =?UTF-8?q?Time=20stretch:=20hold=20a=20loaded=20or=20impo?= =?UTF-8?q?rted=20stretch=20to=20=C2=B11=20%=20to=20=C2=B19900=20%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project file, an .aep, a timeline document or a Lottie file could give a layer a time stretch of 1e-300 %, 0 or 1e300 %. Layer and comp time divide by it and the audio mix read about usize::MAX source samples for one block, which overflowed or tried a huge allocation and aborted the app. - limits::stretch brings a stretch within After Effects' range (±1 % to ±9900 %, Layer.stretch in the scripting guide), sign kept; 0 and NaN are 100 %. Opening a project file corrects each layer and says so; a stretch written as null (NaN in an auto-save) now opens instead of failing. - .aep import and Lottie import clamp and report the stretch; timeline import clamps the clip speed and warns. - layer.timeStretch refuses a non-number and more than 9900 %; the Time Stretch dialog's ranges match. - Layer::layer_time / comp_time and the audio mix use the clamped stretch, so a value set in memory can't divide by zero or read without bound. Fixes #3247 --- crates/aep/src/import/build.rs | 9 +- crates/aep/tests/it/import/synthetic.rs | 41 ++++++++ crates/engine/src/cmd/layer_time.rs | 7 ++ crates/engine/src/tests/limits.rs | 18 ++++ crates/interchange/src/import.rs | 24 ++++- crates/interchange/src/tests.rs | 18 ++++ crates/lottie/src/import.rs | 7 +- crates/lottie/src/tests.rs | 15 +++ crates/project/src/layer.rs | 28 +++++- crates/project/src/limits.rs | 113 +++++++++++++++++++++- crates/render/src/audio.rs | 7 +- crates/render/src/tests.rs | 1 + crates/render/src/tests/stretch_limits.rs | 96 ++++++++++++++++++ crates/ui-egui/src/panels/key_dialogs.rs | 16 ++- 14 files changed, 389 insertions(+), 11 deletions(-) create mode 100644 crates/render/src/tests/stretch_limits.rs diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 4ec346328..ded1e64ad 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -681,7 +681,14 @@ fn apply_timing(l: &mut Layer, h: &LayerHeader, tm: &TimeMap, name: &str, col: & col.lose(LossCategory::DamagedData, format!("layer '{name}': the time stretch is zero or unreadable, 100 % is used")); Rational { num: 1, den: 1 } }); - l.stretch = stretch.to_f64().map_or(100.0, |r| r * 100.0); + let stored = stretch.to_f64().map_or(100.0, |r| r * 100.0); + // A ratio such as 1/2^31 (5e-8 %) or 2^31/1 is outside what After Effects allows (±1 % to + // ±9900 %); taken as stored, the layer and audio time would divide by ~0 (#3247). The in and + // out points keep the comp times the file stores. + l.stretch = effectcraft_project::limits::stretch(stored); + if l.stretch.to_bits() != stored.to_bits() { + col.lose(LossCategory::DamagedData, format!("layer '{name}': the time stretch of {stored:e} % is outside ±1 % to ±9900 %, {} % is used", l.stretch)); + } // A stored time with a zero denominator (or a sum that overflows) has no value; it is read as 0 // and reported, so the layer's timing never changes silently. let unreadable: Vec<&str> = [("start", h.start), ("in point", h.in_relative), ("out point", h.out_relative)] diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index 18709f9e5..e2fb20da4 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -273,6 +273,47 @@ fn layer_times_with_a_zero_denominator_are_reported() { assert!(losses(&r.unwrap(), LossCategory::DamagedData).is_empty()); } +/// The time stretch of every imported layer. +fn stretches(p: &Project) -> Vec { + p.items.values().filter_map(|i| if let ItemKind::Comp(c) = &i.kind { Some(c) } else { None }).flat_map(|c| c.layers.iter().map(|l| l.stretch)).collect() +} + +#[test] +fn a_time_stretch_outside_one_to_9900_percent_is_clamped_and_reported() { + // ldta byte 8 is the stretch numerator (i32), byte 108 the denominator (u32). 1/(2^31 - 1) is + // about 5e-8 %: imported as stored, the layer and audio time divided by ~0 (#3247). + for (num, den, want) in [(1i32, i32::MAX as u32, 1.0), (-1, i32::MAX as u32, -1.0), (i32::MAX, 1, 9900.0), (i32::MIN, 1, -9900.0)] { + let bytes = mutate(&mut |c| { + if c.id == *b"ldta" { + set_data(c, 8, &num.to_be_bytes()); + set_data(c, 108, &den.to_be_bytes()); + } + }); + let (p, r) = run(&bytes, true); + let r = r.unwrap(); + let found = losses(&r, LossCategory::DamagedData); + assert!(found.iter().any(|d| d.contains("time stretch") && d.contains("outside")), "{num}/{den}: {found:?}"); + let s = stretches(&p); + assert!(!s.is_empty() && s.iter().all(|v| *v == want), "{num}/{den}: {s:?}"); + let (p, r) = run(&bytes, false); + assert!(r.is_err(), "{num}/{den}: strict import must refuse"); + assert!(p.items.is_empty()); + } + // A small stretch within the range (1/64: 1.5625 %) and -9900 % itself are no damage. + for (num, den, want) in [(1i32, 64u32, 1.5625), (-99, 1, -9900.0)] { + let bytes = mutate(&mut |c| { + if c.id == *b"ldta" { + set_data(c, 8, &num.to_be_bytes()); + set_data(c, 108, &den.to_be_bytes()); + } + }); + let (p, r) = run(&bytes, true); + let r = r.unwrap(); + assert!(!losses(&r, LossCategory::DamagedData).iter().any(|d| d.contains("time stretch")), "{num}/{den}: {:?}", r.report.losses); + assert!(stretches(&p).iter().all(|v| *v == want), "{num}/{den}: {:?}", stretches(&p)); + } +} + /// The project with `nhed` bytes from `at` replaced. fn header(at: usize, bytes: &[u8]) -> Vec { mutate(&mut |c| { diff --git a/crates/engine/src/cmd/layer_time.rs b/crates/engine/src/cmd/layer_time.rs index 4ecf725ec..a6fe3d990 100644 --- a/crates/engine/src/cmd/layer_time.rs +++ b/crates/engine/src/cmd/layer_time.rs @@ -59,9 +59,16 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result { (None, Some(d)) if span > 0.0 => old * d / span, _ => old, }; + if !new.is_finite() { + return Err(bad("layer.timeStretch", "the stretch factor must be a number")); + } if new.abs() < 1.0 { return Err(bad("layer.timeStretch", "the stretch factor must be at least 1 %")); } + // After Effects' largest stretch; a project file holds no more either (#3247). + if new.abs() > effectcraft_project::limits::MAX_STRETCH { + return Err(bad("layer.timeStretch", "the stretch factor must be at most 9900 %")); + } // The hold point keeps showing the same layer time. let h = match hold { "current" => cti, diff --git a/crates/engine/src/tests/limits.rs b/crates/engine/src/tests/limits.rs index 524719bbf..9c156c4f7 100644 --- a/crates/engine/src/tests/limits.rs +++ b/crates/engine/src/tests/limits.rs @@ -103,6 +103,24 @@ fn commands_cap_sizes_at_30000_px() { assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97)); } +/// Time Stretch takes After Effects' range, ±1 % to ±9900 %: before, 1e300 % was set, and a +/// project file holding it opened at 9900 % (#3247). +#[test] +fn time_stretch_refuses_more_than_9900_percent() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 2})).unwrap(); + let layer = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let stretch = |s: &Session| s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == layer).unwrap().stretch; + for pct in [1e300, -1e300, 9900.5] { + assert!(s.execute("layer.timeStretch", json!({"layers": [layer], "percent": pct})).is_err(), "{pct}"); + assert_eq!(stretch(&s), 100.0, "{pct}"); + } + for pct in [limits::MAX_STRETCH, -limits::MAX_STRETCH] { + s.execute("layer.timeStretch", json!({"layers": [layer], "percent": pct})).unwrap(); + assert_eq!(stretch(&s), pct); + } +} + #[test] fn a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_out_of_stack() { // Comps A and B nest each other (a damaged file); C is unrelated. Checking whether A diff --git a/crates/interchange/src/import.rs b/crates/interchange/src/import.rs index 9789ceb89..22e22b4f2 100644 --- a/crates/interchange/src/import.rs +++ b/crates/interchange/src/import.rs @@ -522,11 +522,31 @@ impl Importer<'_> { // ---- timing let start = tick_in(ti.start); let dur = tick_in(ti.duration); - let speed = if ti.speed.abs() < 1e-9 { 1.0 } else { ti.speed.abs() }; + // A zero, tiny (below 1e-9) or non-finite speed plays at 100 %. Otherwise the layer's + // stretch (100 / speed) is held to the range the app supports (±1 % to ±9900 %): a speed + // of 1e300 or 1e-300 from a damaged file made the layer and audio time divide by ~0 + // (#3247). The speed follows the stretch so the start time below matches it. + let raw = ti.speed.abs(); + let (speed, stretch) = if !raw.is_finite() || raw < 1e-9 { + // A speed of exactly 0 keeps its old silent 100 %; a tiny or non-finite one is named. + if !raw.is_finite() || raw > 0.0 { + self.warn(format!("clip \"{}\": a speed of {} is not supported (100 % used)", ti.name, ti.speed)); + } + (1.0, 100.0) + } else { + let stretch = effectcraft_project::limits::stretch(100.0 / raw); + if stretch.to_bits() == (100.0 / raw).to_bits() { + (raw, stretch) + } else { + let speed = 100.0 / stretch; + self.warn(format!("clip \"{}\": a speed of {:e} % is outside what a layer supports ({} % used)", ti.name, raw * 100.0, speed * 100.0)); + (speed, stretch) + } + }; let reverse = ti.reverse || ti.speed < 0.0; let src_in = tick_in(ti.source_in); let span = Tick((dur.0 as f64 * speed).round() as i64); - l.stretch = if reverse { -100.0 / speed } else { 100.0 / speed }; + l.stretch = if reverse { -stretch } else { stretch }; l.start_time = if reverse { start + Tick(((src_in + span).0 as f64 / speed).round() as i64) } else { start - Tick((src_in.0 as f64 / speed).round() as i64) }; l.in_point = start; diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index f4c9158f8..b451ecf81 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -314,6 +314,24 @@ fn imports_fcp7_xml_fcpxml_and_otio() { } } +/// A clip speed past what a layer's stretch supports (±1 % to ±9900 %) is held to it and named. +/// Before, a speed of 1e6 gave a stretch of 1e-4 % and the layer and audio time divided by ~0 +/// (#3247). +#[test] +fn an_extreme_clip_speed_is_held_to_the_stretch_range() { + for (speed, stretch) in [(1e6, 1.0), (1e-6, 9900.0)] { + let (mut doc, main) = edit(); + doc.sequence_mut(main).unwrap().video_tracks[1].items[0].speed = speed; + let (bytes, _) = fc::export(&doc, main, fc::Format::Otio, &Default::default()).unwrap(); + let (p, r) = import_doc(&bytes, "Main.otio", &mut probe); + let main_comp = r.comps.iter().copied().find(|c| p.item(*c).unwrap().name == "Main").expect("Main comp"); + let c = p.comp(main_comp).unwrap(); + let stretched: Vec = c.layers.iter().map(|l| l.stretch).filter(|s| (s - 100.0).abs() > 1e-9).collect(); + assert_eq!(stretched, [stretch], "speed {speed}"); + assert!(r.warnings.iter().any(|w| w.contains("speed") && w.contains("outside")), "speed {speed}: {:?}", r.warnings); + } +} + #[test] fn imports_aaf_and_omf() { let (doc, main) = edit(); diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 53b1ba71c..57ef6427a 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -431,7 +431,12 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[ // Below 1 % (or 0) the layer time divides by ~0, and layer.timeStretch refuses it too. let sr = f(j, "sr").unwrap_or(1.0); l.stretch = if sr.is_finite() && sr.abs() >= 0.01 { - sr * 100.0 + // Past ±9900 % too (#3247): the largest stretch a layer supports, sign kept. + let pct = effectcraft_project::limits::stretch(sr * 100.0); + if pct.to_bits() != (sr * 100.0).to_bits() { + cx.im.warn(format!("{name}: a time stretch of {} % is not supported ({pct} % used)", sr * 100.0)); + } + pct } else { cx.im.warn(format!("{name}: a time stretch of {} % is not supported (100 % used)", sr * 100.0)); 100.0 diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index d26b3642d..414c875f0 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -520,6 +520,21 @@ fn zero_time_stretch_is_not_imported() { assert!(imp.warnings.iter().any(|w| w.contains("time stretch")), "{:?}", imp.warnings); } +#[test] +fn a_time_stretch_past_9900_percent_is_held_to_it() { + // `sr` 1e300 was imported as 1e302 % (#3247); the sign of a reversed layer is kept. + for (sr, want) in [(1e300, 9900.0), (-1e300, -9900.0), (99.0, 9900.0)] { + let doc = json!({"w": 20, "h": 20, "fr": 30, "ip": 0, "op": 30, "assets": [], + "layers": [{"ty": 1, "ind": 1, "sc": "#ff0000", "sw": 20, "sh": 20, "sr": sr, "ip": 0, "op": 30, "st": 0, "ks": {}}]}); + let mut q = Project::default(); + let imp = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap(); + let l = &q.comp(imp.comp).unwrap().layers[0]; + assert_eq!(l.stretch, want, "sr {sr}"); + let warned = imp.warnings.iter().any(|w| w.contains("time stretch")); + assert_eq!(warned, sr != 99.0, "sr {sr}: {:?}", imp.warnings); + } +} + #[test] fn embedded_glyphs_are_reported_when_dropped() { let glyph = |ch: &str, fam: &str| json!({"ch": ch, "size": 100, "style": "Regular", "w": 60, "fFamily": fam, "data": {"shapes": []}}); diff --git a/crates/project/src/layer.rs b/crates/project/src/layer.rs index 34eeb50c0..bc747bd88 100644 --- a/crates/project/src/layer.rs +++ b/crates/project/src/layer.rs @@ -470,6 +470,20 @@ pub enum AutoOrient { TowardsPointOfInterest, } +/// A stretch written as `null` (a NaN or infinite stretch: JSON holds neither, and auto-saves +/// write it so) reads as NaN, so the project still opens and loading brings it to 100 % with a +/// note ([`crate::limits::stretch`], #3247) instead of the whole file being refused. An infinite +/// stretch therefore also reopens at 100 %, not at ±9900 %. +fn stretch_or_nan<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + Ok(Option::::deserialize(d)?.unwrap_or(f64::NAN)) +} + +/// `t × mul / div` in ticks, within [`Tick::MIN`]..=[`Tick::MAX`] (the cast saturates, NaN is 0), +/// so a layer time of an extreme stretch can't overflow later tick arithmetic. +fn scaled(t: Tick, mul: f64, div: f64) -> Tick { + Tick(((t.0 as f64 * mul / div) as i64).clamp(Tick::MIN.0, Tick::MAX.0)) +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Layer { pub id: LayerId, @@ -484,6 +498,7 @@ pub struct Layer { pub in_point: Tick, pub out_point: Tick, /// Time stretch in percent (100 = normal, negative = reversed). + #[serde(deserialize_with = "stretch_or_nan")] pub stretch: f64, pub switches: Switches, pub blend_mode: BlendMode, @@ -555,15 +570,24 @@ impl Layer { t >= self.in_point && t < self.out_point } + /// The time stretch the layer plays at: [`Layer::stretch`] within + /// [`limits::stretch`](crate::limits::stretch) (a damaged value that got past loading, 0 or + /// NaN, never divides the layer's time by zero). + pub fn effective_stretch(&self) -> f64 { + crate::limits::stretch(self.stretch) + } + /// Layer time for a comp time (stretch-aware). pub fn layer_time(&self, comp_t: Tick) -> Tick { let d = comp_t - self.start_time; - if (self.stretch - 100.0).abs() < 1e-9 { d } else { Tick((d.0 as f64 * 100.0 / self.stretch) as i64) } + let s = self.effective_stretch(); + if (s - 100.0).abs() < 1e-9 { d } else { scaled(d, 100.0, s) } } /// Comp time for a layer time. pub fn comp_time(&self, layer_t: Tick) -> Tick { - if (self.stretch - 100.0).abs() < 1e-9 { self.start_time + layer_t } else { self.start_time + Tick((layer_t.0 as f64 * self.stretch / 100.0) as i64) } + let s = self.effective_stretch(); + if (s - 100.0).abs() < 1e-9 { self.start_time + layer_t } else { self.start_time + scaled(layer_t, s, 100.0) } } pub fn transform(&self) -> Option<&PropGroup> { diff --git a/crates/project/src/limits.rs b/crates/project/src/limits.rs index 998f40225..7a74c992b 100644 --- a/crates/project/src/limits.rs +++ b/crates/project/src/limits.rs @@ -27,6 +27,30 @@ pub const MAX_PIXEL_ASPECT: f64 = 10.0; pub const MOTION_BLUR_SAMPLES: (u32, u32) = (2, 64); /// Composition Settings ▸ Advanced ▸ Adaptive Sample Limit. pub const ADAPTIVE_SAMPLE_LIMIT: (u32, u32) = (16, 256); +/// The smallest time stretch, in percent, either way (After Effects sets a stretch between 0 and +/// 1 % to 1 %, between -1 % and 0 to -1 %; `layer.timeStretch` refuses less). +pub const MIN_STRETCH: f64 = 1.0; +/// The largest time stretch, in percent, either way (After Effects' `Layer.stretch` range is +/// -9900 to 9900). +pub const MAX_STRETCH: f64 = 9900.0; + +/// A layer's time stretch from [`MIN_STRETCH`] to [`MAX_STRETCH`] % either way, its sign (a +/// reversed layer) kept; 100 % for 0 or NaN. Layer and comp time divide by the stretch, and the +/// audio mix reads `100 / stretch` source samples per sample, so a stretch of 1e-300 % or NaN +/// from a damaged file must not reach them (#3247). +pub fn stretch(v: f64) -> f64 { + if v.is_nan() || v == 0.0 { + return 100.0; + } + // Infinity clamps to the largest stretch with its sign. + v.signum() * v.abs().clamp(MIN_STRETCH, MAX_STRETCH) +} + +/// A stretch for a note: a plain number in a readable range, scientific notation beyond it (a +/// plain 1e-300 would print 300 digits). +fn percent(v: f64) -> String { + if v == 0.0 || (1e-3..1e7).contains(&v.abs()) { format!("{v} %") } else { format!("{v:e} %") } +} /// A width or height from 1 to [`MAX_SIZE`]. pub fn size(v: u64) -> u32 { @@ -97,9 +121,11 @@ fn repair_comp(name: &str, c: &Comp, notes: &mut Vec) -> Option { let duration = c.duration.max(rate.frame_duration()); let samples = c.motion_blur_samples.clamp(MOTION_BLUR_SAMPLES.0, MOTION_BLUR_SAMPLES.1); let adaptive = c.motion_blur_adaptive_limit.clamp(ADAPTIVE_SAMPLE_LIMIT.0, ADAPTIVE_SAMPLE_LIMIT.1); + let stretch_ok = |l: &crate::Layer| stretch(l.stretch).to_bits() == l.stretch.to_bits(); let unchanged = (width, height, rate, duration, samples, adaptive) == (c.width, c.height, c.frame_rate, c.duration, c.motion_blur_samples, c.motion_blur_adaptive_limit) - && pa.to_bits() == c.pixel_aspect.to_bits(); + && pa.to_bits() == c.pixel_aspect.to_bits() + && c.layers.iter().all(stretch_ok); if unchanged { return None; } @@ -125,6 +151,12 @@ fn repair_comp(name: &str, c: &Comp, notes: &mut Vec) -> Option { } (n.width, n.height, n.pixel_aspect, n.frame_rate, n.duration) = (width, height, pa, rate, duration); (n.motion_blur_samples, n.motion_blur_adaptive_limit) = (samples, adaptive); + // Only the layers out of range are copied (the others stay shared). + for l in n.layers.iter_mut_where(|l| !stretch_ok(l)) { + let fixed = stretch(l.stretch); + note(notes, name, &format!("layer '{}' time stretch", l.name), percent(l.stretch), percent(fixed)); + l.stretch = fixed; + } Some(n) } @@ -217,6 +249,85 @@ mod tests { ); } + #[test] + fn a_stretch_is_held_to_one_to_9900_percent_with_its_sign() { + assert_eq!(stretch(f64::NAN), 100.0); + assert_eq!(stretch(0.0), 100.0); + assert_eq!(stretch(-0.0), 100.0); + assert_eq!(stretch(1e-300), MIN_STRETCH); + assert_eq!(stretch(-1e-300), -MIN_STRETCH); + assert_eq!(stretch(0.5), MIN_STRETCH); + assert_eq!(stretch(1e300), MAX_STRETCH); + assert_eq!(stretch(f64::INFINITY), MAX_STRETCH); + assert_eq!(stretch(f64::NEG_INFINITY), -MAX_STRETCH); + for v in [1.0, -1.0, 33.3, 100.0, -100.0, 200.0, 9900.0, -9900.0] { + assert_eq!(stretch(v).to_bits(), v.to_bits(), "{v} is in range"); + } + } + + /// A layer whose stretch got past loading (0, NaN, 1e-300 %, set in memory) still maps time + /// through a stretch in range: before, 0 and 1e-300 % saturated every layer time to the end of + /// time and NaN collapsed it to 0 (#3247). + #[test] + fn layer_and_comp_time_use_a_stretch_in_range() { + let mut p = crate::Project::default(); + let comp = Comp::new(64, 64, FrameRate::FPS_25, Tick::from_seconds_f64(10.0)); + let mut l = crate::build::layer(&mut p, &comp, "L", crate::LayerSource::Camera, (64, 64), None); + l.start_time = Tick::from_seconds_f64(1.0); + let at = |s: f64| Tick::from_seconds_f64(s); + for (bad, effective) in [(0.0, 100.0), (f64::NAN, 100.0), (1e-300, 1.0), (-1e-300, -1.0), (1e300, 9900.0), (f64::NEG_INFINITY, -9900.0)] { + l.stretch = bad; + assert_eq!(l.effective_stretch(), effective, "{bad}"); + // One comp second after the start is 100 / stretch layer seconds. + let want = Tick((at(1.0).0 as f64 * 100.0 / effective) as i64); + assert_eq!(l.layer_time(at(2.0)), want, "{bad}"); + // And back, within the rounding of the two conversions. + assert!((l.comp_time(l.layer_time(at(2.0))) - at(2.0)).abs() <= Tick(100), "{bad}"); + } + } + + #[test] + fn a_stretch_out_of_range_in_a_file_is_corrected_and_reported() { + let mut p = crate::Project::default(); + let comp = Comp::new(64, 64, FrameRate::FPS_25, Tick::from_seconds_f64(2.0)); + let mut c = comp.clone(); + for (name, s) in [("tiny", 1e-300), ("reversed tiny", -1e-300), ("zero", 0.0), ("huge", 1e300), ("reversed huge", -5e4), ("fine", -50.0)] { + let mut l = crate::build::layer(&mut p, &comp, name, crate::LayerSource::Camera, (64, 64), None); + l.stretch = s; + c.layers.push(l); + } + // Added through `add_item`, the comp is held to the limits too: write the values back in. + let id = p.add_item("Comp", effectcraft_color::Label::None, None, ItemKind::Comp(Arc::new(c.clone()))); + assert!(p.comp(id).unwrap().layers.iter().all(|l| l.stretch.abs() >= MIN_STRETCH && l.stretch.abs() <= MAX_STRETCH)); + let mut v: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap(); + for (k, l) in c.layers.iter().enumerate() { + v["items"][id.0.to_string()]["kind"]["layers"][k]["stretch"] = serde_json::json!(l.stretch); + } + let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap(); + let got: Vec = back.comp(id).unwrap().layers.iter().map(|l| l.stretch).collect(); + assert_eq!(got, [1.0, -1.0, 100.0, 9900.0, -9900.0, -50.0]); + assert_eq!( + notes, + [ + "Comp: layer 'tiny' time stretch 1e-300 % → 1 %", + "Comp: layer 'reversed tiny' time stretch -1e-300 % → -1 %", + "Comp: layer 'zero' time stretch 0 % → 100 %", + "Comp: layer 'huge' time stretch 1e300 % → 9900 %", + "Comp: layer 'reversed huge' time stretch -50000 % → -9900 %", + ] + ); + // A NaN stretch is written as null (an auto-save): the file opens, at 100 %, and says so. + // Before, the whole project was refused. + v["items"][id.0.to_string()]["kind"]["layers"][0]["stretch"] = serde_json::Value::Null; + let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap(); + assert_eq!(back.comp(id).unwrap().layers.iter().next().unwrap().stretch, 100.0); + assert_eq!(notes.first().map(String::as_str), Some("Comp: layer 'tiny' time stretch NaN % → 100 %")); + let mut nan = p.clone(); + nan.comp_mut(id).unwrap().layers.iter_mut().for_each(|l| l.stretch = f64::NAN); + let (back, _) = crate::Project::from_json_repaired(&nan.to_json_compact()).unwrap(); + assert!(back.comp(id).unwrap().layers.iter().all(|l| l.stretch == 100.0)); + } + #[test] fn a_comp_in_range_is_not_copied() { let c = Comp::new(1920, 1080, FrameRate::FPS_24, Tick::from_seconds_f64(2.0)); diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index e2861340c..00342847d 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -155,7 +155,9 @@ impl MixEnv<'_> { return out; } let m = b - a; - let speed = 100.0 / l.stretch; + // Within ±1 % to ±9900 % (#3247): a stretch of 1e-300 % set in memory read about + // usize::MAX source samples for one block. + let speed = 100.0 / l.effective_stretch(); let src_n = ((m as f64 * speed).ceil() as usize).max(1); let lt = l.layer_time(sample_tick(i0 + a as i64, rate)); let buf = match &l.source { @@ -291,7 +293,8 @@ fn mix_into(sources: &AudioSources<'_>, comp_id: ItemId, start: Tick, out: &mut let any_solo = comp.layers.iter().any(|l| l.switches.solo && audible(project, l)); let s0 = start.to_units_floor(rate as i64); for l in &comp.layers { - if !audible(project, l) || (any_solo && !l.switches.solo) || l.stretch <= 0.0 { + // The stretch the layer's time uses (#3247): 0 and NaN play at 100 %, as `layer_time`. + if !audible(project, l) || (any_solo && !l.switches.solo) || l.effective_stretch() <= 0.0 { continue; } // Sample range of the block that falls inside the layer's [in, out). diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index c77fc2d94..930bd810b 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -22,6 +22,7 @@ mod nested; mod paint; mod remap; mod roi; +mod stretch_limits; mod styles; mod time; diff --git a/crates/render/src/tests/stretch_limits.rs b/crates/render/src/tests/stretch_limits.rs new file mode 100644 index 000000000..17bdff317 --- /dev/null +++ b/crates/render/src/tests/stretch_limits.rs @@ -0,0 +1,96 @@ +//! A project file whose layers carry a time stretch outside ±1 % to ±9900 % (1e-300 %, 0, +//! 1e300 %…) opens with the stretch brought into that range, and a frame of it draws and its audio +//! mixes with bounded source reads. Before, a stretch of 1e-300 % asked the footage for about +//! `usize::MAX` samples and the mix overflowed or tried a huge allocation, aborting the app (#3247). + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use effectcraft_color::Label; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, build, limits}; +use effectcraft_time::Tick; + +use super::{setup, solid}; +use crate::{FootageSource, Image, audio::mix_comp, render_frame}; + +/// Silent audio footage that records the longest read asked of it and refuses a read past a +/// thousand seconds (so a runaway read can't take the machine's memory: the mix then fills the +/// read with silence itself). +#[derive(Default)] +struct Probe { + longest: AtomicUsize, +} + +impl FootageSource for Probe { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn audio(&self, _: ItemId, _: &Footage, _: Tick, frames: usize, rate: u32) -> Option> { + self.longest.fetch_max(frames, Ordering::Relaxed); + (frames <= rate as usize * 1000).then(|| vec![0.0; frames * 2]) + } +} + +/// The project file of a comp holding a red solid and a sound, both with their stretch set to +/// `stretch` in the file text. +fn file_with_stretch(stretch: f64) -> (String, ItemId) { + let (mut p, cid, comp) = setup(); + let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + let f = Footage { + path: "tone.wav".into(), + kind: FootageKind::Audio, + has_audio: true, + frame_rate: comp.frame_rate, + duration: Tick::from_seconds_f64(10.0), + ..Default::default() + }; + let fid = p.add_item("tone.wav", Label::SeaFoam, None, ItemKind::Footage(f)); + let sound = build::layer(&mut p, &comp, "sound", LayerSource::Footage { item: fid }, (0, 0), None); + let c = p.comp_mut(cid).unwrap(); + c.layers.push(red); + c.layers.push(sound); + // Both layers are at 100 %; `{:e}` writes a JSON number for any finite value, and NaN is + // written as null, as serializing the project writes it. + let text = p.to_json(); + assert_eq!(text.matches("\"stretch\": 100.0").count(), 2); + let value = if stretch.is_nan() { "null".to_string() } else { format!("{stretch:e}") }; + (text.replace("\"stretch\": 100.0", &format!("\"stretch\": {value}")), cid) +} + +#[test] +fn a_file_with_an_extreme_stretch_opens_in_range_draws_and_mixes_with_bounded_reads() { + for (stored, opened) in + [(1e-300, 1.0), (-1e-300, -1.0), (0.0, 100.0), (1e300, limits::MAX_STRETCH), (-1e300, -limits::MAX_STRETCH), (0.5, 1.0), (f64::NAN, 100.0)] + { + let (text, cid) = file_with_stretch(stored); + let (p, notes) = Project::from_json_repaired(&text).unwrap(); + assert!(notes.iter().filter(|n| n.contains("time stretch")).count() == 2, "{stored}: {notes:?}"); + let c = p.comp(cid).unwrap(); + assert!(c.layers.iter().all(|l| l.stretch == opened), "{stored}: {:?}", c.layers.iter().map(|l| l.stretch).collect::>()); + // A frame draws: the solid has no keys and keeps its in and out points, so it is red. + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + assert_eq!(img.get(10, 10), [1.0, 0.0, 0.0, 1.0], "{stored}"); + // The audio mixes, reading at most 100 source samples per comp sample (a stretch of 1 %). + let (rate, n) = (8000, 4000); + let probe = Probe::default(); + let mixed = mix_comp(&p, &probe, None, cid, Tick::from_seconds_f64(0.5), n, rate); + assert_eq!(mixed.len(), n * 2, "{stored}"); + assert!(probe.longest.load(Ordering::Relaxed) <= 100 * n + 1, "{stored}: a read of {} samples", probe.longest.load(Ordering::Relaxed)); + } +} + +/// A stretch set in memory (not through a file, so not repaired) mixes as the clamped stretch the +/// layer's time uses: before, 1e-300 % read about `usize::MAX` source samples and overflowed. +#[test] +fn an_in_memory_extreme_stretch_mixes_with_bounded_reads() { + for stretch in [1e-300, 0.0, f64::NAN, 1e300] { + let (text, cid) = file_with_stretch(100.0); + let (mut p, _) = Project::from_json_repaired(&text).unwrap(); + p.comp_mut(cid).unwrap().layers.iter_mut().for_each(|l| l.stretch = stretch); + let (rate, n) = (8000, 4000); + let probe = Probe::default(); + let mixed = mix_comp(&p, &probe, None, cid, Tick::from_seconds_f64(0.5), n, rate); + assert_eq!(mixed.len(), n * 2, "{stretch}"); + assert!(probe.longest.load(Ordering::Relaxed) <= 100 * n + 1, "{stretch}: a read of {} samples", probe.longest.load(Ordering::Relaxed)); + } +} diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs index 2a5579837..db3144ffd 100644 --- a/crates/ui-egui/src/panels/key_dialogs.rs +++ b/crates/ui-egui/src/panels/key_dialogs.rs @@ -262,7 +262,14 @@ pub fn time_stretch(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); ui.label(tr("Stretch Factor:")); let before = d.percent; - drag(ui, app, "dialog.timeStretch.percent", &mut d.percent, DragSpec { speed: 0.5, range: -10000.0..=10000.0, suffix: " %" }, true); + drag( + ui, + app, + "dialog.timeStretch.percent", + &mut d.percent, + DragSpec { speed: 0.5, range: -effectcraft_project::limits::MAX_STRETCH..=effectcraft_project::limits::MAX_STRETCH, suffix: " %" }, + true, + ); if (before - d.percent).abs() > 1e-9 && d.orig_percent.abs() > 1e-9 { d.duration = d.orig_duration * d.percent.abs() / d.orig_percent.abs(); } @@ -271,7 +278,12 @@ pub fn time_stretch(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let before = d.duration; drag(ui, app, "dialog.timeStretch.duration", &mut d.duration, DragSpec { speed: 0.05, range: 0.01..=100000.0, suffix: " s" }, true); if (before - d.duration).abs() > 1e-9 && d.orig_duration > 1e-9 { - d.percent = d.orig_percent.signum() * d.orig_percent.abs() * d.duration / d.orig_duration; + let wanted = d.orig_percent.signum() * d.orig_percent.abs() * d.duration / d.orig_duration; + // Within what Time Stretch accepts (±1 % to ±9900 %, #3247); the duration follows. + d.percent = effectcraft_project::limits::stretch(wanted); + if d.percent.to_bits() != wanted.to_bits() && d.orig_percent.abs() > 1e-9 { + d.duration = d.orig_duration * d.percent.abs() / d.orig_percent.abs(); + } } ui.end_row(); });