mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-14 11:17:01 -04:00
TIME.FRAME_NAVIGATION: Preserve exact frame steps and bound time arithmetic
Co-authored-by: Codex <[email protected]>
This commit is contained in:
1 parent
9c43a0d02f
commit
a3ad7a8ce7
8 files changed
+437
-47
No files matched your search
@@ -1813,8 +1813,11 @@ fn build() -> Vec<CommandSpec> {
|
||||
cmd!("playhead.step", "Step Frames", [], None, r#"{"frames":i64}"#, has_seq, |s, p| {
|
||||
let n = p.get("frames").and_then(Value::as_i64).unwrap_or(1);
|
||||
let r = s.sequence_rate();
|
||||
let f = r.frame_at(s.playhead()) + n;
|
||||
s.set_playhead(r.tick_of(f.max(0)));
|
||||
let f = r.frame_at(s.playhead()).checked_add(n).ok_or_else(|| bad("playhead.step", "frame offset is too large"))?.max(0);
|
||||
if f > r.frame_at(Tick::MAX) {
|
||||
return Err(bad("playhead.step", "target frame exceeds the supported time range"));
|
||||
}
|
||||
s.set_playhead(r.tick_of(f));
|
||||
Ok(json!({"frame": r.frame_at(s.playhead())}))
|
||||
}),
|
||||
cmd!("playhead.stepForward", "Step Forward One Frame", [], Some("Right"), "{}", has_seq, |s, _| s.execute("playhead.step", json!({"frames": 1}))),
|
||||
|
||||
@@ -35,6 +35,50 @@ fn demo_project_is_valid_and_renders() {
|
||||
assert!(img.px.chunks(4).any(|p| p[3] > 0.9));
|
||||
}
|
||||
|
||||
/// Ordinary and configured multi-frame offsets remain valid.
|
||||
#[test]
|
||||
fn frame_steps_in_new_custom_rate_sequences_move_in_the_requested_direction() {
|
||||
for fps in [31.0, 23.98, 24.999] {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"fps": fps})).unwrap();
|
||||
let rate = s.sequence_rate();
|
||||
s.execute("playhead.set", json!({"frame": 10})).unwrap();
|
||||
assert_eq!(rate.frame_at(s.playhead()), 10);
|
||||
for frame in 11..=42 {
|
||||
s.execute("playhead.stepForward", json!({})).unwrap();
|
||||
assert_eq!(rate.frame_at(s.playhead()), frame);
|
||||
}
|
||||
for frame in (10..42).rev() {
|
||||
s.execute("playhead.stepBack", json!({})).unwrap();
|
||||
assert_eq!(rate.frame_at(s.playhead()), frame);
|
||||
}
|
||||
s.execute("prefs.set", json!({"key": "playback.stepManyFrames", "value": 12})).unwrap();
|
||||
s.execute("playhead.stepForward5", json!({})).unwrap();
|
||||
assert_eq!(rate.frame_at(s.playhead()), 22);
|
||||
s.execute("playhead.stepBack5", json!({})).unwrap();
|
||||
assert_eq!(rate.frame_at(s.playhead()), 10);
|
||||
s.execute("playhead.step", json!({"frames": -20})).unwrap();
|
||||
s.execute("playhead.stepBack", json!({})).unwrap();
|
||||
assert_eq!(s.playhead(), Tick::ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_frame_steps_are_errors_and_leave_the_playhead_unchanged() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"fps": 31.0})).unwrap();
|
||||
for start in [10, 0] {
|
||||
s.execute("playhead.set", json!({"frame": start})).unwrap();
|
||||
let before = s.playhead();
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute("playhead.step", json!({"frames": i64::MAX}))));
|
||||
assert!(result.is_ok(), "an extreme frame offset must not panic");
|
||||
assert!(matches!(result.unwrap(), Err(EngineError::BadParams { .. })));
|
||||
assert_eq!(s.playhead(), before);
|
||||
}
|
||||
s.execute("playhead.step", json!({"frames": i64::MIN})).unwrap();
|
||||
assert_eq!(s.playhead(), Tick::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_edit_undo_redo() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -204,7 +204,7 @@ fn gpu_push_matches_cpu_with_alpha_and_surrounding_blends() {
|
||||
c.composite_prepared(&plan, Some(&prepare(&plan)));
|
||||
let (_, _, gpu) = c.read_output().expect("readback");
|
||||
let mut diffs: Vec<u32> =
|
||||
cpu.chunks_exact(4).zip(gpu.chunks_exact(4)).map(|(a, b)| (0..3).map(|k| a[k].abs_diff(b[k]) as u32).max().unwrap()).collect();
|
||||
cpu.as_chunks::<4>().0.iter().zip(gpu.as_chunks::<4>().0).map(|(a, b)| (0..3).map(|k| a[k].abs_diff(b[k]) as u32).max().unwrap()).collect();
|
||||
diffs.sort_unstable();
|
||||
let p99 = diffs[diffs.len() * 99 / 100];
|
||||
let mean = diffs.iter().sum::<u32>() as f64 / diffs.len() as f64;
|
||||
|
||||
@@ -36,8 +36,14 @@ fn off_uses_the_software_decoder() {
|
||||
assert_eq!(d.name(), "FilmCraft HEVC");
|
||||
return;
|
||||
}
|
||||
let (backend, hw_name) = if cfg!(target_os = "macos") { ("VideoToolbox", "VideoToolbox HEVC") } else { ("Media Foundation", "Media Foundation HEVC") };
|
||||
assert_eq!(d.name(), hw_name);
|
||||
let backend = if cfg!(target_os = "linux") {
|
||||
assert!(d.name().starts_with("VA-API HEVC ("), "Linux Auto decoder: {}", d.name());
|
||||
"VA-API"
|
||||
} else {
|
||||
let (backend, hw_name) = if cfg!(target_os = "macos") { ("VideoToolbox", "VideoToolbox HEVC") } else { ("Media Foundation", "Media Foundation HEVC") };
|
||||
assert_eq!(d.name(), hw_name);
|
||||
backend
|
||||
};
|
||||
assert_eq!(filmcraft_codecs::hw::hw_backend(), Some(backend), "perf.stats reports the backend");
|
||||
assert!(filmcraft_platform::registered(), "register() put the factory in the registry");
|
||||
let h1 = filmcraft_codecs::hw::hw_stats();
|
||||
|
||||
+198
-38
@@ -20,6 +20,7 @@ use std::ops::{Add, AddAssign, Neg, Sub, SubAssign};
|
||||
pub const TICKS_PER_SECOND: i64 = 254_016_000_000;
|
||||
|
||||
/// A point or duration in time, in ticks.
|
||||
/// Arithmetic saturates at the i64 storage limits; representable results remain exact.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Tick(pub i64);
|
||||
@@ -37,20 +38,26 @@ impl Tick {
|
||||
}
|
||||
/// Exact conversion from a count of `units` at `per_second` (e.g. samples at 48 000).
|
||||
pub fn from_units(units: i64, per_second: i64) -> Tick {
|
||||
Tick((units as i128 * TICKS_PER_SECOND as i128).checked_div(per_second as i128).unwrap_or(0) as i64)
|
||||
Tick(clamp_i64((units as i128 * TICKS_PER_SECOND as i128).checked_div(per_second as i128).unwrap_or(0)))
|
||||
}
|
||||
/// Floor conversion to a count of units at `per_second`.
|
||||
pub fn to_units_floor(self, per_second: i64) -> i64 {
|
||||
(self.0 as i128 * per_second as i128).div_euclid(TICKS_PER_SECOND as i128) as i64
|
||||
clamp_i64((self.0 as i128 * per_second as i128).div_euclid(TICKS_PER_SECOND as i128))
|
||||
}
|
||||
/// Conversion from a rational timestamp `pts * num / den` seconds (container timebases).
|
||||
pub fn from_rational(pts: i64, num: i64, den: i64) -> Tick {
|
||||
let t = pts as i128 * num as i128 * TICKS_PER_SECOND as i128;
|
||||
Tick(t.checked_div_euclid(den as i128).unwrap_or(0) as i64)
|
||||
if den == 0 {
|
||||
return Tick::ZERO;
|
||||
}
|
||||
let Some(t) = (pts as i128 * num as i128).checked_mul(TICKS_PER_SECOND as i128) else {
|
||||
// A product beyond i128 divided by an i64 cannot fit back into i64.
|
||||
return Tick(if (pts < 0) ^ (num < 0) ^ (den < 0) { i64::MIN } else { i64::MAX });
|
||||
};
|
||||
Tick(clamp_i64(t.div_euclid(den as i128)))
|
||||
}
|
||||
/// Inverse of [`Tick::from_rational`], floored to the timebase.
|
||||
pub fn to_rational_floor(self, num: i64, den: i64) -> i64 {
|
||||
(self.0 as i128 * den as i128).checked_div_euclid(num as i128 * TICKS_PER_SECOND as i128).unwrap_or(0) as i64
|
||||
clamp_i64((self.0 as i128 * den as i128).checked_div_euclid(num as i128 * TICKS_PER_SECOND as i128).unwrap_or(0))
|
||||
}
|
||||
/// Inverse of [`Tick::from_rational`], rounded to the nearest timebase unit (halves up).
|
||||
///
|
||||
@@ -63,10 +70,10 @@ impl Tick {
|
||||
let (n, unit) = if unit < 0 { (-n, -unit) } else { (n, unit) };
|
||||
let (Some(q), Some(r)) = (n.checked_div_euclid(unit), n.checked_rem_euclid(unit)) else { return 0 };
|
||||
// 0 <= r < unit: round up from the halfway point
|
||||
(if r * 2 >= unit { q + 1 } else { q }) as i64
|
||||
clamp_i64(if r * 2 >= unit { q + 1 } else { q })
|
||||
}
|
||||
pub fn abs(self) -> Tick {
|
||||
Tick(self.0.abs())
|
||||
Tick(self.0.saturating_abs())
|
||||
}
|
||||
pub fn min(self, o: Tick) -> Tick {
|
||||
Tick(self.0.min(o.0))
|
||||
@@ -80,36 +87,36 @@ impl Tick {
|
||||
}
|
||||
/// Scale by a rational factor (`num/den`), flooring.
|
||||
pub fn mul_ratio(self, num: i64, den: i64) -> Tick {
|
||||
Tick((self.0 as i128 * num as i128).checked_div_euclid(den as i128).unwrap_or(0) as i64)
|
||||
Tick(clamp_i64((self.0 as i128 * num as i128).checked_div_euclid(den as i128).unwrap_or(0)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for Tick {
|
||||
type Output = Tick;
|
||||
fn add(self, o: Tick) -> Tick {
|
||||
Tick(self.0 + o.0)
|
||||
Tick(self.0.saturating_add(o.0))
|
||||
}
|
||||
}
|
||||
impl Sub for Tick {
|
||||
type Output = Tick;
|
||||
fn sub(self, o: Tick) -> Tick {
|
||||
Tick(self.0 - o.0)
|
||||
Tick(self.0.saturating_sub(o.0))
|
||||
}
|
||||
}
|
||||
impl Neg for Tick {
|
||||
type Output = Tick;
|
||||
fn neg(self) -> Tick {
|
||||
Tick(-self.0)
|
||||
Tick(self.0.saturating_neg())
|
||||
}
|
||||
}
|
||||
impl AddAssign for Tick {
|
||||
fn add_assign(&mut self, o: Tick) {
|
||||
self.0 += o.0;
|
||||
self.0 = self.0.saturating_add(o.0);
|
||||
}
|
||||
}
|
||||
impl SubAssign for Tick {
|
||||
fn sub_assign(&mut self, o: Tick) {
|
||||
self.0 -= o.0;
|
||||
self.0 = self.0.saturating_sub(o.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,8 +195,8 @@ impl FrameRate {
|
||||
];
|
||||
|
||||
pub fn new(num: i64, den: i64) -> Self {
|
||||
let g = gcd(num.abs(), den.abs()).max(1);
|
||||
FrameRate { num: num / g, den: den / g }
|
||||
let g = i128::from(gcd(num.unsigned_abs(), den.unsigned_abs()).max(1));
|
||||
FrameRate { num: (i128::from(num) / g) as i64, den: (i128::from(den) / g) as i64 }
|
||||
}
|
||||
|
||||
/// Closest standard rate for a float (e.g. from a container's average rate).
|
||||
@@ -213,49 +220,52 @@ impl FrameRate {
|
||||
}
|
||||
|
||||
/// Exact duration of one frame (rounded down only for exotic rates).
|
||||
/// Values outside the integer tick range saturate at its limit.
|
||||
pub fn frame_duration(self) -> Tick {
|
||||
let r = self.sane();
|
||||
Tick(((TICKS_PER_SECOND as i128 * r.den as i128) / r.num as i128) as i64)
|
||||
Tick(clamp_i64((TICKS_PER_SECOND as i128 * r.den as i128) / r.num as i128))
|
||||
}
|
||||
|
||||
/// Index of the frame containing `t` (floor).
|
||||
/// Index of the frame containing `t` (floor), saturated to the integer frame range.
|
||||
pub fn frame_at(self, t: Tick) -> i64 {
|
||||
let r = self.sane();
|
||||
(t.0 as i128 * r.num as i128).div_euclid(TICKS_PER_SECOND as i128 * r.den as i128) as i64
|
||||
clamp_i64(frame_index(self.sane(), t))
|
||||
}
|
||||
|
||||
/// Start tick of frame `f` (ceiling division).
|
||||
///
|
||||
/// Uses ceiling (not floor) so that `frame_at(tick_of(f)) == f` holds for every
|
||||
/// frame index, even when the frame duration is not an integer number of ticks
|
||||
/// representable frame boundary with at least one tick per frame, even when the frame duration is not an integer number of ticks
|
||||
/// (exotic rates like 37.516 fps where `TICKS_PER_SECOND * den / num` has a
|
||||
/// fractional remainder). Floor division truncates the remainder, causing each
|
||||
/// frame boundary to drift backward by one tick until `frame_at` returns `f - 1`
|
||||
/// instead of `f` — frame stepping then stalls or moves backward.
|
||||
/// Ceiling is correct because the frame duration is always > 1 tick, so
|
||||
/// `floor(ceil(f·D) / D) == f` for every integer `f`.
|
||||
/// Values outside the integer tick range saturate at its limit.
|
||||
pub fn tick_of(self, f: i64) -> Tick {
|
||||
let r = self.sane();
|
||||
let n = f as i128 * TICKS_PER_SECOND as i128 * r.den as i128;
|
||||
Tick((-(-n).div_euclid(r.num as i128)) as i64)
|
||||
Tick(clamp_i64(frame_boundary(self.sane(), i128::from(f))))
|
||||
}
|
||||
|
||||
/// Snap `t` down to a frame boundary.
|
||||
pub fn snap(self, t: Tick) -> Tick {
|
||||
self.tick_of(self.frame_at(t))
|
||||
let r = self.sane();
|
||||
Tick(clamp_i64(frame_boundary(r, frame_index(r, t))))
|
||||
}
|
||||
|
||||
/// Snap `t` to the nearest frame boundary.
|
||||
pub fn snap_nearest(self, t: Tick) -> Tick {
|
||||
let a = self.snap(t);
|
||||
let b = self.tick_of(self.frame_at(t) + 1);
|
||||
if (t - a) <= (b - t) { a } else { b }
|
||||
let r = self.sane();
|
||||
// Keep the frame index and both boundaries wide until choosing the nearest. Saturating
|
||||
// an unrepresentable upper boundary first would make it seem closer than it really is.
|
||||
let f = frame_index(r, t);
|
||||
let a = frame_boundary(r, f);
|
||||
let b = frame_boundary(r, f.saturating_add(1));
|
||||
let t = i128::from(t.0);
|
||||
Tick(clamp_i64(if t - a <= b - t { a } else { b }))
|
||||
}
|
||||
|
||||
/// Timecode base (frames counted per timecode second): 30 for 29.97, 24 for 23.976.
|
||||
pub fn timecode_base(self) -> i64 {
|
||||
let r = self.sane();
|
||||
((r.num + r.den - 1) / r.den).max(1)
|
||||
(r.num / r.den).saturating_add(i64::from(r.num % r.den != 0)).max(1)
|
||||
}
|
||||
|
||||
/// NTSC (x/1001) rates can use drop-frame timecode.
|
||||
@@ -286,7 +296,29 @@ impl fmt::Display for FrameRate {
|
||||
}
|
||||
}
|
||||
|
||||
fn gcd(mut a: i64, mut b: i64) -> i64 {
|
||||
/// Narrow only after applying the storage bounds, never by a wrapping integer cast.
|
||||
fn clamp_i64(value: i128) -> i64 {
|
||||
value.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64
|
||||
}
|
||||
|
||||
/// Callers pass a positive rate. Products of two i64 values fit in i128.
|
||||
fn frame_index(rate: FrameRate, t: Tick) -> i128 {
|
||||
(i128::from(t.0) * i128::from(rate.num)).div_euclid(i128::from(TICKS_PER_SECOND) * i128::from(rate.den))
|
||||
}
|
||||
|
||||
/// Ceiling of the rational boundary. An overflowing numerator divided by an i64 rate cannot
|
||||
/// fit in an i64 tick, so saturating that intermediate preserves the public result. Boundaries
|
||||
/// derived from frame_index remain within i128 (at most one frame away from an i64 tick).
|
||||
fn frame_boundary(rate: FrameRate, frame: i128) -> i128 {
|
||||
let Some(n) = frame.checked_mul(i128::from(TICKS_PER_SECOND)).and_then(|n| n.checked_mul(i128::from(rate.den))) else {
|
||||
return if frame < 0 { i128::MIN } else { i128::MAX };
|
||||
};
|
||||
let d = i128::from(rate.num);
|
||||
// Flooring a fractional boundary puts the tick in the preceding frame.
|
||||
n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0)
|
||||
}
|
||||
|
||||
fn gcd(mut a: u64, mut b: u64) -> u64 {
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
@@ -329,8 +361,9 @@ fn df_params(rate: FrameRate) -> (i64, i64) {
|
||||
/// Convert a frame count to SMPTE fields `(negative, h, m, s, f)`.
|
||||
pub fn frames_to_fields(frame: i64, rate: FrameRate, drop_frame: bool) -> (bool, i64, i64, i64, i64) {
|
||||
let neg = frame < 0;
|
||||
let mut n = frame.saturating_abs();
|
||||
let mut n = i128::from(frame).abs();
|
||||
let (drop, base) = df_params(rate);
|
||||
let (drop, base) = (i128::from(drop), i128::from(base));
|
||||
if drop_frame && rate.supports_drop_frame() {
|
||||
let per_min = base * 60 - drop;
|
||||
let per_10 = per_min * 10 + drop;
|
||||
@@ -345,18 +378,20 @@ pub fn frames_to_fields(frame: i64, rate: FrameRate, drop_frame: bool) -> (bool,
|
||||
let s = (n / base) % 60;
|
||||
let mi = (n / (base * 60)) % 60;
|
||||
let h = n / (base * 3600);
|
||||
(neg, h, mi, s, f)
|
||||
(neg, clamp_i64(h), clamp_i64(mi), clamp_i64(s), clamp_i64(f))
|
||||
}
|
||||
|
||||
/// Convert SMPTE fields back to a frame count.
|
||||
pub fn fields_to_frames(h: i64, m: i64, s: i64, f: i64, rate: FrameRate, drop_frame: bool) -> i64 {
|
||||
let (drop, base) = df_params(rate);
|
||||
let mut n = h.saturating_mul(3600).saturating_add(m.saturating_mul(60)).saturating_add(s).saturating_mul(base).saturating_add(f);
|
||||
let seconds = i128::from(h) * 3600 + i128::from(m) * 60 + i128::from(s);
|
||||
// Nominal drop-frame labels can exceed i64 even when the final frame count fits.
|
||||
let mut n = seconds.saturating_mul(i128::from(base)).saturating_add(i128::from(f));
|
||||
if drop_frame && rate.supports_drop_frame() {
|
||||
let total_min = h.saturating_mul(60).saturating_add(m);
|
||||
n = n.saturating_sub(drop.saturating_mul(total_min - total_min / 10));
|
||||
let total_min = i128::from(h) * 60 + i128::from(m);
|
||||
n = n.saturating_sub(i128::from(drop) * (total_min - total_min / 10));
|
||||
}
|
||||
n
|
||||
clamp_i64(n)
|
||||
}
|
||||
|
||||
/// Format a frame count as SMPTE timecode (`HH:MM:SS:FF`, or `HH;MM;SS;FF` for drop-frame).
|
||||
@@ -377,7 +412,7 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp
|
||||
TimeDisplay::Feet35 | TimeDisplay::Feet16 => {
|
||||
let per_ft = if display == TimeDisplay::Feet35 { 16 } else { 40 };
|
||||
let neg = frame < 0;
|
||||
let a = frame.abs();
|
||||
let a = frame.unsigned_abs();
|
||||
format!("{}{}+{:02}", if neg { "-" } else { "" }, a / per_ft, a % per_ft)
|
||||
}
|
||||
TimeDisplay::AudioSamples => {
|
||||
@@ -511,6 +546,119 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_rates_saturate_without_wrapping_and_keep_representable_values_exact() {
|
||||
let slow = FrameRate { num: 1, den: i64::MAX };
|
||||
assert_eq!(slow.frame_duration(), Tick(i64::MAX));
|
||||
assert_eq!(slow.tick_of(i64::MAX), Tick(i64::MAX));
|
||||
assert_eq!(slow.tick_of(i64::MIN), Tick(i64::MIN));
|
||||
assert_eq!(slow.snap_nearest(Tick(i64::MAX)), Tick::ZERO, "compare the real boundary before saturating");
|
||||
assert_eq!(slow.snap_nearest(Tick(i64::MIN)), Tick::ZERO);
|
||||
let fast = FrameRate { num: i64::MAX, den: 1 };
|
||||
assert_eq!(fast.tick_of(i64::MAX), Tick(TICKS_PER_SECOND), "large factors can cancel exactly");
|
||||
assert_eq!(fast.tick_of(-i64::MAX), Tick(-TICKS_PER_SECOND));
|
||||
assert_eq!(fast.frame_at(Tick(TICKS_PER_SECOND)), i64::MAX);
|
||||
assert_eq!(fast.frame_at(Tick(i64::MAX)), i64::MAX);
|
||||
assert_eq!(fast.frame_at(Tick(i64::MIN)), i64::MIN);
|
||||
assert_eq!(fast.timecode_base(), i64::MAX);
|
||||
for t in [i64::MIN, -TICKS_PER_SECOND, -1, 0, 1, TICKS_PER_SECOND, i64::MAX] {
|
||||
assert_eq!(fast.snap(Tick(t)), Tick(t), "sub-tick frames include every integer tick");
|
||||
assert_eq!(fast.snap_nearest(Tick(t)), Tick(t));
|
||||
}
|
||||
let balanced = FrameRate { num: i64::MAX, den: i64::MAX };
|
||||
assert_eq!(balanced.frame_duration(), Tick(TICKS_PER_SECOND));
|
||||
assert_eq!(balanced.tick_of(2), Tick(2 * TICKS_PER_SECOND));
|
||||
assert_eq!(balanced.frame_at(Tick(2 * TICKS_PER_SECOND)), 2);
|
||||
assert_eq!(balanced.timecode_base(), 1);
|
||||
assert_eq!(FrameRate::new(i64::MIN, i64::MIN), FrameRate { num: -1, den: -1 });
|
||||
assert_eq!(FrameRate::new(i64::MIN, 0), FrameRate { num: -1, den: 0 });
|
||||
assert_eq!(FrameRate::new(i64::MIN, 1), FrameRate { num: i64::MIN, den: 1 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_time_conversions_saturate_at_storage_limits() {
|
||||
assert_eq!(Tick::from_units(i64::MAX, 1), Tick(i64::MAX));
|
||||
assert_eq!(Tick::from_units(i64::MIN, 1), Tick(i64::MIN));
|
||||
assert_eq!(Tick(i64::MAX).to_units_floor(i64::MAX), i64::MAX);
|
||||
assert_eq!(Tick(i64::MIN).to_units_floor(i64::MAX), i64::MIN);
|
||||
assert_eq!(Tick::from_rational(i64::MAX, i64::MAX, 1), Tick(i64::MAX));
|
||||
assert_eq!(Tick::from_rational(i64::MIN, i64::MAX, 1), Tick(i64::MIN));
|
||||
assert_eq!(Tick::from_rational(i64::MIN, i64::MAX, -1), Tick(i64::MAX));
|
||||
assert_eq!(Tick::from_rational(i64::MAX, i64::MAX, 0), Tick::ZERO);
|
||||
assert_eq!(Tick(i64::MAX).to_rational_floor(1, i64::MAX), i64::MAX);
|
||||
assert_eq!(Tick(i64::MIN).to_rational_round(1, i64::MAX), i64::MIN);
|
||||
assert_eq!(Tick(i64::MAX).mul_ratio(i64::MAX, 1), Tick(i64::MAX));
|
||||
assert_eq!(Tick(i64::MIN).abs(), Tick(i64::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_frame_fields_keep_cancelling_terms_wide_at_storage_limits() {
|
||||
for rate in [FrameRate::FPS_29_97, FrameRate::FPS_59_94, FrameRate::FPS_119_88] {
|
||||
for frame in [0, 1, 999_999, i64::MAX - 1, i64::MAX] {
|
||||
let (negative, h, m, s, f) = frames_to_fields(frame, rate, true);
|
||||
assert!(!negative);
|
||||
assert_eq!(fields_to_frames(h, m, s, f, rate, true), frame, "{rate}: {frame}");
|
||||
assert_eq!(fields_to_frames(-h, -m, -s, -f, rate, true), -frame);
|
||||
}
|
||||
let (negative, h, m, s, f) = frames_to_fields(i64::MIN, rate, true);
|
||||
assert!(negative);
|
||||
assert_eq!(fields_to_frames(-h, -m, -s, -f, rate, true), i64::MIN);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_arithmetic_saturates_only_outside_storage_limits() {
|
||||
let values = [i64::MIN, i64::MIN + 1, Tick::MIN.0, -1, 0, 1, Tick::MAX.0, i64::MAX - 1, i64::MAX];
|
||||
for a in values {
|
||||
assert_eq!(-Tick(a), Tick(clamp_i64(-i128::from(a))));
|
||||
for b in values {
|
||||
let sum = Tick(clamp_i64(i128::from(a) + i128::from(b)));
|
||||
let difference = Tick(clamp_i64(i128::from(a) - i128::from(b)));
|
||||
assert_eq!(Tick(a) + Tick(b), sum);
|
||||
assert_eq!(Tick(a) - Tick(b), difference);
|
||||
let mut assigned = Tick(a);
|
||||
assigned += Tick(b);
|
||||
assert_eq!(assigned, sum);
|
||||
assigned = Tick(a);
|
||||
assigned -= Tick(b);
|
||||
assert_eq!(assigned, difference);
|
||||
}
|
||||
}
|
||||
assert_eq!(Tick::MAX + Tick(1), Tick(Tick::MAX.0 + 1), "project bounds are not storage bounds");
|
||||
assert_eq!(Tick::MIN - Tick(1), Tick(Tick::MIN.0 - 1));
|
||||
assert_eq!(TimeRange::new(Tick(i64::MAX), Tick(1)).end(), Tick(i64::MAX));
|
||||
assert_eq!(TimeRange::new(Tick(i64::MIN), Tick(-1)).end(), Tick(i64::MIN));
|
||||
assert_eq!(TimeRange::from_bounds(Tick(i64::MIN), Tick(i64::MAX)).duration, Tick(i64::MAX));
|
||||
assert_eq!(TimeRange::from_bounds(Tick(i64::MAX), Tick(i64::MIN)).duration, Tick(i64::MIN));
|
||||
let ordinary = TimeRange::from_bounds(Tick::MIN, Tick::MAX);
|
||||
assert_eq!(ordinary.end(), Tick::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_limits_are_total_across_frame_conversion_and_display() {
|
||||
let values = [i64::MIN, -i64::MAX, -1, 0, 1, TICKS_PER_SECOND, i64::MAX];
|
||||
for num in values {
|
||||
for den in values {
|
||||
let r = FrameRate { num, den };
|
||||
let _ = FrameRate::new(num, den);
|
||||
let _ = r.frame_duration();
|
||||
assert!(r.timecode_base() >= 1);
|
||||
for value in values {
|
||||
let tick = Tick(value);
|
||||
let _ = r.tick_of(value);
|
||||
let _ = r.frame_at(tick);
|
||||
let _ = r.snap(tick);
|
||||
let _ = r.snap_nearest(tick);
|
||||
for display in TimeDisplay::ALL {
|
||||
let _ = format_time(tick, r, true, display, 48_000);
|
||||
}
|
||||
let (_, h, m, sec, f) = frames_to_fields(value, r, true);
|
||||
let _ = fields_to_frames(h, m, sec, f, r, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_common_rate_is_exact() {
|
||||
for r in FrameRate::COMMON {
|
||||
@@ -660,6 +808,18 @@ mod tests {
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn extreme_frame_math_stays_monotonic(num in 1i64..=i64::MAX, den in 1i64..=i64::MAX, a in any::<i64>(), b in any::<i64>()) {
|
||||
let r = FrameRate { num, den };
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
prop_assert!(r.tick_of(lo) <= r.tick_of(hi));
|
||||
prop_assert!(r.frame_at(Tick(lo)) <= r.frame_at(Tick(hi)));
|
||||
prop_assert!(r.snap(Tick(lo)) <= r.snap(Tick(hi)));
|
||||
prop_assert!(r.snap_nearest(Tick(lo)) <= r.snap_nearest(Tick(hi)));
|
||||
let snapped = r.snap_nearest(Tick(a));
|
||||
prop_assert_eq!(r.snap_nearest(snapped), snapped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_tick_roundtrip(f in -1_000_000i64..10_000_000, ri in 0usize..11) {
|
||||
let r = FrameRate::COMMON[ri];
|
||||
|
||||
@@ -310,8 +310,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
|
||||
let view = filmcraft_engine::clip_ops::source_view(&app.session, item).ok_or("Source clip is unavailable")?;
|
||||
let count = if id.ends_with('5') { i64::from(app.session.prefs.playback.step_many_frames) } else { 1 };
|
||||
let direction = if id.contains("Back") { -1 } else { 1 };
|
||||
let delta = view.rate.frame_duration().0.saturating_mul(count).saturating_mul(direction);
|
||||
let time = filmcraft_time::Tick(app.session.state.source_playhead.0.saturating_add(delta));
|
||||
let time = if view.rate.frame_duration().0 == 0 {
|
||||
app.session.state.source_playhead
|
||||
} else {
|
||||
let frame = view.rate.frame_at(app.session.state.source_playhead).saturating_add(count.saturating_mul(direction));
|
||||
view.rate.tick_of(frame)
|
||||
};
|
||||
return app.session.execute("source.setPlayhead", json!({"time": time.0})).map_err(|e| e.to_string());
|
||||
}
|
||||
"playback.forward" => {
|
||||
|
||||
@@ -674,8 +674,9 @@ fn source_nav(app: &mut FilmcraftApp, cmd: &str) {
|
||||
let t = match cmd {
|
||||
"src.goIn" => v.mark_in.unwrap_or(v.start),
|
||||
"src.goOut" => v.mark_out.unwrap_or(v.end - rate.frame_duration()),
|
||||
"src.stepBack" => cur - rate.frame_duration(),
|
||||
_ => cur + rate.frame_duration(),
|
||||
_ if rate.frame_duration() == Tick::ZERO => cur,
|
||||
"src.stepBack" => rate.tick_of(rate.frame_at(cur).saturating_sub(1)),
|
||||
_ => rate.tick_of(rate.frame_at(cur).saturating_add(1)),
|
||||
};
|
||||
let _ = app.session.execute("source.setPlayhead", json!({"time": t.0}));
|
||||
}
|
||||
@@ -179,6 +179,178 @@ impl Driver {
|
||||
}
|
||||
}
|
||||
|
||||
/// #301: Source transport must step by frame index when frame durations need rounding.
|
||||
#[test]
|
||||
fn source_transport_steps_every_frame_at_a_fractional_tick_rate() {
|
||||
let rate = FrameRate::new(31, 1);
|
||||
let generator = Generator::Demo(DemoScene::OceanSunset);
|
||||
let src = GeneratorSource::new(generator.clone(), 32, 18, rate, rate.tick_of(41));
|
||||
let mut session = Session::default();
|
||||
let item = Arc::make_mut(&mut session.project).add_item(
|
||||
"31 fps source",
|
||||
Label::Iris,
|
||||
ItemKind::Media(MediaClip {
|
||||
media: MediaRef::Generator(generator),
|
||||
info: src.info().clone(),
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
session.execute("source.open", json!({"item": item.0})).unwrap();
|
||||
let mut d = Driver::new(session);
|
||||
for frame in 1..=40 {
|
||||
d.click("source.transport.src.stepFwd");
|
||||
assert_eq!(rate.frame_at(d.app().session.state.source_playhead), frame);
|
||||
}
|
||||
d.click("source.transport.src.stepFwd");
|
||||
assert_eq!(rate.frame_at(d.app().session.state.source_playhead), 40, "stop at the last source frame");
|
||||
for frame in (0..40).rev() {
|
||||
d.click("source.transport.src.stepBack");
|
||||
assert_eq!(rate.frame_at(d.app().session.state.source_playhead), frame);
|
||||
}
|
||||
d.click("source.transport.src.stepBack");
|
||||
assert_eq!(d.app().session.state.source_playhead, Tick::ZERO);
|
||||
}
|
||||
|
||||
/// Source-focused menu/keyboard commands use the same frame grid as the Source toolbar.
|
||||
#[test]
|
||||
fn source_menu_steps_use_ordinals_for_explicit_targets_and_focus() {
|
||||
let rate = FrameRate::new(31, 1);
|
||||
for explicit_source in [true, false] {
|
||||
let generator = Generator::Demo(DemoScene::OceanSunset);
|
||||
let src = GeneratorSource::new(generator.clone(), 32, 18, rate, rate.tick_of(41));
|
||||
let mut session = Session::default();
|
||||
session.execute("file.newSequence", json!({"fps": 31, "width": 32, "height": 18})).unwrap();
|
||||
session.execute("playhead.set", json!({"frame": 10})).unwrap();
|
||||
session.execute("prefs.set", json!({"key": "playback.stepManyFrames", "value": 12})).unwrap();
|
||||
let item = Arc::make_mut(&mut session.project).add_item(
|
||||
"31 fps menu source",
|
||||
Label::Iris,
|
||||
ItemKind::Media(MediaClip {
|
||||
media: MediaRef::Generator(generator),
|
||||
info: src.info().clone(),
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
session.execute("source.open", json!({"item": item.0})).unwrap();
|
||||
let mut d = Driver::new(session);
|
||||
d.ok("ui.set", json!({"focused": if explicit_source { "Program" } else { "Source" }}));
|
||||
let project = d.app().session.project.clone();
|
||||
let history = d.exec("history.list", json!({}));
|
||||
let mut source_metadata = d.exec("source.inspect", json!({}));
|
||||
source_metadata.as_object_mut().unwrap().remove("playhead");
|
||||
let source_params = if explicit_source { json!({"monitor": "source"}) } else { json!({}) };
|
||||
for (command, frame) in [
|
||||
("playhead.stepForward", 1),
|
||||
("playhead.stepForward", 2),
|
||||
("playhead.stepBack", 1),
|
||||
("playhead.stepBack", 0),
|
||||
("playhead.stepBack", 0),
|
||||
("playhead.stepForward5", 12),
|
||||
("playhead.stepForward5", 24),
|
||||
("playhead.stepForward5", 36),
|
||||
("playhead.stepForward5", 40),
|
||||
("playhead.stepForward5", 40),
|
||||
("playhead.stepBack5", 28),
|
||||
("playhead.stepBack5", 16),
|
||||
("playhead.stepBack5", 4),
|
||||
("playhead.stepBack5", 0),
|
||||
("playhead.stepBack5", 0),
|
||||
] {
|
||||
d.ok("ui.menu.invoke", json!({"id": command, "params": source_params.clone()}));
|
||||
let mut source = d.exec("source.inspect", json!({}));
|
||||
assert_eq!(source["playhead"], json!(rate.tick_of(frame).0), "{command}, explicit Source target: {explicit_source}");
|
||||
source.as_object_mut().unwrap().remove("playhead");
|
||||
assert_eq!(source, source_metadata);
|
||||
assert_eq!(d.app().session.playhead(), rate.tick_of(10));
|
||||
assert!(Arc::ptr_eq(&d.app().session.project, &project), "Source navigation must preserve the document");
|
||||
assert_eq!(d.exec("history.list", json!({})), history);
|
||||
let state = d.ok("ui.inspect", json!({}));
|
||||
assert_eq!(state["playback"]["playing"], json!(false));
|
||||
assert_eq!(state["sourcePlayback"]["playing"], json!(false));
|
||||
}
|
||||
// An explicit Program target takes priority even while Source has focus.
|
||||
for (command, frame) in [("playhead.stepForward", 11), ("playhead.stepBack", 10)] {
|
||||
d.ok("ui.menu.invoke", json!({"id": command, "params": {"monitor": "program"}}));
|
||||
assert_eq!(d.app().session.playhead(), rate.tick_of(frame));
|
||||
assert_eq!(d.app().session.state.source_playhead, Tick::ZERO);
|
||||
assert!(Arc::ptr_eq(&d.app().session.project, &project));
|
||||
assert_eq!(d.exec("history.list", json!({})), history);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Requires the wide Time arithmetic: the accepted Source rate has more than one frame per tick.
|
||||
fn zero_tick_frame_duration_source() -> (Driver, Tick) {
|
||||
let rate = FrameRate::new(i64::MAX, 1000);
|
||||
let current = Tick(254_270_016_000_000); // 1001 seconds, beyond the i64 frame-index range.
|
||||
assert_eq!(rate.frame_duration(), Tick::ZERO);
|
||||
assert_eq!(rate.frame_at(current), i64::MAX);
|
||||
assert_eq!(rate.tick_of(i64::MAX), Tick(254_016_000_000_000));
|
||||
assert_eq!(rate.snap(current), current, "the composed Time helper must retain the exact cursor");
|
||||
let generator = Generator::ColorMatte { color: [0.2, 0.8, 0.2, 1.0] };
|
||||
let src = GeneratorSource::new(generator.clone(), 32, 18, rate, Tick(508_032_000_000_000)); // 2000 seconds.
|
||||
let mut session = Session::default();
|
||||
session.execute("file.newSequence", json!({"fps": 31, "width": 32, "height": 18})).unwrap();
|
||||
let item = Arc::make_mut(&mut session.project).add_item(
|
||||
"Source with less than one tick per frame",
|
||||
Label::Iris,
|
||||
ItemKind::Media(MediaClip {
|
||||
media: MediaRef::Generator(generator),
|
||||
info: src.info().clone(),
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
assert_eq!(filmcraft_engine::clip_ops::source_view(&session, item).unwrap().rate, rate);
|
||||
session.execute("source.open", json!({"item": item.0})).unwrap();
|
||||
let mut d = Driver::new(session);
|
||||
d.exec("source.setPlayhead", json!({"time": current.0}));
|
||||
assert_eq!(d.app().session.state.source_playhead, current, "the public Source seek must accept the exact starting cursor");
|
||||
(d, current)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_toolbar_steps_preserve_zero_tick_frame_duration_cursor() {
|
||||
let (mut d, current) = zero_tick_frame_duration_source();
|
||||
for button in ["source.transport.src.stepFwd", "source.transport.src.stepBack"] {
|
||||
d.click(button);
|
||||
assert_eq!(d.app().session.state.source_playhead, current, "{button} must retain the existing zero-duration no-op");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_menu_steps_preserve_zero_tick_frame_duration_cursor() {
|
||||
for explicit_source in [true, false] {
|
||||
let (mut d, current) = zero_tick_frame_duration_source();
|
||||
d.ok("ui.set", json!({"focused": if explicit_source { "Program" } else { "Source" }}));
|
||||
let params = if explicit_source { json!({"monitor": "source"}) } else { json!({}) };
|
||||
for command in ["playhead.stepForward", "playhead.stepBack"] {
|
||||
d.ok("ui.menu.invoke", json!({"id": command, "params": params.clone()}));
|
||||
assert_eq!(d.app().session.state.source_playhead, current, "{command}, explicit Source target: {explicit_source}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_media_dialog_relinks_moved_media() {
|
||||
let root = tmp_dir("link");
|
||||
|
||||
Reference in new issue
Block a user