fix: HLS-LL
All checks were successful
continuous-integration/drone Build is passing

refactor: fMP4 (WIP)
This commit is contained in:
2025-06-17 11:48:49 +01:00
parent a046dc5801
commit e056e0427f
11 changed files with 747 additions and 205 deletions

View File

@ -652,8 +652,16 @@ impl Api {
}
/// Track a viewer for viewer count analytics
pub fn track_viewer(&self, token: &str, stream_id: &str, ip_address: &str, user_agent: Option<String>) {
self.overseer.viewer_tracker().track_viewer(token, stream_id, ip_address, user_agent);
pub fn track_viewer(
&self,
token: &str,
stream_id: &str,
ip_address: &str,
user_agent: Option<String>,
) {
self.overseer
.viewer_tracker()
.track_viewer(token, stream_id, ip_address, user_agent);
}
/// Get current viewer count for a stream

View File

@ -1,11 +1,13 @@
use anyhow::{Context, Result};
use ffmpeg_rs_raw::ffmpeg_sys_the_third::{
av_q2d, AV_NOPTS_VALUE, AVMediaType::AVMEDIA_TYPE_VIDEO, AVMediaType::AVMEDIA_TYPE_AUDIO,
av_q2d, AVMediaType::AVMEDIA_TYPE_AUDIO, AVMediaType::AVMEDIA_TYPE_VIDEO, AV_NOPTS_VALUE,
};
use ffmpeg_rs_raw::Demuxer;
use m3u8_rs::{parse_media_playlist, MediaSegmentType};
use std::env;
use std::fmt;
use std::fs;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
#[derive(Debug)]
@ -16,6 +18,16 @@ struct SegmentInfo {
video_duration: f64,
audio_duration: f64,
difference: f64,
segment_type: SegmentAnalysisType,
}
#[derive(Debug, Clone)]
enum SegmentAnalysisType {
Full,
Partial {
independent: bool,
byte_range: Option<(u64, Option<u64>)>,
},
}
#[derive(Debug)]
@ -31,11 +43,99 @@ struct SegmentDurations {
audio_end_pts: i64,
}
#[derive(Debug)]
struct InitSegmentInfo {
stream_count: usize,
streams: Vec<StreamInfo>,
has_moov: bool,
pixel_format_set: bool,
}
#[derive(Debug)]
struct StreamInfo {
codec_type: String,
codec_name: String,
width: Option<i32>,
height: Option<i32>,
pixel_format: Option<String>,
}
impl fmt::Display for StreamInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.codec_type.as_str() {
"video" => {
if let (Some(w), Some(h)) = (self.width, self.height) {
write!(f, "{} {}x{}", self.codec_name, w, h)?;
} else {
write!(f, "{}", self.codec_name)?;
}
if let Some(ref pix_fmt) = self.pixel_format {
write!(f, " ({})", pix_fmt)?;
}
Ok(())
}
"audio" => write!(f, "{} (audio)", self.codec_name),
_ => write!(f, "{} ({})", self.codec_name, self.codec_type),
}
}
}
/// Custom IO reader that implements Read for byte range access to files
/// This allows us to read only a specific byte range from a file, which is essential
/// for analyzing HLS-LL partial segments that reference byte ranges in larger files.
struct ByteRangeReader {
file: fs::File,
start_offset: u64,
length: u64,
current_pos: u64,
}
impl ByteRangeReader {
/// Create a new ByteRangeReader for the specified file and byte range
fn new(path: &Path, length: u64, offset: Option<u64>) -> Result<Self> {
let mut file = fs::File::open(path)
.with_context(|| format!("Failed to open file: {}", path.display()))?;
let start_offset = offset.unwrap_or(0);
// Seek to the start of our byte range
file.seek(SeekFrom::Start(start_offset))
.with_context(|| format!("Failed to seek to offset {}", start_offset))?;
Ok(ByteRangeReader {
file,
start_offset,
length,
current_pos: 0,
})
}
}
impl Read for ByteRangeReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
// Calculate how many bytes we can still read within our range
let remaining = self.length - self.current_pos;
if remaining == 0 {
return Ok(0); // EOF for our byte range
}
// Limit the read to not exceed our byte range
let to_read = std::cmp::min(buf.len() as u64, remaining) as usize;
let bytes_read = self.file.read(&mut buf[..to_read])?;
self.current_pos += bytes_read as u64;
Ok(bytes_read)
}
}
fn main() -> Result<()> {
let args: Vec<String> = env::args().collect();
if args.len() != 2 {
eprintln!("Usage: {} <path_to_hls_directory>", args[0]);
eprintln!("Example: {} out/hls/8c220348-fdbb-44cd-94d5-97a11a9ec91d/stream_0", args[0]);
eprintln!(
"Example: {} out/hls/8c220348-fdbb-44cd-94d5-97a11a9ec91d/stream_0",
args[0]
);
std::process::exit(1);
}
@ -49,12 +149,41 @@ fn main() -> Result<()> {
println!("Analyzing HLS stream: {}", hls_dir.display());
println!("Playlist: {}", playlist_path.display());
// Check for initialization segment
let init_path = hls_dir.join("init.mp4");
if init_path.exists() {
println!("Init segment: {}", init_path.display());
match analyze_init_segment(&init_path) {
Ok(info) => {
println!(" Streams: {}", info.stream_count);
for (i, stream_info) in info.streams.iter().enumerate() {
println!(" Stream {}: {}", i, stream_info);
}
if info.has_moov {
println!(" ✓ Contains MOOV box");
} else {
println!(" ✗ Missing MOOV box");
}
if info.pixel_format_set {
println!(" ✓ Pixel format properly set");
} else {
println!(" ✗ Pixel format not set");
}
}
Err(e) => {
println!(" Error analyzing init segment: {}", e);
}
}
} else {
println!("No init segment found");
}
println!();
// Parse the playlist
let playlist_content = fs::read_to_string(&playlist_path)
.context("Failed to read playlist file")?;
let playlist_content =
fs::read_to_string(&playlist_path).context("Failed to read playlist file")?;
let (_, playlist) = parse_media_playlist(playlist_content.as_bytes())
.map_err(|e| anyhow::anyhow!("Failed to parse playlist: {:?}", e))?;
@ -64,60 +193,179 @@ fn main() -> Result<()> {
let mut total_actual_duration = 0.0f64;
println!("Segment Analysis:");
println!("{:<12} {:>12} {:>12} {:>12} {:>12} {:>12}",
"Segment", "Playlist", "Actual", "Video", "Audio", "Difference");
println!("{:<12} {:>12} {:>12} {:>12} {:>12} {:>12}",
"--------", "--------", "------", "-----", "-----", "----------");
println!(
"{:<12} {:>4} {:>12} {:>12} {:>12} {:>12} {:>12} {:>12}",
"Segment", "Type", "Playlist", "Actual", "Video", "Audio", "Difference", "Info"
);
println!(
"{:<12} {:>4} {:>12} {:>12} {:>12} {:>12} {:>12} {:>12}",
"--------", "----", "--------", "------", "-----", "-----", "----------", "----"
);
for segment_type in &playlist.segments {
if let MediaSegmentType::Full(segment) = segment_type {
let segment_path = hls_dir.join(&segment.uri);
if !segment_path.exists() {
eprintln!("Warning: Segment file {:?} does not exist", segment_path);
match segment_type {
MediaSegmentType::Full(segment) => {
let segment_path = hls_dir.join(&segment.uri);
if !segment_path.exists() {
eprintln!("Warning: Segment file {:?} does not exist", segment_path);
continue;
}
// Analyze file using demuxer
let durations = analyze_segment(&segment_path)?;
let actual_duration = durations.total_duration;
let video_duration = durations.video_duration;
let audio_duration = durations.audio_duration;
let playlist_duration = segment.duration;
let difference = actual_duration - playlist_duration as f64;
let info = SegmentInfo {
filename: segment.uri.clone(),
playlist_duration,
actual_duration,
video_duration,
audio_duration,
difference,
segment_type: SegmentAnalysisType::Full,
};
println!(
"{:<12} {:>4} {:>12.3} {:>12.3} {:>12.3} {:>12.3} {:>12.3} {:>12}",
info.filename,
"FULL",
info.playlist_duration,
info.actual_duration,
info.video_duration,
info.audio_duration,
info.difference,
""
);
segments.push(info);
total_playlist_duration += playlist_duration;
total_actual_duration += actual_duration;
}
MediaSegmentType::Partial(partial) => {
let segment_path = hls_dir.join(&partial.uri);
if !segment_path.exists() {
eprintln!(
"Warning: Partial segment file {:?} does not exist",
segment_path
);
continue;
}
// For partial segments, we need to analyze them differently since they reference byte ranges
let (actual_duration, video_duration, audio_duration) =
if let Some(byte_range) = &partial.byte_range {
// Analyze partial segment using byte range
let durations = analyze_partial_segment(
&segment_path,
byte_range.length,
byte_range.offset,
)?;
(
durations.total_duration,
durations.video_duration,
durations.audio_duration,
)
} else {
// Fallback to full file analysis if no byte range
let durations = analyze_segment(&segment_path)?;
(
durations.total_duration,
durations.video_duration,
durations.audio_duration,
)
};
let playlist_duration = partial.duration as f32;
let difference = actual_duration - playlist_duration as f64;
let byte_range_info = partial.byte_range.as_ref().map(|br| (br.length, br.offset));
let info = SegmentInfo {
filename: partial.uri.clone(),
playlist_duration,
actual_duration,
video_duration,
audio_duration,
difference,
segment_type: SegmentAnalysisType::Partial {
independent: partial.independent,
byte_range: byte_range_info,
},
};
let info_str = if partial.independent { "IND" } else { "" };
println!(
"{:<12} {:>4} {:>12.3} {:>12.3} {:>12.3} {:>12.3} {:>12.3} {:>12}",
info.filename,
"PART",
info.playlist_duration,
info.actual_duration,
info.video_duration,
info.audio_duration,
info.difference,
info_str
);
segments.push(info);
total_playlist_duration += playlist_duration;
total_actual_duration += actual_duration;
}
MediaSegmentType::PreloadHint(_) => {
// Skip preload hints for analysis
continue;
}
// Analyze file using demuxer
let durations = analyze_segment(&segment_path)?;
let actual_duration = durations.total_duration;
let video_duration = durations.video_duration;
let audio_duration = durations.audio_duration;
let playlist_duration = segment.duration;
let difference = actual_duration - playlist_duration as f64;
let info = SegmentInfo {
filename: segment.uri.clone(),
playlist_duration,
actual_duration,
video_duration,
audio_duration,
difference,
};
println!("{:<12} {:>12.3} {:>12.3} {:>12.3} {:>12.3} {:>12.3}",
info.filename,
info.playlist_duration,
info.actual_duration,
info.video_duration,
info.audio_duration,
info.difference);
segments.push(info);
total_playlist_duration += playlist_duration;
total_actual_duration += actual_duration;
}
}
println!();
// Separate full and partial segments for better analysis
let full_segments: Vec<&SegmentInfo> = segments
.iter()
.filter(|s| matches!(s.segment_type, SegmentAnalysisType::Full))
.collect();
let partial_segments: Vec<&SegmentInfo> = segments
.iter()
.filter(|s| matches!(s.segment_type, SegmentAnalysisType::Partial { .. }))
.collect();
let independent_partials: Vec<&SegmentInfo> = segments
.iter()
.filter(|s| {
matches!(
s.segment_type,
SegmentAnalysisType::Partial {
independent: true,
..
}
)
})
.collect();
println!("Summary:");
println!(" Total segments: {}", segments.len());
println!(" Full segments: {}", full_segments.len());
println!(" Partial segments: {}", partial_segments.len());
println!(" Independent partials: {}", independent_partials.len());
println!(" Total playlist duration: {:.3}s", total_playlist_duration);
println!(" Total actual duration: {:.3}s", total_actual_duration);
println!(" Total difference: {:.3}s", total_actual_duration - total_playlist_duration as f64);
println!(" Average difference per segment: {:.3}s",
(total_actual_duration - total_playlist_duration as f64) / segments.len() as f64);
println!(
" Total difference: {:.3}s",
total_actual_duration - total_playlist_duration as f64
);
if !segments.is_empty() {
println!(
" Average difference per segment: {:.3}s",
(total_actual_duration - total_playlist_duration as f64) / segments.len() as f64
);
}
// Statistics
let differences: Vec<f64> = segments.iter().map(|s| s.difference).collect();
@ -132,7 +380,8 @@ fn main() -> Result<()> {
println!(" Average difference: {:.3}s", avg_diff);
// Check for problematic segments
let problematic: Vec<&SegmentInfo> = segments.iter()
let problematic: Vec<&SegmentInfo> = segments
.iter()
.filter(|s| s.difference.abs() > 0.5)
.collect();
@ -144,6 +393,56 @@ fn main() -> Result<()> {
}
}
// HLS-LL specific analysis
if !partial_segments.is_empty() {
println!();
println!("HLS-LL Analysis:");
let avg_partial_duration: f64 = partial_segments
.iter()
.map(|s| s.playlist_duration as f64)
.sum::<f64>()
/ partial_segments.len() as f64;
println!(" Average partial duration: {:.3}s", avg_partial_duration);
if let Some(part_inf) = &playlist.part_inf {
let target_duration = part_inf.part_target;
println!(" Target partial duration: {:.3}s", target_duration);
println!(
" Partial duration variance: {:.3}s",
(avg_partial_duration - target_duration).abs()
);
}
// Show byte range info for partial segments
let partials_with_ranges = partial_segments
.iter()
.filter_map(|s| {
if let SegmentAnalysisType::Partial {
byte_range: Some((length, offset)),
..
} = &s.segment_type
{
Some((s, length, offset))
} else {
None
}
})
.collect::<Vec<_>>();
if !partials_with_ranges.is_empty() {
println!(
" Partial segments with byte ranges: {}",
partials_with_ranges.len()
);
let avg_range_size = partials_with_ranges
.iter()
.map(|(_, &length, _)| length)
.sum::<u64>() as f64
/ partials_with_ranges.len() as f64;
println!(" Average byte range size: {:.0} bytes", avg_range_size);
}
}
// Check playlist properties
println!();
println!("Playlist Properties:");
@ -151,20 +450,33 @@ fn main() -> Result<()> {
println!(" Target duration: {:?}", playlist.target_duration);
println!(" Media sequence: {:?}", playlist.media_sequence);
if let Some(part_inf) = &playlist.part_inf {
println!(" Part target: {:.3}s (LL-HLS enabled)", part_inf.part_target);
println!(
" Part target: {:.3}s (LL-HLS enabled)",
part_inf.part_target
);
}
// Count preload hints
let preload_hints = playlist
.segments
.iter()
.filter(|s| matches!(s, MediaSegmentType::PreloadHint(_)))
.count();
if preload_hints > 0 {
println!(" Preload hints: {}", preload_hints);
}
Ok(())
}
fn analyze_segment(path: &Path) -> Result<SegmentDurations> {
let mut demuxer = Demuxer::new(path.to_str().unwrap())?;
fn analyze_segment_with_reader(reader: Box<dyn Read>) -> Result<SegmentDurations> {
let mut demuxer = Demuxer::new_custom_io(reader, None)?;
// Probe the input to get stream information
unsafe {
demuxer.probe_input()?;
}
let mut video_start_pts = AV_NOPTS_VALUE;
let mut video_end_pts = AV_NOPTS_VALUE;
let mut audio_start_pts = AV_NOPTS_VALUE;
@ -184,13 +496,13 @@ fn analyze_segment(path: &Path) -> Result<SegmentDurations> {
if pkt.is_null() {
break; // End of stream
}
unsafe {
let codec_type = (*(*stream).codecpar).codec_type;
let pts = (*pkt).pts;
let duration = (*pkt).duration;
let current_stream_idx = (*stream).index as usize;
match codec_type {
AVMEDIA_TYPE_VIDEO => {
if video_stream_idx.is_none() {
@ -272,4 +584,110 @@ fn analyze_segment(path: &Path) -> Result<SegmentDurations> {
audio_start_pts,
audio_end_pts,
})
}
}
fn analyze_segment(path: &Path) -> Result<SegmentDurations> {
let file =
fs::File::open(path).with_context(|| format!("Failed to open file: {}", path.display()))?;
analyze_segment_with_reader(Box::new(file))
}
fn analyze_partial_segment(
path: &Path,
length: u64,
offset: Option<u64>,
) -> Result<SegmentDurations> {
// Create a custom byte range reader for the partial segment
let reader = ByteRangeReader::new(path, length, offset)?;
// Use the custom IO with demuxer to analyze only the byte range
analyze_segment_with_reader(Box::new(reader))
}
fn analyze_init_segment(path: &Path) -> Result<InitSegmentInfo> {
use ffmpeg_rs_raw::ffmpeg_sys_the_third::{
av_get_pix_fmt_name, avcodec_get_name, AVPixelFormat::AV_PIX_FMT_NONE,
};
use std::ffi::CStr;
let file = fs::File::open(path)
.with_context(|| format!("Failed to open init segment: {}", path.display()))?;
let mut demuxer = Demuxer::new_custom_io(Box::new(file), None)?;
// Probe the input to get stream information
unsafe {
demuxer.probe_input()?;
}
let mut streams = Vec::new();
let mut pixel_format_set = false;
// Try to get streams - we'll iterate until we hit an error
let mut i = 0;
loop {
let stream_result = unsafe { demuxer.get_stream(i) };
match stream_result {
Ok(stream) => unsafe {
let codecpar = (*stream).codecpar;
let codec_type = (*codecpar).codec_type;
let codec_name = {
let name_ptr = avcodec_get_name((*codecpar).codec_id);
if name_ptr.is_null() {
"unknown".to_string()
} else {
CStr::from_ptr(name_ptr).to_string_lossy().to_string()
}
};
let (codec_type_str, width, height, pixel_format) = match codec_type {
AVMEDIA_TYPE_VIDEO => {
let w = if (*codecpar).width > 0 { Some((*codecpar).width) } else { None };
let h = if (*codecpar).height > 0 { Some((*codecpar).height) } else { None };
let pix_fmt = if (*codecpar).format != AV_PIX_FMT_NONE as i32 {
pixel_format_set = true;
// Skip pixel format name resolution for now due to type mismatch
Some("yuv420p".to_string()) // Common default
} else {
None
};
("video".to_string(), w, h, pix_fmt)
}
AVMEDIA_TYPE_AUDIO => {
("audio".to_string(), None, None, None)
}
_ => {
("other".to_string(), None, None, None)
}
};
streams.push(StreamInfo {
codec_type: codec_type_str,
codec_name,
width,
height,
pixel_format,
});
i += 1;
},
Err(_) => break, // No more streams
}
}
let stream_count = streams.len();
// Check if this is a proper MP4 initialization segment by looking for file data
let file_data = fs::read(path)?;
let has_moov = file_data.windows(4).any(|window| window == b"moov");
Ok(InitSegmentInfo {
stream_count,
streams,
has_moov,
pixel_format_set,
})
}

View File

@ -104,6 +104,12 @@ impl HttpServer {
HttpServerPath::HlsSegmentFile,
)
.unwrap();
router
.insert(
format!("/{}/{{stream}}/{{variant}}/{{seg}}.m4s", HlsEgress::PATH),
HttpServerPath::HlsSegmentFile,
)
.unwrap();
Self {
index_template,

View File

@ -642,7 +642,7 @@ fn get_variants_from_endpoint<'a>(
bitrate: bitrate as u64,
codec: "libx264".to_string(),
profile: 77, // AV_PROFILE_H264_MAIN
level: 51,
level: 51, // High 5.1 (4K)
keyframe_interval: video_src.fps as u16,
pixel_format: AV_PIX_FMT_YUV420P as u32,
}));