Merge pull request #3256 from vangel76/clamp-time-stretch-3247

Time stretch: hold a loaded or imported stretch to ±1 % to ±9900 %
This commit is contained in:
FredApps
2026-11-08 15:44:45 +01:00
committed by GitHub
14 changed files with 389 additions and 11 deletions
+8 -1
View File
@@ -699,7 +699,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)]
+41
View File
@@ -434,6 +434,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<f64> {
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<u8> {
mutate(&mut |c| {
+7
View File
@@ -59,9 +59,16 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
(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,
+18
View File
@@ -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
+22 -2
View File
@@ -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;
+18
View File
@@ -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<f64> = 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();
+6 -1
View File
@@ -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
+15
View File
@@ -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": []}});
+26 -2
View File
@@ -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<f64, D::Error> {
Ok(Option::<f64>::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> {
+112 -1
View File
@@ -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<String>) -> Option<Comp> {
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<String>) -> Option<Comp> {
}
(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<f64> = 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));
+5 -2
View File
@@ -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).
+1
View File
@@ -22,6 +22,7 @@ mod nested;
mod paint;
mod remap;
mod roi;
mod stretch_limits;
mod styles;
mod time;
+96
View File
@@ -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<Arc<Image>> {
None
}
fn audio(&self, _: ItemId, _: &Footage, _: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
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::<Vec<_>>());
// 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));
}
}
+14 -2
View File
@@ -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();
});