fix(video): stop stream-copying oversized sources into HLS #166
+119
-8
@@ -161,6 +161,34 @@ pub struct VideoStreamMeta {
|
||||
/// `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>,
|
||||
/// Coded frame size. `None` when ffprobe didn't report it.
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
/// Bits per second — the video stream's own rate when the container
|
||||
/// reports it, else the overall format rate. `None` when unknown.
|
||||
pub bit_rate: Option<u64>,
|
||||
}
|
||||
|
||||
/// Longest the short side of an HLS rendition may be. Sources above this
|
||||
/// are transcoded down; the transcode scale filter enforces the same cap.
|
||||
const HLS_MAX_SHORT_SIDE: u32 = 1080;
|
||||
|
||||
/// Default ceiling for stream-copying a source into HLS, in bits/s.
|
||||
/// Overridable via `HLS_COPY_MAX_BITRATE`.
|
||||
const HLS_COPY_MAX_BITRATE_DEFAULT: u64 = 20_000_000;
|
||||
|
||||
/// Whether a source is small enough to stream-copy into HLS as-is.
|
||||
///
|
||||
/// Copy keeps the source's resolution and bitrate, so a 4K/100+ Mbps
|
||||
/// mirrorless clip would become ~50 MB 3-second segments that stall
|
||||
/// playback start and seeks on anything slower than wired gigabit.
|
||||
/// Unknown size or bitrate counts as too big — transcoding is the safe
|
||||
/// fallback, same as the GOP probe.
|
||||
fn fits_hls_copy_limits(meta: &VideoStreamMeta, max_bit_rate: u64) -> bool {
|
||||
let (Some(w), Some(h), Some(br)) = (meta.width, meta.height, meta.bit_rate) else {
|
||||
return false;
|
||||
};
|
||||
w.min(h) <= HLS_MAX_SHORT_SIDE && br <= max_bit_rate
|
||||
}
|
||||
|
||||
/// Parse ffprobe's rational frame-rate strings (`"30000/1001"`,
|
||||
@@ -196,9 +224,10 @@ pub async fn probe_video_stream_meta(video_path: &str) -> VideoStreamMeta {
|
||||
// network. For non-faststart phone clips on an SMB mount that turned a
|
||||
// metadata probe into a full-file read (tens of seconds per open). The
|
||||
// Display Matrix rotation we need is present at stream level, so this
|
||||
// keeps codec/fps/rotation while reading only the header.
|
||||
// keeps codec/fps/rotation while reading only the header. Size and
|
||||
// bit_rate (stream and format level) are header fields too.
|
||||
.arg("-show_entries")
|
||||
.arg("stream=codec_name,r_frame_rate,avg_frame_rate:stream_tags=rotate:stream_side_data_list")
|
||||
.arg("stream=codec_name,r_frame_rate,avg_frame_rate,width,height,bit_rate:stream_tags=rotate:stream_side_data_list:format=bit_rate")
|
||||
.arg(video_path)
|
||||
.output()
|
||||
.await;
|
||||
@@ -262,15 +291,38 @@ pub async fn probe_video_stream_meta(video_path: &str) -> VideoStreamMeta {
|
||||
.and_then(parse_ffprobe_rational)
|
||||
});
|
||||
|
||||
let dimension = |key: &str| {
|
||||
stream
|
||||
.get(key)
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.filter(|v| *v > 0)
|
||||
};
|
||||
let width = dimension("width");
|
||||
let height = dimension("height");
|
||||
|
||||
// ffprobe reports bit_rate as a decimal string. Prefer the video
|
||||
// stream's own rate; fall back to the container's overall rate.
|
||||
let parse_bit_rate = |v: &serde_json::Value| {
|
||||
v.get("bit_rate")
|
||||
.and_then(|b| b.as_str())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.filter(|b| *b > 0)
|
||||
};
|
||||
let bit_rate = parse_bit_rate(stream).or_else(|| parse_bit_rate(&json["format"]));
|
||||
|
||||
debug!(
|
||||
"Probed {}: codec_h264={}, rotation={}°, fps={:?}",
|
||||
video_path, is_h264, rotation, frame_rate
|
||||
"Probed {}: codec_h264={}, rotation={}°, fps={:?}, size={:?}x{:?}, bit_rate={:?}",
|
||||
video_path, is_h264, rotation, frame_rate, width, height, bit_rate
|
||||
);
|
||||
|
||||
VideoStreamMeta {
|
||||
is_h264,
|
||||
rotation,
|
||||
frame_rate,
|
||||
width,
|
||||
height,
|
||||
bit_rate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,12 +559,20 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
|
||||
let rotation = stream_meta.rotation;
|
||||
let has_rotation = rotation != 0;
|
||||
|
||||
// Stream-copy keeps the source's resolution and bitrate, so only
|
||||
// copy sources that are already a sensible streaming size.
|
||||
let max_copy_bit_rate = std::env::var("HLS_COPY_MAX_BITRATE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(HLS_COPY_MAX_BITRATE_DEFAULT);
|
||||
let size_ok = fits_hls_copy_limits(&stream_meta, max_copy_bit_rate);
|
||||
|
||||
// Stream-copy is only safe when the source GOP fits inside a
|
||||
// single HLS segment. Otherwise ffmpeg has to extend segments
|
||||
// past hls_time to land on a keyframe, producing uneven
|
||||
// segments and seeking glitches.
|
||||
const HLS_SEGMENT_SECONDS: f64 = 3.0;
|
||||
let gop_ok = if is_h264 && !has_rotation {
|
||||
let gop_ok = if is_h264 && !has_rotation && size_ok {
|
||||
match get_max_gop_seconds(&video_file).await {
|
||||
Some(g) if g > HLS_SEGMENT_SECONDS => {
|
||||
info!(
|
||||
@@ -536,7 +596,7 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
|
||||
false
|
||||
};
|
||||
|
||||
let use_copy = is_h264 && !has_rotation && gop_ok;
|
||||
let use_copy = is_h264 && !has_rotation && size_ok && gop_ok;
|
||||
|
||||
if has_rotation {
|
||||
info!(
|
||||
@@ -550,6 +610,12 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
|
||||
} else if use_copy {
|
||||
info!("Video {} is already h264, using stream copy", video_file);
|
||||
span.add_event("Using stream copy (h264 detected)", vec![]);
|
||||
} else if is_h264 && !size_ok {
|
||||
info!(
|
||||
"Video {} is h264 but too large to copy ({:?}x{:?} @ {:?} bps), transcoding down",
|
||||
video_file, stream_meta.width, stream_meta.height, stream_meta.bit_rate
|
||||
);
|
||||
span.add_event("Transcoding to reduce size/bitrate", vec![]);
|
||||
} else if is_h264 {
|
||||
info!(
|
||||
"Video {} is h264 but needs transcoding for GOP alignment",
|
||||
@@ -591,7 +657,12 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
|
||||
cmd.arg("-crf").arg("21");
|
||||
cmd.arg("-preset").arg("veryfast");
|
||||
}
|
||||
cmd.arg("-vf").arg("scale='min(1080,iw)':-2,setsar=1:1");
|
||||
// Cap the short side so landscape and portrait both land at
|
||||
// real 1080p. iw/ih are post-autorotation display dims.
|
||||
cmd.arg("-vf").arg(format!(
|
||||
"scale='if(gt(iw,ih),-2,min({m},iw))':'if(gt(iw,ih),min({m},ih),-2)',setsar=1:1",
|
||||
m = HLS_MAX_SHORT_SIDE
|
||||
));
|
||||
cmd.arg("-c:a").arg("aac");
|
||||
// Force an IDR frame every hls_time seconds so each HLS
|
||||
// segment starts on a keyframe — accurate seeking without
|
||||
@@ -867,7 +938,47 @@ impl Handler<GeneratePreviewClipMessage> for PreviewClipGenerator {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_ffprobe_rational;
|
||||
use super::{VideoStreamMeta, fits_hls_copy_limits, parse_ffprobe_rational};
|
||||
|
||||
const LIMIT: u64 = 20_000_000;
|
||||
|
||||
fn meta(width: u32, height: u32, bit_rate: u64) -> VideoStreamMeta {
|
||||
VideoStreamMeta {
|
||||
width: Some(width),
|
||||
height: Some(height),
|
||||
bit_rate: Some(bit_rate),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_phone_sized_sources() {
|
||||
assert!(fits_hls_copy_limits(&meta(1920, 1080, 16_000_000), LIMIT));
|
||||
// Portrait 1080p: short side is the width.
|
||||
assert!(fits_hls_copy_limits(&meta(1080, 1920, 16_000_000), LIMIT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcodes_4k_sources() {
|
||||
assert!(!fits_hls_copy_limits(&meta(3840, 2160, 16_000_000), LIMIT));
|
||||
assert!(!fits_hls_copy_limits(&meta(2160, 3840, 16_000_000), LIMIT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcodes_high_bitrate_1080p() {
|
||||
// Mirrorless 1080p at 100 Mbps fits the size cap but not the bitrate.
|
||||
assert!(!fits_hls_copy_limits(&meta(1920, 1080, 100_000_000), LIMIT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcodes_when_size_or_bitrate_unknown() {
|
||||
assert!(!fits_hls_copy_limits(&VideoStreamMeta::default(), LIMIT));
|
||||
let no_bit_rate = VideoStreamMeta {
|
||||
bit_rate: None,
|
||||
..meta(1920, 1080, 0)
|
||||
};
|
||||
assert!(!fits_hls_copy_limits(&no_bit_rate, LIMIT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_common_rational_framerates() {
|
||||
|
||||
Reference in new issue
Block a user