mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
Avoid duplicated timeline time primitives
This commit is contained in:
1 parent
10a2d6dd14
commit
5e24ea380e
5 files changed
+164
-635
No files matched your search
Generated
+1
@@ -2264,6 +2264,7 @@ dependencies = [
|
||||
name = "craft-timeline"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-time",
|
||||
"craft-xml",
|
||||
"proptest",
|
||||
"serde",
|
||||
|
||||
+142
-19
@@ -35,8 +35,7 @@ impl Tick {
|
||||
|
||||
/// Seconds to ticks, clamped to [`Tick::MIN`]..=[`Tick::MAX`].
|
||||
pub fn from_seconds_f64(s: f64) -> Tick {
|
||||
let t = (s * TICKS_PER_SECOND as f64).round();
|
||||
Tick((t as i64).clamp(Tick::MIN.0, Tick::MAX.0))
|
||||
Tick((s * TICKS_PER_SECOND as f64).round() as i64)
|
||||
}
|
||||
|
||||
pub fn seconds(self) -> f64 {
|
||||
@@ -246,7 +245,7 @@ impl TimeRange {
|
||||
}
|
||||
|
||||
pub fn overlaps(&self, o: &TimeRange) -> bool {
|
||||
self.start < o.end() && o.start < self.end()
|
||||
!self.is_empty() && !o.is_empty() && self.start < o.end() && o.start < self.end()
|
||||
}
|
||||
|
||||
pub fn intersect(&self, o: &TimeRange) -> Option<TimeRange> {
|
||||
@@ -316,6 +315,19 @@ impl FrameRate {
|
||||
FrameRate::new((fps * 1000.0).round() as i64, 1000)
|
||||
}
|
||||
|
||||
/// Closest standard rate when `fps` is within `tolerance` of it, otherwise the nearest
|
||||
/// 1/1000 rate. This retains the timeline API's coarse-timebase compatibility behavior.
|
||||
pub fn from_f64_within(fps: f64, tolerance: f64) -> Self {
|
||||
let mut best: Option<(f64, FrameRate)> = None;
|
||||
for rate in Self::COMMON {
|
||||
let off = (rate.as_f64() - fps).abs();
|
||||
if off <= tolerance * rate.as_f64() && best.is_none_or(|(closest, _)| off < closest) {
|
||||
best = Some((off, rate));
|
||||
}
|
||||
}
|
||||
best.map_or_else(|| Self::from_f64(fps), |(_, rate)| rate)
|
||||
}
|
||||
|
||||
/// Returns this frame rate if positive, or the default rate ([`FrameRate::FPS_23_976`]).
|
||||
///
|
||||
/// ### Application Compatibility Note
|
||||
@@ -341,6 +353,11 @@ impl FrameRate {
|
||||
clamp_i64(frame_index(self.sane(), t))
|
||||
}
|
||||
|
||||
/// Frames from `start` to `end`, counted between their frame indices.
|
||||
pub fn frames_between(self, start: Tick, end: Tick) -> i64 {
|
||||
self.frame_at(end).saturating_sub(self.frame_at(start))
|
||||
}
|
||||
|
||||
/// Start tick of frame `f` (ceiling division).
|
||||
pub fn tick_of(self, f: i64) -> Tick {
|
||||
Tick(clamp_i64(frame_boundary(self.sane(), i128::from(f))))
|
||||
@@ -432,6 +449,9 @@ impl TimeDisplay {
|
||||
pub const ALL: [TimeDisplay; 6] =
|
||||
[TimeDisplay::Timecode, TimeDisplay::Feet35, TimeDisplay::Feet16, TimeDisplay::Frames, TimeDisplay::AudioSamples, TimeDisplay::Seconds];
|
||||
|
||||
/// The video display formats (Sequence Settings ▸ Display Format, the burn-in's Time Format).
|
||||
pub const VIDEO: [TimeDisplay; 4] = [TimeDisplay::Timecode, TimeDisplay::Feet35, TimeDisplay::Feet16, TimeDisplay::Frames];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TimeDisplay::Timecode => "Timecode",
|
||||
@@ -442,6 +462,32 @@ impl TimeDisplay {
|
||||
TimeDisplay::Seconds => "Seconds",
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable id used by commands and the UI.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
TimeDisplay::Timecode => "timecode",
|
||||
TimeDisplay::Frames => "frames",
|
||||
TimeDisplay::Feet16 => "feet16",
|
||||
TimeDisplay::Feet35 => "feet35",
|
||||
TimeDisplay::AudioSamples => "samples",
|
||||
TimeDisplay::Seconds => "seconds",
|
||||
}
|
||||
}
|
||||
|
||||
/// The display with this [`id`](Self::id), ignoring case.
|
||||
pub fn from_id(id: &str) -> Option<TimeDisplay> {
|
||||
TimeDisplay::ALL.into_iter().find(|display| display.id().eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
/// Frames per foot for the Feet + Frames displays.
|
||||
pub fn frames_per_foot(self) -> Option<i64> {
|
||||
match self {
|
||||
TimeDisplay::Feet35 => Some(16),
|
||||
TimeDisplay::Feet16 => Some(40),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn df_params(rate: FrameRate) -> (i64, i64) {
|
||||
@@ -509,35 +555,33 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp
|
||||
match display {
|
||||
TimeDisplay::Timecode => format_timecode_frames(frame, rate, drop_frame),
|
||||
TimeDisplay::Frames => format!("{frame}"),
|
||||
TimeDisplay::Feet35 | TimeDisplay::Feet16 => {
|
||||
let per_ft = if display == TimeDisplay::Feet35 { 16 } else { 40 };
|
||||
let neg = frame < 0;
|
||||
let a = frame.unsigned_abs();
|
||||
format!("{}{}+{:02}", if neg { "-" } else { "" }, a / per_ft, a % per_ft)
|
||||
}
|
||||
TimeDisplay::Feet35 | TimeDisplay::Feet16 => format_feet_frames(frame, display.frames_per_foot().unwrap_or(16)),
|
||||
TimeDisplay::AudioSamples => {
|
||||
let neg = t.0 < 0;
|
||||
let abs_ticks = t.0.unsigned_abs();
|
||||
let total_secs = abs_ticks / (TICKS_PER_SECOND as u64);
|
||||
let rem_ticks = abs_ticks % (TICKS_PER_SECOND as u64);
|
||||
let rem_samples = (rem_ticks as u128 * sample_rate as u128) / (TICKS_PER_SECOND as u128);
|
||||
let rem_samples = Tick(rem_ticks as i64).to_units_floor(sample_rate);
|
||||
let h = total_secs / 3600;
|
||||
let m = (total_secs / 60) % 60;
|
||||
let s = total_secs % 60;
|
||||
format!("{}{:02}:{:02}:{:02}:{:05}", if neg { "-" } else { "" }, h, m, s, rem_samples)
|
||||
}
|
||||
TimeDisplay::Seconds => format!("{:.3}", t.seconds()),
|
||||
TimeDisplay::Seconds => {
|
||||
let seconds = format!("{:.3}", t.seconds());
|
||||
if seconds == "-0.000" { "0.000".into() } else { seconds }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a frame count as Feet + Frames (film footage counting).
|
||||
pub fn format_feet_frames(frame: i64, per_foot: i64) -> String {
|
||||
let pf = per_foot.max(1);
|
||||
let per_foot = per_foot.max(1);
|
||||
let sign = if frame < 0 { "-" } else { "" };
|
||||
let a = frame.unsigned_abs();
|
||||
let pf = pf as u64;
|
||||
let w = if pf > 100 { 3 } else { 2 };
|
||||
format!("{sign}{:04}+{:0w$}", a / pf, a % pf)
|
||||
let frames = frame.unsigned_abs();
|
||||
let per_foot = per_foot as u64;
|
||||
let width = if per_foot > 100 { 3 } else { 2 };
|
||||
format!("{sign}{:04}+{:0width$}", frames / per_foot, frames % per_foot)
|
||||
}
|
||||
|
||||
/// Error from timecode parsing.
|
||||
@@ -561,6 +605,22 @@ impl std::error::Error for ParseError {}
|
||||
/// saturates to `i64::MAX` (or `i64::MIN`) to keep calculations total without errors.
|
||||
/// In contrast, EffectCraft returns `Err(ParseError("timecode too large"))`.
|
||||
pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i64) -> Result<i64, ParseError> {
|
||||
parse_timecode_with_negative_policy(input, rate, drop_frame, current, true)
|
||||
}
|
||||
|
||||
/// Parse timecode with the relative-negative policy used by the historical timeline API.
|
||||
/// A leading minus offsets from `current`, including a complete four-field timecode.
|
||||
pub fn parse_timecode_relative(input: &str, rate: FrameRate, drop_frame: bool, current: i64) -> Result<i64, ParseError> {
|
||||
parse_timecode_with_negative_policy(input, rate, drop_frame, current, false)
|
||||
}
|
||||
|
||||
fn parse_timecode_with_negative_policy(
|
||||
input: &str,
|
||||
rate: FrameRate,
|
||||
drop_frame: bool,
|
||||
current: i64,
|
||||
full_negative_is_absolute: bool,
|
||||
) -> Result<i64, ParseError> {
|
||||
let s = input.trim();
|
||||
if s.is_empty() {
|
||||
return Err(ParseError("empty timecode".into()));
|
||||
@@ -615,7 +675,10 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i
|
||||
let base = rate.timecode_base();
|
||||
let frames = if nums.len() == 1 && sign_char.is_some() {
|
||||
fr
|
||||
} else if drop_frame && rate.supports_drop_frame() && m < 60 && sec < 60 && fr < base {
|
||||
} else if drop_frame && rate.supports_drop_frame() {
|
||||
let (sec, fr) = (sec.saturating_add(fr / base), fr % base);
|
||||
let (m, sec) = (m.saturating_add(sec / 60), sec % 60);
|
||||
let (h, m) = (h.saturating_add(m / 60), m % 60);
|
||||
fields_to_frames(h, m, sec, fr, rate, true)
|
||||
} else {
|
||||
h.saturating_mul(3600).saturating_add(m.saturating_mul(60)).saturating_add(sec).saturating_mul(base).saturating_add(fr)
|
||||
@@ -623,10 +686,11 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i
|
||||
|
||||
match sign_char {
|
||||
Some('+') => Ok(current.saturating_add(frames)),
|
||||
Some('-') if full_negative_is_absolute && nums.len() == 4 => Ok(-frames),
|
||||
Some('-') => {
|
||||
// A full 4-field timecode with leading '-' represents an absolute negative timecode (e.g. preroll).
|
||||
// Single-number or partial relative offsets (e.g. -15 or -1.00) offset from current.
|
||||
if nums.len() == 4 { Ok(-frames) } else { Ok(current.saturating_sub(frames)) }
|
||||
// The timeline compatibility path treats every leading minus as an offset instead.
|
||||
Ok(current.saturating_sub(frames))
|
||||
}
|
||||
None | Some(_) => Ok(frames),
|
||||
}
|
||||
@@ -671,6 +735,65 @@ pub fn parse_feet_frames(input: &str, per_foot: i64, current: i64) -> Result<i64
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse text using a [`TimeDisplay`] representation.
|
||||
pub fn parse_time_display(input: &str, rate: FrameRate, drop_frame: bool, display: TimeDisplay, current: i64) -> Result<i64, ParseError> {
|
||||
parse_time_display_with_negative_policy(input, rate, drop_frame, display, current, true)
|
||||
}
|
||||
|
||||
/// Parse text using a [`TimeDisplay`] representation and the historical timeline relative-minus rule.
|
||||
pub fn parse_time_display_relative(input: &str, rate: FrameRate, drop_frame: bool, display: TimeDisplay, current: i64) -> Result<i64, ParseError> {
|
||||
parse_time_display_with_negative_policy(input, rate, drop_frame, display, current, false)
|
||||
}
|
||||
|
||||
fn parse_time_display_with_negative_policy(
|
||||
input: &str,
|
||||
rate: FrameRate,
|
||||
drop_frame: bool,
|
||||
display: TimeDisplay,
|
||||
current: i64,
|
||||
full_negative_is_absolute: bool,
|
||||
) -> Result<i64, ParseError> {
|
||||
let per_foot = match display {
|
||||
TimeDisplay::Frames => 1,
|
||||
TimeDisplay::Feet35 | TimeDisplay::Feet16 => display.frames_per_foot().unwrap_or(16),
|
||||
_ => {
|
||||
return if full_negative_is_absolute {
|
||||
parse_timecode(input, rate, drop_frame, current)
|
||||
} else {
|
||||
parse_timecode_relative(input, rate, drop_frame, current)
|
||||
};
|
||||
}
|
||||
};
|
||||
let text = input.trim();
|
||||
if text.is_empty() {
|
||||
return Err(ParseError("empty time".into()));
|
||||
}
|
||||
let (relative, body) = match text.as_bytes()[0] {
|
||||
b'+' => (Some(1), &text[1..]),
|
||||
b'-' => (Some(-1), &text[1..]),
|
||||
_ => (None, text),
|
||||
};
|
||||
let field = |part: &str| -> Result<i64, ParseError> {
|
||||
if part.is_empty() {
|
||||
Ok(0)
|
||||
} else if !part.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
Err(ParseError(format!("not a time: `{input}`")))
|
||||
} else {
|
||||
part.parse::<i64>().map_err(|_| ParseError("number too large".into()))
|
||||
}
|
||||
};
|
||||
let parts: Vec<&str> = body.split(['+', ':', ';', '.', ',']).collect();
|
||||
let frames = match parts.as_slice() {
|
||||
[number] => field(number)?,
|
||||
[feet, frames] if per_foot > 1 => field(feet)?.saturating_mul(per_foot).saturating_add(field(frames)?),
|
||||
_ => return Err(ParseError(format!("not a time: `{input}`"))),
|
||||
};
|
||||
Ok(match relative {
|
||||
Some(sign) => current.saturating_add(frames.saturating_mul(sign)),
|
||||
None => frames,
|
||||
})
|
||||
}
|
||||
|
||||
/// Common audio sample rates offered in sequence settings.
|
||||
pub const SAMPLE_RATES: [i64; 6] = [32_000, 44_100, 48_000, 88_200, 96_000, 192_000];
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ otio = ["dep:serde_json"]
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, optional = true }
|
||||
craft-time.workspace = true
|
||||
craft-xml = { workspace = true, optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
|
||||
|
||||
+16
-616
@@ -1,627 +1,27 @@
|
||||
//! Exact media time.
|
||||
//! Compatibility re-exports for timeline time primitives.
|
||||
//!
|
||||
//! All time is an integer number of [`Tick`]s at [`TICKS_PER_SECOND`] = 254 016 000 000/s. That
|
||||
//! rate divides evenly into every broadcast frame duration (23.976, 24, 25, 29.97, 30, 48, 50,
|
||||
//! 59.94, 60, 120 …) and every common audio sample duration (8 k … 192 kHz, including the 44.1 k
|
||||
//! family), so edits, frame math and audio alignment never drift.
|
||||
//!
|
||||
//! Timecode (SMPTE drop/non-drop, frames, feet+frames, samples) is only a *display* of ticks.
|
||||
//!
|
||||
//! Every conversion is total: a zero or negative rate (from a damaged file or project) gives
|
||||
//! zero instead of a division-by-zero panic, and an invalid [`FrameRate`] counts as the default.
|
||||
//! The implementation lives in `craft-time`; this module keeps the established
|
||||
//! `craft_timeline::time::*` paths available to downstream users.
|
||||
|
||||
use std::fmt;
|
||||
use std::ops::{Add, AddAssign, Neg, Sub, SubAssign};
|
||||
pub use craft_time::{
|
||||
FrameRate, ParseError, SAMPLE_RATES, TICKS_PER_SECOND, Tick, TimeDisplay, TimeRange, fields_to_frames, format_timecode_frames, frames_to_fields,
|
||||
parse_time_display_relative as parse_time_display, parse_timecode_relative as parse_timecode,
|
||||
};
|
||||
|
||||
/// Ticks per second.
|
||||
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)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
#[cfg_attr(feature = "serde", serde(transparent))]
|
||||
pub struct Tick(pub i64);
|
||||
|
||||
impl Tick {
|
||||
pub const ZERO: Tick = Tick(0);
|
||||
pub const MAX: Tick = Tick(i64::MAX / 4);
|
||||
pub const MIN: Tick = Tick(i64::MIN / 4);
|
||||
|
||||
pub fn from_seconds_f64(s: f64) -> Tick {
|
||||
Tick((s * TICKS_PER_SECOND as f64).round() as i64)
|
||||
}
|
||||
pub fn seconds(self) -> f64 {
|
||||
self.0 as f64 / TICKS_PER_SECOND as f64
|
||||
}
|
||||
/// 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(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 {
|
||||
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 {
|
||||
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 {
|
||||
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).
|
||||
///
|
||||
/// For finding a sample by its timestamp: containers store timestamps rounded to their
|
||||
/// timebase (Matroska usually to 1 ms), so a stamp can sit up to half a unit after the true
|
||||
/// time. Flooring the requested time misses every frame whose stamp was rounded up.
|
||||
pub fn to_rational_round(self, num: i64, den: i64) -> i64 {
|
||||
let n = self.0 as i128 * den as i128;
|
||||
let unit = num as i128 * TICKS_PER_SECOND as i128;
|
||||
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
|
||||
clamp_i64(if r * 2 >= unit { q + 1 } else { q })
|
||||
}
|
||||
pub fn abs(self) -> Tick {
|
||||
Tick(self.0.saturating_abs())
|
||||
}
|
||||
pub fn min(self, o: Tick) -> Tick {
|
||||
Tick(self.0.min(o.0))
|
||||
}
|
||||
pub fn max(self, o: Tick) -> Tick {
|
||||
Tick(self.0.max(o.0))
|
||||
}
|
||||
pub fn clamp(self, lo: Tick, hi: Tick) -> Tick {
|
||||
// max/min rather than `clamp`, which panics when the bounds cross.
|
||||
Tick(self.0.max(lo.0).min(hi.0))
|
||||
}
|
||||
/// Scale by a rational factor (`num/den`), flooring.
|
||||
pub fn mul_ratio(self, num: i64, den: i64) -> Tick {
|
||||
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.saturating_add(o.0))
|
||||
}
|
||||
}
|
||||
impl Sub for Tick {
|
||||
type Output = Tick;
|
||||
fn sub(self, o: Tick) -> Tick {
|
||||
Tick(self.0.saturating_sub(o.0))
|
||||
}
|
||||
}
|
||||
impl Neg for Tick {
|
||||
type Output = Tick;
|
||||
fn neg(self) -> Tick {
|
||||
Tick(self.0.saturating_neg())
|
||||
}
|
||||
}
|
||||
impl AddAssign for Tick {
|
||||
fn add_assign(&mut self, o: Tick) {
|
||||
self.0 = self.0.saturating_add(o.0);
|
||||
}
|
||||
}
|
||||
impl SubAssign for Tick {
|
||||
fn sub_assign(&mut self, o: Tick) {
|
||||
self.0 = self.0.saturating_sub(o.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A half-open time range `[start, start + duration)`.
|
||||
/// Zero or negative durations are empty and never overlap another range.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct TimeRange {
|
||||
pub start: Tick,
|
||||
pub duration: Tick,
|
||||
}
|
||||
|
||||
impl TimeRange {
|
||||
pub fn new(start: Tick, duration: Tick) -> Self {
|
||||
Self { start, duration }
|
||||
}
|
||||
pub fn from_bounds(start: Tick, end: Tick) -> Self {
|
||||
Self { start, duration: end - start }
|
||||
}
|
||||
pub fn end(&self) -> Tick {
|
||||
self.start + self.duration
|
||||
}
|
||||
pub fn contains(&self, t: Tick) -> bool {
|
||||
t >= self.start && t < self.end()
|
||||
}
|
||||
pub fn overlaps(&self, o: &TimeRange) -> bool {
|
||||
!self.is_empty() && !o.is_empty() && self.start < o.end() && o.start < self.end()
|
||||
}
|
||||
pub fn intersect(&self, o: &TimeRange) -> Option<TimeRange> {
|
||||
let s = self.start.max(o.start);
|
||||
let e = self.end().min(o.end());
|
||||
(e > s).then(|| TimeRange::from_bounds(s, e))
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.duration.0 <= 0
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame rate as an exact rational (`num / den` frames per second).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct FrameRate {
|
||||
pub num: i64,
|
||||
pub den: i64,
|
||||
}
|
||||
|
||||
impl Default for FrameRate {
|
||||
fn default() -> Self {
|
||||
FrameRate::FPS_23_976
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameRate {
|
||||
pub const FPS_23_976: FrameRate = FrameRate { num: 24000, den: 1001 };
|
||||
pub const FPS_24: FrameRate = FrameRate { num: 24, den: 1 };
|
||||
pub const FPS_25: FrameRate = FrameRate { num: 25, den: 1 };
|
||||
pub const FPS_29_97: FrameRate = FrameRate { num: 30000, den: 1001 };
|
||||
pub const FPS_30: FrameRate = FrameRate { num: 30, den: 1 };
|
||||
pub const FPS_48: FrameRate = FrameRate { num: 48, den: 1 };
|
||||
pub const FPS_50: FrameRate = FrameRate { num: 50, den: 1 };
|
||||
pub const FPS_59_94: FrameRate = FrameRate { num: 60000, den: 1001 };
|
||||
pub const FPS_60: FrameRate = FrameRate { num: 60, den: 1 };
|
||||
pub const FPS_119_88: FrameRate = FrameRate { num: 120000, den: 1001 };
|
||||
pub const FPS_120: FrameRate = FrameRate { num: 120, den: 1 };
|
||||
|
||||
/// Rates offered in sequence settings (Premiere's list).
|
||||
pub const COMMON: [FrameRate; 11] = [
|
||||
Self::FPS_23_976,
|
||||
Self::FPS_24,
|
||||
Self::FPS_25,
|
||||
Self::FPS_29_97,
|
||||
Self::FPS_30,
|
||||
Self::FPS_48,
|
||||
Self::FPS_50,
|
||||
Self::FPS_59_94,
|
||||
Self::FPS_60,
|
||||
Self::FPS_119_88,
|
||||
Self::FPS_120,
|
||||
];
|
||||
|
||||
pub fn new(num: i64, den: i64) -> Self {
|
||||
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).
|
||||
pub fn from_f64(fps: f64) -> Self {
|
||||
for r in Self::COMMON {
|
||||
if (r.as_f64() - fps).abs() < 0.005 {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
FrameRate::new((fps * 1000.0).round() as i64, 1000)
|
||||
}
|
||||
|
||||
/// Closest standard rate when `fps` is within `tolerance` of it, `tolerance` a fraction of the
|
||||
/// rate (`0.005` is half a percent), else the nearest 1/1000 rate. A rate a container reports
|
||||
/// from timestamps rounded to a coarse unit lands slightly off a standard rate: at 90 kHz,
|
||||
/// whole milliseconds leave a 30 fps clip's average at 30.02 or 29.95 (#1700).
|
||||
pub fn from_f64_within(fps: f64, tolerance: f64) -> Self {
|
||||
let mut best: Option<(f64, FrameRate)> = None;
|
||||
for r in Self::COMMON {
|
||||
let off = (r.as_f64() - fps).abs();
|
||||
if off <= tolerance * r.as_f64() && best.is_none_or(|(closest, _)| off < closest) {
|
||||
best = Some((off, r));
|
||||
}
|
||||
}
|
||||
best.map_or_else(|| Self::from_f64(fps), |(_, r)| r)
|
||||
}
|
||||
|
||||
/// This rate if it is positive, else the default (a damaged file or project can carry a zero
|
||||
/// or negative rate; every frame computation uses this so it can't divide by zero).
|
||||
pub fn sane(self) -> FrameRate {
|
||||
if self.num > 0 && self.den > 0 { self } else { FrameRate::default() }
|
||||
}
|
||||
|
||||
pub fn as_f64(self) -> f64 {
|
||||
self.num as f64 / self.den as f64
|
||||
}
|
||||
|
||||
/// 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(clamp_i64((TICKS_PER_SECOND as i128 * r.den as i128) / r.num as i128))
|
||||
}
|
||||
|
||||
/// Index of the frame containing `t` (floor), saturated to the integer frame range.
|
||||
pub fn frame_at(self, t: Tick) -> i64 {
|
||||
clamp_i64(frame_index(self.sane(), t))
|
||||
}
|
||||
|
||||
/// Frames from `start` to `end`, counted between their frame indices. `frame_at(end - start)`
|
||||
/// can read one short when a frame is not a whole number of ticks: the ticks between two
|
||||
/// boundaries can be one fewer than the exact length of those frames.
|
||||
pub fn frames_between(self, start: Tick, end: Tick) -> i64 {
|
||||
self.frame_at(end).saturating_sub(self.frame_at(start))
|
||||
}
|
||||
|
||||
/// Start tick of frame `f` (ceiling division).
|
||||
///
|
||||
/// Uses ceiling (not floor) so that `frame_at(tick_of(f)) == f` holds for every
|
||||
/// 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.
|
||||
/// Values outside the integer tick range saturate at its limit.
|
||||
pub fn tick_of(self, f: i64) -> Tick {
|
||||
Tick(clamp_i64(frame_boundary(self.sane(), i128::from(f))))
|
||||
}
|
||||
|
||||
/// Snap `t` down to a frame boundary.
|
||||
pub fn snap(self, t: Tick) -> Tick {
|
||||
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 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).saturating_add(i64::from(r.num % r.den != 0)).max(1)
|
||||
}
|
||||
|
||||
/// NTSC (x/1001) rates can use drop-frame timecode.
|
||||
pub fn is_ntsc(self) -> bool {
|
||||
self.den == 1001
|
||||
}
|
||||
|
||||
/// Whether drop-frame counting applies (29.97 / 59.94 / 119.88).
|
||||
pub fn supports_drop_frame(self) -> bool {
|
||||
self.is_ntsc() && self.timecode_base() % 30 == 0
|
||||
}
|
||||
|
||||
pub fn label(self) -> String {
|
||||
if self.den == 1 {
|
||||
format!("{}", self.num)
|
||||
} else {
|
||||
let v = self.as_f64();
|
||||
let s = format!("{v:.3}");
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FrameRate {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{} fps", self.label())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// How time is displayed (Premiere: Timecode, Feet+Frames 16mm/35mm, Frames, Audio Samples).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum TimeDisplay {
|
||||
#[default]
|
||||
Timecode,
|
||||
Frames,
|
||||
Feet16,
|
||||
Feet35,
|
||||
AudioSamples,
|
||||
Seconds,
|
||||
}
|
||||
|
||||
impl TimeDisplay {
|
||||
pub const ALL: [TimeDisplay; 6] =
|
||||
[TimeDisplay::Timecode, TimeDisplay::Feet35, TimeDisplay::Feet16, TimeDisplay::Frames, TimeDisplay::AudioSamples, TimeDisplay::Seconds];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TimeDisplay::Timecode => "Timecode",
|
||||
TimeDisplay::Frames => "Frames",
|
||||
TimeDisplay::Feet16 => "Feet + Frames 16mm",
|
||||
TimeDisplay::Feet35 => "Feet + Frames 35mm",
|
||||
TimeDisplay::AudioSamples => "Audio Samples",
|
||||
TimeDisplay::Seconds => "Seconds",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TimeDisplay {
|
||||
/// The video display formats (Sequence Settings ▸ Display Format, the burn-in's Time Format).
|
||||
pub const VIDEO: [TimeDisplay; 4] = [TimeDisplay::Timecode, TimeDisplay::Feet35, TimeDisplay::Feet16, TimeDisplay::Frames];
|
||||
|
||||
/// Stable id used by commands and the UI: `timecode`, `feet35`, `feet16`, `frames`, `samples`, `seconds`.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
TimeDisplay::Timecode => "timecode",
|
||||
TimeDisplay::Frames => "frames",
|
||||
TimeDisplay::Feet16 => "feet16",
|
||||
TimeDisplay::Feet35 => "feet35",
|
||||
TimeDisplay::AudioSamples => "samples",
|
||||
TimeDisplay::Seconds => "seconds",
|
||||
}
|
||||
}
|
||||
|
||||
/// The display with this [`id`](Self::id), ignoring case.
|
||||
pub fn from_id(id: &str) -> Option<TimeDisplay> {
|
||||
TimeDisplay::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
/// Frames per foot of film for the Feet + Frames displays: 16 for 35mm 4-perf, 40 for 16mm.
|
||||
pub fn frames_per_foot(self) -> Option<i64> {
|
||||
match self {
|
||||
TimeDisplay::Feet35 => Some(16),
|
||||
TimeDisplay::Feet16 => Some(40),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a frame count as film feet + frames (`F+FF`, e.g. `2+08`; negative: `-2+08`).
|
||||
/// `frames_per_foot` below 1 is treated as 1 so a bad value never divides by zero.
|
||||
/// Format a timeline frame count as film feet + frames, retaining its established compact display.
|
||||
pub fn format_feet_frames(frame: i64, frames_per_foot: i64) -> String {
|
||||
let per_ft = frames_per_foot.max(1).unsigned_abs();
|
||||
let a = frame.unsigned_abs();
|
||||
format!("{}{}+{:02}", if frame < 0 { "-" } else { "" }, a / per_ft, a % per_ft)
|
||||
let per_foot = frames_per_foot.max(1).unsigned_abs();
|
||||
let frames = frame.unsigned_abs();
|
||||
format!("{}{}+{:02}", if frame < 0 { "-" } else { "" }, frames / per_foot, frames % per_foot)
|
||||
}
|
||||
|
||||
/// Drop-frame parameters: frames dropped per minute and nominal base.
|
||||
fn df_params(rate: FrameRate) -> (i64, i64) {
|
||||
let base = rate.timecode_base();
|
||||
(base / 15, base) // 30 → 2, 60 → 4, 120 → 8
|
||||
}
|
||||
|
||||
/// 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 = 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;
|
||||
let d = n / per_10;
|
||||
let m = n % per_10;
|
||||
n = n.saturating_add(drop * 9 * d);
|
||||
if m > drop {
|
||||
n = n.saturating_add(drop * ((m - drop) / per_min));
|
||||
}
|
||||
/// Format time using the timeline's historical compact Feet + Frames display.
|
||||
pub fn format_time(tick: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisplay, sample_rate: i64) -> String {
|
||||
if let TimeDisplay::Feet35 | TimeDisplay::Feet16 = display {
|
||||
return format_feet_frames(rate.frame_at(tick), display.frames_per_foot().unwrap_or(16));
|
||||
}
|
||||
let f = n % base;
|
||||
let s = (n / base) % 60;
|
||||
let mi = (n / (base * 60)) % 60;
|
||||
let h = n / (base * 3600);
|
||||
(neg, clamp_i64(h), clamp_i64(mi), clamp_i64(s), clamp_i64(f))
|
||||
craft_time::format_time(tick, rate, drop_frame, display, sample_rate)
|
||||
}
|
||||
|
||||
/// 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 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 = i128::from(h) * 60 + i128::from(m);
|
||||
n = n.saturating_sub(i128::from(drop) * (total_min - total_min / 10));
|
||||
}
|
||||
clamp_i64(n)
|
||||
}
|
||||
|
||||
/// Format a frame count as SMPTE timecode (`HH:MM:SS:FF`, or `HH;MM;SS;FF` for drop-frame).
|
||||
pub fn format_timecode_frames(frame: i64, rate: FrameRate, drop_frame: bool) -> String {
|
||||
let df = drop_frame && rate.supports_drop_frame();
|
||||
let (neg, h, m, s, f) = frames_to_fields(frame, rate, df);
|
||||
let sep = if df { ';' } else { ':' };
|
||||
let fw = if rate.timecode_base() >= 100 { 3 } else { 2 };
|
||||
format!("{}{h:02}{sep}{m:02}{sep}{s:02}{sep}{f:0fw$}", if neg { "-" } else { "" })
|
||||
}
|
||||
|
||||
/// Format a tick according to a display mode.
|
||||
pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisplay, sample_rate: i64) -> String {
|
||||
let frame = rate.frame_at(t);
|
||||
match display {
|
||||
TimeDisplay::Timecode => format_timecode_frames(frame, rate, drop_frame),
|
||||
TimeDisplay::Frames => format!("{frame}"),
|
||||
TimeDisplay::Feet35 | TimeDisplay::Feet16 => format_feet_frames(frame, display.frames_per_foot().unwrap_or(16)),
|
||||
TimeDisplay::AudioSamples => {
|
||||
let neg = t.0 < 0;
|
||||
let a = t.0.unsigned_abs();
|
||||
let tps = TICKS_PER_SECOND.unsigned_abs();
|
||||
let secs = a / tps;
|
||||
let rem = Tick((a % tps) as i64).to_units_floor(sample_rate);
|
||||
format!("{}{:02}:{:02}:{:02}:{rem:05}", if neg { "-" } else { "" }, secs / 3600, (secs / 60) % 60, secs % 60)
|
||||
}
|
||||
TimeDisplay::Seconds => {
|
||||
let s = format!("{:.3}", t.seconds());
|
||||
// A tiny negative time rounds to zero but keeps its sign: drop it.
|
||||
if s == "-0.000" { "0.000".to_string() } else { s }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error from [`parse_timecode`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParseError(pub String);
|
||||
|
||||
impl fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ParseError {}
|
||||
|
||||
/// Parse user-typed timecode into a frame count, the way Premiere's timecode fields do:
|
||||
/// - `01:02:03:04`, `01;02;03;04`, `1.2.3.4` (any of `:;.,` separators),
|
||||
/// - bare digits are read right-aligned as `HHMMSSFF` (`1000` = 00:00:10:00),
|
||||
/// - a leading `+`/`-` makes the value relative to `current` (`+15` = 15 frames later, `-1.00` = 1 s earlier).
|
||||
pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i64) -> Result<i64, ParseError> {
|
||||
let s = input.trim();
|
||||
if s.is_empty() {
|
||||
return Err(ParseError("empty timecode".into()));
|
||||
}
|
||||
let (rel, body) = match s.as_bytes()[0] {
|
||||
b'+' => (Some(1), &s[1..]),
|
||||
b'-' => (Some(-1), &s[1..]),
|
||||
_ => (None, s),
|
||||
};
|
||||
let parts: Vec<&str> = body.split([':', ';', '.', ',']).collect();
|
||||
let nums: Vec<i64> = if parts.len() == 1 {
|
||||
let digits = parts[0];
|
||||
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(ParseError(format!("not a timecode: `{input}`")));
|
||||
}
|
||||
// Right-aligned pairs: "12345" -> [1, 23, 45]; relative bare numbers are frames.
|
||||
if rel.is_some() {
|
||||
vec![digits.parse().map_err(|_| ParseError("number too large".into()))?]
|
||||
} else {
|
||||
let mut v = Vec::new();
|
||||
let b = digits.as_bytes();
|
||||
let mut end = b.len();
|
||||
while end > 0 {
|
||||
let start = end.saturating_sub(2);
|
||||
v.push(digits[start..end].parse::<i64>().unwrap_or(0));
|
||||
end = start;
|
||||
}
|
||||
v.reverse();
|
||||
v
|
||||
}
|
||||
} else {
|
||||
parts
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if p.is_empty() {
|
||||
Ok(0)
|
||||
} else if !p.bytes().all(|b| b.is_ascii_digit()) {
|
||||
Err(ParseError(format!("bad field `{p}`")))
|
||||
} else {
|
||||
p.parse::<i64>().map_err(|_| ParseError(format!("bad field `{p}`")))
|
||||
}
|
||||
})
|
||||
.collect::<Result<_, _>>()?
|
||||
};
|
||||
if nums.len() > 4 {
|
||||
return Err(ParseError("too many timecode fields".into()));
|
||||
}
|
||||
let mut f4 = [0i64; 4];
|
||||
let off = 4 - nums.len();
|
||||
f4[off..].copy_from_slice(&nums);
|
||||
let [h, m, sec, fr] = f4;
|
||||
// Overflowing fields (e.g. 90 frames) are allowed, as in Premiere.
|
||||
let base = rate.timecode_base();
|
||||
let frames = if nums.len() == 1 && rel.is_some() {
|
||||
fr
|
||||
} else if drop_frame && rate.supports_drop_frame() {
|
||||
// overflowing fields carry into the next one first, then drop-frame counting applies
|
||||
let (sec, fr) = (sec.saturating_add(fr / base), fr % base);
|
||||
let (m, sec) = (m.saturating_add(sec / 60), sec % 60);
|
||||
let (h, m) = (h.saturating_add(m / 60), m % 60);
|
||||
fields_to_frames(h, m, sec, fr, rate, true)
|
||||
} else {
|
||||
// Saturating: typed or agent-sent timecodes can have absurdly large fields.
|
||||
h.saturating_mul(3600).saturating_add(m.saturating_mul(60)).saturating_add(sec).saturating_mul(base).saturating_add(fr)
|
||||
};
|
||||
Ok(match rel {
|
||||
Some(sign) => current.saturating_add(frames.saturating_mul(sign)),
|
||||
None => frames,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse typed text in the given display format into a frame count.
|
||||
///
|
||||
/// - [`TimeDisplay::Frames`]: a plain frame count (`240`).
|
||||
/// - [`TimeDisplay::Feet35`] / [`TimeDisplay::Feet16`]: `F+FF` (`2+08`; `.`, `:`, `;` and `,` also
|
||||
/// separate); a bare number is a frame count. A frames field past the foot rolls over
|
||||
/// (`1+20` at 35mm is 36 frames), as the timecode parser does.
|
||||
/// - A leading `+` / `-` makes the value relative to `current` (a bare relative number is frames).
|
||||
/// - Every other display parses as timecode ([`parse_timecode`]).
|
||||
pub fn parse_time_display(input: &str, rate: FrameRate, drop_frame: bool, display: TimeDisplay, current: i64) -> Result<i64, ParseError> {
|
||||
let per_ft = match display {
|
||||
TimeDisplay::Frames => 1,
|
||||
TimeDisplay::Feet35 | TimeDisplay::Feet16 => display.frames_per_foot().unwrap_or(16),
|
||||
_ => return parse_timecode(input, rate, drop_frame, current),
|
||||
};
|
||||
let s = input.trim();
|
||||
if s.is_empty() {
|
||||
return Err(ParseError("empty time".into()));
|
||||
}
|
||||
let (rel, body) = match s.as_bytes()[0] {
|
||||
b'+' => (Some(1), &s[1..]),
|
||||
b'-' => (Some(-1), &s[1..]),
|
||||
_ => (None, s),
|
||||
};
|
||||
let field = |p: &str| -> Result<i64, ParseError> {
|
||||
if p.is_empty() {
|
||||
Ok(0)
|
||||
} else if !p.bytes().all(|b| b.is_ascii_digit()) {
|
||||
Err(ParseError(format!("not a time: `{input}`")))
|
||||
} else {
|
||||
p.parse::<i64>().map_err(|_| ParseError("number too large".into()))
|
||||
}
|
||||
};
|
||||
let parts: Vec<&str> = body.split(['+', ':', ';', '.', ',']).collect();
|
||||
let frames = match parts.as_slice() {
|
||||
[n] => field(n)?,
|
||||
[feet, fr] if per_ft > 1 => field(feet)?.saturating_mul(per_ft).saturating_add(field(fr)?),
|
||||
_ => return Err(ParseError(format!("not a time: `{input}`"))),
|
||||
};
|
||||
Ok(match rel {
|
||||
Some(sign) => current.saturating_add(frames.saturating_mul(sign)),
|
||||
None => frames,
|
||||
})
|
||||
}
|
||||
|
||||
/// Common audio sample rates offered in sequence settings.
|
||||
pub const SAMPLE_RATES: [i64; 6] = [32_000, 44_100, 48_000, 88_200, 96_000, 192_000];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,6 +1,10 @@
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn clamp_i64(value: i128) -> i64 {
|
||||
value.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_time_ranges_never_overlap() {
|
||||
let clip = TimeRange::new(Tick(0), Tick(10));
|
||||
|
||||
Reference in new issue
Block a user