mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-09 03:49:12 +00:00
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:
@@ -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)]
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": []}});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -22,6 +22,7 @@ mod nested;
|
||||
mod paint;
|
||||
mod remap;
|
||||
mod roi;
|
||||
mod stretch_limits;
|
||||
mod styles;
|
||||
mod time;
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user