feature/video-frame-rate #98

Merged
cameron merged 3 commits from feature/video-frame-rate into master 2026-05-18 00:09:36 +00:00
3 changed files with 118 additions and 10 deletions

9
.gitattributes vendored Normal file
View 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

View File

@@ -25,7 +25,9 @@ use crate::files::is_valid_full_path;
use crate::libraries;
use crate::otel::{extract_context_from_request, global_tracer};
use crate::state::AppState;
use crate::video::actors::{GeneratePreviewClipMessage, QueueVideosMessage, VideoToQueue};
use crate::video::actors::{
GeneratePreviewClipMessage, QueueVideosMessage, VideoToQueue, probe_video_stream_meta,
};
use crate::video::hls_paths;
/// Response body for `POST /video/generate`. Clients consume
@@ -46,6 +48,11 @@ struct GenerateVideoResponse {
/// transcode was queued; clients should retry the URL after a short
/// delay (or rely on HLS.js's own retry policy).
ready: bool,
/// Source-video frame rate in Hz, probed via ffprobe. `None` when the
/// probe failed or ffprobe couldn't parse either rate field — clients
/// fall back to their own default (typically 30) for frame stepping.
#[serde(skip_serializing_if = "Option::is_none")]
frame_rate: Option<f32>,
}
#[post("/video/generate")]
@@ -182,11 +189,19 @@ pub async fn generate_video(
hls_paths::PLAYLIST_FILENAME
);
// Probe the source for frame rate so the mobile scrubber can step at
// the right interval. Cheap (~tens of ms) and only runs once per video
// open. Probe failures degrade silently — clients have a fallback.
let frame_rate = probe_video_stream_meta(&full_path.to_string_lossy())
.await
.frame_rate;
span.set_status(Status::Ok);
HttpResponse::Ok().json(GenerateVideoResponse {
playlist_url,
content_hash: content_hash_str,
ready,
frame_rate,
})
}

View File

@@ -122,16 +122,36 @@ 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>,
}
/// 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
/// 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 +160,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 +211,29 @@ 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.
let frame_rate = stream
.get("avg_frame_rate")
.and_then(|v| v.as_str())
.and_then(parse_ffprobe_rational)
.or_else(|| {
stream
.get("r_frame_rate")
.and_then(|v| v.as_str())
.and_then(parse_ffprobe_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.
@@ -786,3 +823,50 @@ 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);
}
}