diff --git a/src/video/actors.rs b/src/video/actors.rs index 6f1520a..009aa4a 100644 --- a/src/video/actors.rs +++ b/src/video/actors.rs @@ -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, + /// Coded frame size. `None` when ffprobe didn't report it. + pub width: Option, + pub height: Option, + /// 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, +} + +/// 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::().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 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::().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 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 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 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 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() {