video: probe frame rate via ffprobe and return on /video/generate
Adds frame_rate to GenerateVideoResponse so the mobile scrubber can step at the source's real fps instead of a hardcoded 30. probe_video_stream_meta gains a frame_rate field (avg_frame_rate preferred, r_frame_rate fallback, nonsense values rejected) and is now pub so the handler can reuse it. Cost is one ffprobe per /video/generate call; degrades silently to None on probe failure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -122,16 +122,21 @@ pub fn generate_image_thumbnail_ffmpeg(path: &Path, destination: &Path) -> std::
|
||||
/// Video stream metadata needed to pick HLS encode settings. Populated by
|
||||
/// a single ffprobe call to avoid spawning multiple subprocesses per video.
|
||||
#[derive(Debug, Default)]
|
||||
struct VideoStreamMeta {
|
||||
is_h264: bool,
|
||||
pub struct VideoStreamMeta {
|
||||
pub is_h264: bool,
|
||||
/// Rotation in degrees (0/90/180/270). Checks both the legacy `rotate`
|
||||
/// stream tag and the modern display-matrix side data.
|
||||
rotation: i32,
|
||||
pub rotation: i32,
|
||||
/// Frames per second. Prefers `avg_frame_rate` (handles VFR better than
|
||||
/// `r_frame_rate`, which lies on variable-framerate sources). `None`
|
||||
/// when ffprobe couldn't parse either field — caller picks a fallback.
|
||||
pub frame_rate: Option<f32>,
|
||||
}
|
||||
|
||||
/// Probe video stream metadata in one ffprobe call. Returns default (codec
|
||||
/// unknown, rotation 0) on any failure — callers fall back to transcoding.
|
||||
async fn probe_video_stream_meta(video_path: &str) -> VideoStreamMeta {
|
||||
/// unknown, rotation 0, fps None) on any failure — callers fall back to
|
||||
/// transcoding / a default framerate.
|
||||
pub async fn probe_video_stream_meta(video_path: &str) -> VideoStreamMeta {
|
||||
let output = tokio::process::Command::new("ffprobe")
|
||||
.arg("-v")
|
||||
.arg("error")
|
||||
@@ -140,7 +145,7 @@ async fn probe_video_stream_meta(video_path: &str) -> VideoStreamMeta {
|
||||
.arg("-print_format")
|
||||
.arg("json")
|
||||
.arg("-show_entries")
|
||||
.arg("stream=codec_name:stream_tags=rotate:side_data_list")
|
||||
.arg("stream=codec_name,r_frame_rate,avg_frame_rate:stream_tags=rotate:side_data_list")
|
||||
.arg(video_path)
|
||||
.output()
|
||||
.await;
|
||||
@@ -191,12 +196,41 @@ async fn probe_video_stream_meta(video_path: &str) -> VideoStreamMeta {
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
// 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<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
|
||||
.get("avg_frame_rate")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(parse_rational)
|
||||
.or_else(|| {
|
||||
stream
|
||||
.get("r_frame_rate")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(parse_rational)
|
||||
});
|
||||
|
||||
debug!(
|
||||
"Probed {}: codec_h264={}, rotation={}°",
|
||||
video_path, is_h264, rotation
|
||||
"Probed {}: codec_h264={}, rotation={}°, fps={:?}",
|
||||
video_path, is_h264, rotation, frame_rate
|
||||
);
|
||||
|
||||
VideoStreamMeta { is_h264, rotation }
|
||||
VideoStreamMeta {
|
||||
is_h264,
|
||||
rotation,
|
||||
frame_rate,
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the max keyframe interval (GOP) in the first ~30s of a video.
|
||||
|
||||
Reference in New Issue
Block a user