diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7ed1241 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Normalize line endings in the repo to LF. Windows checkouts can still +# present working-copy files as CRLF; this just keeps the committed history +# stable so contributors on any OS don't see whitespace-only diffs every +# time someone touches a file. +* text=auto eol=lf + +# Migrations and SQL must be LF — SQLite parsers don't care, but diffing +# is much cleaner with stable endings. +*.sql text eol=lf diff --git a/src/video/actors.rs b/src/video/actors.rs index 27d599b..8d04ea1 100644 --- a/src/video/actors.rs +++ b/src/video/actors.rs @@ -133,6 +133,21 @@ pub struct VideoStreamMeta { pub frame_rate: Option, } +/// Parse ffprobe's rational frame-rate strings (`"30000/1001"`, +/// `"60/1"`, `"0/0"`). Rejects 0/0 (ffprobe's "unknown" sentinel), +/// non-positive results, and anything wildly out of range so a malformed +/// probe can't poison the scrubber's step size. +fn parse_ffprobe_rational(s: &str) -> Option { + let (num, den) = s.split_once('/')?; + let num: f32 = num.parse().ok()?; + let den: f32 = den.parse().ok()?; + if den.abs() < f32::EPSILON { + return None; + } + let v = num / den; + (v.is_finite() && v > 0.0 && v < 1000.0).then_some(v) +} + /// Probe video stream metadata in one ffprobe call. Returns default (codec /// unknown, rotation 0, fps None) on any failure — callers fall back to /// transcoding / a default framerate. @@ -198,27 +213,15 @@ pub async fn probe_video_stream_meta(video_path: &str) -> VideoStreamMeta { // ffprobe reports frame rates as rational strings like "30000/1001". // Prefer avg_frame_rate (handles VFR) and fall back to r_frame_rate. - // Reject 0/0 (unknown), negative numbers, and anything wildly out of - // range so a malformed probe can't poison the scrubber's step size. - let parse_rational = |s: &str| -> Option { - let (num, den) = s.split_once('/')?; - let num: f32 = num.parse().ok()?; - let den: f32 = den.parse().ok()?; - if den.abs() < f32::EPSILON { - return None; - } - let v = num / den; - (v.is_finite() && v > 0.0 && v < 1000.0).then_some(v) - }; let frame_rate = stream .get("avg_frame_rate") .and_then(|v| v.as_str()) - .and_then(parse_rational) + .and_then(parse_ffprobe_rational) .or_else(|| { stream .get("r_frame_rate") .and_then(|v| v.as_str()) - .and_then(parse_rational) + .and_then(parse_ffprobe_rational) }); debug!( @@ -820,3 +823,51 @@ impl Handler for PreviewClipGenerator { }) } } + +#[cfg(test)] +mod tests { + use super::parse_ffprobe_rational; + + #[test] + fn parses_common_rational_framerates() { + // NTSC 29.97 fps + assert!((parse_ffprobe_rational("30000/1001").unwrap() - 29.970_03).abs() < 1e-3); + // Plain integer fps + assert!((parse_ffprobe_rational("30/1").unwrap() - 30.0).abs() < 1e-6); + assert!((parse_ffprobe_rational("60/1").unwrap() - 60.0).abs() < 1e-6); + // iPhone slow-mo + assert!((parse_ffprobe_rational("240/1").unwrap() - 240.0).abs() < 1e-6); + } + + #[test] + fn rejects_ffprobe_unknown_sentinel() { + // 0/0 is ffprobe's way of saying "I don't know" — must not be + // interpreted as 0 fps. + assert_eq!(parse_ffprobe_rational("0/0"), None); + } + + #[test] + fn rejects_malformed_input() { + assert_eq!(parse_ffprobe_rational(""), None); + assert_eq!(parse_ffprobe_rational("30"), None); + assert_eq!(parse_ffprobe_rational("/1"), None); + assert_eq!(parse_ffprobe_rational("30/"), None); + assert_eq!(parse_ffprobe_rational("abc/def"), None); + } + + #[test] + fn rejects_non_positive_results() { + // Negative numerator -> negative fps; meaningless. + assert_eq!(parse_ffprobe_rational("-30/1"), None); + // Zero numerator -> zero fps; also meaningless for frame stepping. + assert_eq!(parse_ffprobe_rational("0/1"), None); + } + + #[test] + fn rejects_out_of_range() { + // Anything > 1000 fps is almost certainly garbage probe output, + // not a real source. (Real high-speed capture maxes near 1 kHz.) + assert_eq!(parse_ffprobe_rational("999999/1"), None); + } +} +