fix(video): stop stream-copying oversized sources into HLS

HLS generation stream-copied any h264 source with no rotation and a
GOP within one segment, which keeps the source's resolution and
bitrate. Mirrorless footage (4K at 100-160 Mbps) came through as-is,
giving ~50 MB 3-second segments; playback start and seeks stalled on
anything slower than wired gigabit. On prod, 16 playlists run above
50 Mbps, versus ~10 Mbps typical for phone clips.

The stream probe now also reads width, height and bit_rate (stream
level, falling back to format level; all header fields, so the probe
stays header-only). Copy additionally requires the short side to be
<= 1080 and the bitrate <= HLS_COPY_MAX_BITRATE (default 20 Mbps).
Unknown size or bitrate transcodes, matching the GOP probe's fallback.

The transcode scale filter capped width at 1080, so landscape sources
came out at 1080x608. It now caps the short side instead: 3840x2160
-> 1920x1080, 2160x3840 -> 1080x1920, smaller sources unchanged
(verified with ffmpeg).

Existing oversized playlists need regenerating after deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-13 10:46:46 -04:00
1 parent ff1ba1fcd6
commit 1279cb8430
1 file changed
+119 -8
+119 -8
View File
@@ -161,6 +161,34 @@ pub struct VideoStreamMeta {
/// `r_frame_rate`, which lies on variable-framerate sources). `None` /// `r_frame_rate`, which lies on variable-framerate sources). `None`
/// when ffprobe couldn't parse either field — caller picks a fallback. /// when ffprobe couldn't parse either field — caller picks a fallback.
pub frame_rate: Option<f32>, 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"`, /// 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 // 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 // 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 // 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("-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) .arg(video_path)
.output() .output()
.await; .await;
@@ -262,15 +291,38 @@ pub async fn probe_video_stream_meta(video_path: &str) -> VideoStreamMeta {
.and_then(parse_ffprobe_rational) .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!( debug!(
"Probed {}: codec_h264={}, rotation={}°, fps={:?}", "Probed {}: codec_h264={}, rotation={}°, fps={:?}, size={:?}x{:?}, bit_rate={:?}",
video_path, is_h264, rotation, frame_rate video_path, is_h264, rotation, frame_rate, width, height, bit_rate
); );
VideoStreamMeta { VideoStreamMeta {
is_h264, is_h264,
rotation, rotation,
frame_rate, frame_rate,
width,
height,
bit_rate,
} }
} }
@@ -507,12 +559,20 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
let rotation = stream_meta.rotation; let rotation = stream_meta.rotation;
let has_rotation = rotation != 0; 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 // Stream-copy is only safe when the source GOP fits inside a
// single HLS segment. Otherwise ffmpeg has to extend segments // single HLS segment. Otherwise ffmpeg has to extend segments
// past hls_time to land on a keyframe, producing uneven // past hls_time to land on a keyframe, producing uneven
// segments and seeking glitches. // segments and seeking glitches.
const HLS_SEGMENT_SECONDS: f64 = 3.0; 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 { match get_max_gop_seconds(&video_file).await {
Some(g) if g > HLS_SEGMENT_SECONDS => { Some(g) if g > HLS_SEGMENT_SECONDS => {
info!( info!(
@@ -536,7 +596,7 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
false false
}; };
let use_copy = is_h264 && !has_rotation && gop_ok; let use_copy = is_h264 && !has_rotation && size_ok && gop_ok;
if has_rotation { if has_rotation {
info!( info!(
@@ -550,6 +610,12 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
} else if use_copy { } else if use_copy {
info!("Video {} is already h264, using stream copy", video_file); info!("Video {} is already h264, using stream copy", video_file);
span.add_event("Using stream copy (h264 detected)", vec![]); 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 { } else if is_h264 {
info!( info!(
"Video {} is h264 but needs transcoding for GOP alignment", "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("-crf").arg("21");
cmd.arg("-preset").arg("veryfast"); 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"); cmd.arg("-c:a").arg("aac");
// Force an IDR frame every hls_time seconds so each HLS // Force an IDR frame every hls_time seconds so each HLS
// segment starts on a keyframe — accurate seeking without // segment starts on a keyframe — accurate seeking without
@@ -867,7 +938,47 @@ impl Handler<GeneratePreviewClipMessage> for PreviewClipGenerator {
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn parses_common_rational_framerates() { fn parses_common_rational_framerates() {