feature/video-frame-rate #98
@@ -25,7 +25,9 @@ use crate::files::is_valid_full_path;
|
|||||||
use crate::libraries;
|
use crate::libraries;
|
||||||
use crate::otel::{extract_context_from_request, global_tracer};
|
use crate::otel::{extract_context_from_request, global_tracer};
|
||||||
use crate::state::AppState;
|
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;
|
use crate::video::hls_paths;
|
||||||
|
|
||||||
/// Response body for `POST /video/generate`. Clients consume
|
/// 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
|
/// transcode was queued; clients should retry the URL after a short
|
||||||
/// delay (or rely on HLS.js's own retry policy).
|
/// delay (or rely on HLS.js's own retry policy).
|
||||||
ready: bool,
|
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")]
|
#[post("/video/generate")]
|
||||||
@@ -182,11 +189,19 @@ pub async fn generate_video(
|
|||||||
hls_paths::PLAYLIST_FILENAME
|
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);
|
span.set_status(Status::Ok);
|
||||||
HttpResponse::Ok().json(GenerateVideoResponse {
|
HttpResponse::Ok().json(GenerateVideoResponse {
|
||||||
playlist_url,
|
playlist_url,
|
||||||
content_hash: content_hash_str,
|
content_hash: content_hash_str,
|
||||||
ready,
|
ready,
|
||||||
|
frame_rate,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
/// Video stream metadata needed to pick HLS encode settings. Populated by
|
||||||
/// a single ffprobe call to avoid spawning multiple subprocesses per video.
|
/// a single ffprobe call to avoid spawning multiple subprocesses per video.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct VideoStreamMeta {
|
pub struct VideoStreamMeta {
|
||||||
is_h264: bool,
|
pub is_h264: bool,
|
||||||
/// Rotation in degrees (0/90/180/270). Checks both the legacy `rotate`
|
/// Rotation in degrees (0/90/180/270). Checks both the legacy `rotate`
|
||||||
/// stream tag and the modern display-matrix side data.
|
/// 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
|
/// Probe video stream metadata in one ffprobe call. Returns default (codec
|
||||||
/// unknown, rotation 0) on any failure — callers fall back to transcoding.
|
/// unknown, rotation 0, fps None) on any failure — callers fall back to
|
||||||
async fn probe_video_stream_meta(video_path: &str) -> VideoStreamMeta {
|
/// transcoding / a default framerate.
|
||||||
|
pub async fn probe_video_stream_meta(video_path: &str) -> VideoStreamMeta {
|
||||||
let output = tokio::process::Command::new("ffprobe")
|
let output = tokio::process::Command::new("ffprobe")
|
||||||
.arg("-v")
|
.arg("-v")
|
||||||
.arg("error")
|
.arg("error")
|
||||||
@@ -140,7 +145,7 @@ async fn probe_video_stream_meta(video_path: &str) -> VideoStreamMeta {
|
|||||||
.arg("-print_format")
|
.arg("-print_format")
|
||||||
.arg("json")
|
.arg("json")
|
||||||
.arg("-show_entries")
|
.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)
|
.arg(video_path)
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
@@ -191,12 +196,41 @@ async fn probe_video_stream_meta(video_path: &str) -> VideoStreamMeta {
|
|||||||
})
|
})
|
||||||
.unwrap_or(0);
|
.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!(
|
debug!(
|
||||||
"Probed {}: codec_h264={}, rotation={}°",
|
"Probed {}: codec_h264={}, rotation={}°, fps={:?}",
|
||||||
video_path, is_h264, rotation
|
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.
|
/// Probe the max keyframe interval (GOP) in the first ~30s of a video.
|
||||||
|
|||||||
Reference in New Issue
Block a user