chore: add .gitattributes + unit tests for ffprobe rational parser
LF normalization across OSes; *.sql pinned to LF for stable diffs. Tests cover the rational frame-rate parser (NTSC 29.97, integer fps, slow-mo 240, ffprobe's 0/0 unknown sentinel, malformed and out-of-range inputs). Extracted the closure into a free fn for the test seam. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
9
.gitattributes
vendored
Normal file
9
.gitattributes
vendored
Normal file
@@ -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
|
||||||
@@ -133,6 +133,21 @@ pub struct VideoStreamMeta {
|
|||||||
pub frame_rate: Option<f32>,
|
pub frame_rate: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<f32> {
|
||||||
|
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
|
/// Probe video stream metadata in one ffprobe call. Returns default (codec
|
||||||
/// unknown, rotation 0, fps None) on any failure — callers fall back to
|
/// unknown, rotation 0, fps None) on any failure — callers fall back to
|
||||||
/// transcoding / a default framerate.
|
/// 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".
|
// ffprobe reports frame rates as rational strings like "30000/1001".
|
||||||
// Prefer avg_frame_rate (handles VFR) and fall back to r_frame_rate.
|
// 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<f32> {
|
|
||||||
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
|
let frame_rate = stream
|
||||||
.get("avg_frame_rate")
|
.get("avg_frame_rate")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(parse_rational)
|
.and_then(parse_ffprobe_rational)
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
stream
|
stream
|
||||||
.get("r_frame_rate")
|
.get("r_frame_rate")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(parse_rational)
|
.and_then(parse_ffprobe_rational)
|
||||||
});
|
});
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
@@ -820,3 +823,51 @@ impl Handler<GeneratePreviewClipMessage> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user