TIME.FRAME_NAVIGATION: Preserve exact frame steps and bound time arithmetic

Co-authored-by: Codex <[email protected]>
This commit is contained in:
sanyamsingh001andCodex committed 2026-10-10 09:00:34 +10:00
1 parent 9c43a0d02f
commit a3ad7a8ce7
8 files changed
+437 -47

No files matched your search

+5 -2
View File
@@ -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}))),
+44
View File
@@ -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();
+1 -1
View File
@@ -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;
+8 -2
View File
@@ -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
View File
@@ -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];
+6 -2
View File
@@ -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" => {
+3 -2
View File
@@ -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}));
}
+172
View File
@@ -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");