#![forbid(unsafe_code)]
use oximedia_core::{OxiError, OxiResult};
use std::fmt::Write as FmtWrite;
use std::path::{Path, PathBuf};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use super::mp4::Mp4Fragment;
#[derive(Clone, Debug)]
pub struct SegmentWriterConfig {
pub output_dir: PathBuf,
pub filename_pattern: String,
pub delete_old_segments: bool,
pub max_segments: Option<usize>,
pub generate_playlist: bool,
}
impl SegmentWriterConfig {
#[must_use]
pub fn new<P: AsRef<Path>>(output_dir: P) -> Self {
Self {
output_dir: output_dir.as_ref().to_path_buf(),
filename_pattern: "segment_%05d.m4s".into(),
delete_old_segments: false,
max_segments: None,
generate_playlist: false,
}
}
#[must_use]
pub fn with_filename_pattern(mut self, pattern: impl Into<String>) -> Self {
self.filename_pattern = pattern.into();
self
}
#[must_use]
pub const fn with_delete_old_segments(mut self, enabled: bool) -> Self {
self.delete_old_segments = enabled;
self
}
#[must_use]
pub const fn with_max_segments(mut self, max: usize) -> Self {
self.max_segments = Some(max);
self
}
#[must_use]
pub const fn with_playlist_generation(mut self, enabled: bool) -> Self {
self.generate_playlist = enabled;
self
}
}
#[derive(Debug, Clone)]
pub struct SegmentInfo {
pub sequence: u32,
pub path: PathBuf,
pub size: u64,
pub duration_secs: f64,
pub has_keyframe: bool,
}
impl SegmentInfo {
#[must_use]
pub fn new(sequence: u32, path: PathBuf, size: u64, duration_secs: f64) -> Self {
Self {
sequence,
path,
size,
duration_secs,
has_keyframe: false,
}
}
#[must_use]
pub fn filename(&self) -> Option<&str> {
self.path.file_name().and_then(|n| n.to_str())
}
}
pub struct SegmentWriter {
config: SegmentWriterConfig,
segments: Vec<SegmentInfo>,
init_segment_path: Option<PathBuf>,
}
impl SegmentWriter {
pub async fn new(config: SegmentWriterConfig) -> OxiResult<Self> {
fs::create_dir_all(&config.output_dir)
.await
.map_err(|e: std::io::Error| OxiError::from(e))?;
Ok(Self {
config,
segments: Vec::new(),
init_segment_path: None,
})
}
pub async fn write_init_segment(&mut self, fragment: &Mp4Fragment) -> OxiResult<PathBuf> {
if !fragment.is_init() {
return Err(OxiError::InvalidData(
"Fragment is not an init segment".into(),
));
}
let path = self.config.output_dir.join("init.mp4");
let mut file = fs::File::create(&path)
.await
.map_err(|e: std::io::Error| OxiError::from(e))?;
file.write_all(&fragment.data)
.await
.map_err(|e: std::io::Error| OxiError::from(e))?;
file.flush()
.await
.map_err(|e: std::io::Error| OxiError::from(e))?;
self.init_segment_path = Some(path.clone());
Ok(path)
}
pub async fn write_segment(&mut self, fragment: &Mp4Fragment) -> OxiResult<SegmentInfo> {
if !fragment.is_media() {
return Err(OxiError::InvalidData(
"Fragment is not a media segment".into(),
));
}
let filename = self
.config
.filename_pattern
.replace("%05d", &format!("{:05}", fragment.sequence))
.replace("%d", &fragment.sequence.to_string());
let path = self.config.output_dir.join(filename);
let mut file = fs::File::create(&path)
.await
.map_err(|e: std::io::Error| OxiError::from(e))?;
file.write_all(&fragment.data)
.await
.map_err(|e: std::io::Error| OxiError::from(e))?;
file.flush()
.await
.map_err(|e: std::io::Error| OxiError::from(e))?;
#[allow(clippy::cast_precision_loss)]
let duration_secs = fragment.duration_us as f64 / 1_000_000.0;
let mut info = SegmentInfo::new(
fragment.sequence,
path,
fragment.data.len() as u64,
duration_secs,
);
info.has_keyframe = fragment.has_keyframe;
self.segments.push(info.clone());
if self.config.delete_old_segments {
self.cleanup_old_segments().await?;
}
if self.config.generate_playlist {
self.generate_playlist().await?;
}
Ok(info)
}
#[must_use]
pub fn segments(&self) -> &[SegmentInfo] {
&self.segments
}
#[must_use]
pub fn init_segment_path(&self) -> Option<&Path> {
self.init_segment_path.as_deref()
}
async fn cleanup_old_segments(&mut self) -> OxiResult<()> {
if let Some(max_segments) = self.config.max_segments {
while self.segments.len() > max_segments {
if let Some(segment) = self.segments.first() {
let path = segment.path.clone();
if let Err(e) = fs::remove_file(&path).await {
eprintln!("Failed to delete segment {}: {e}", path.display());
}
}
self.segments.remove(0);
}
}
Ok(())
}
async fn generate_playlist(&self) -> OxiResult<()> {
let playlist_path = self.config.output_dir.join("playlist.m3u8");
let mut content = String::new();
content.push_str("#EXTM3U\n");
content.push_str("#EXT-X-VERSION:6\n");
content.push_str("#EXT-X-TARGETDURATION:10\n");
content.push_str("#EXT-X-MEDIA-SEQUENCE:1\n");
if let Some(init_path) = &self.init_segment_path {
if let Some(filename) = init_path.file_name().and_then(|n| n.to_str()) {
let _ = writeln!(content, "#EXT-X-MAP:URI=\"{filename}\"");
}
}
for segment in &self.segments {
let _ = writeln!(content, "#EXTINF:{:.6},", segment.duration_secs);
if let Some(filename) = segment.filename() {
content.push_str(filename);
content.push('\n');
}
}
fs::write(&playlist_path, content)
.await
.map_err(|e: std::io::Error| OxiError::from(e))?;
Ok(())
}
}
pub struct DashManifestGenerator {
presentation_duration_secs: f64,
min_buffer_time_secs: f64,
}
impl DashManifestGenerator {
#[must_use]
pub const fn new() -> Self {
Self {
presentation_duration_secs: 0.0,
min_buffer_time_secs: 4.0,
}
}
#[must_use]
pub const fn with_duration(mut self, duration_secs: f64) -> Self {
self.presentation_duration_secs = duration_secs;
self
}
#[must_use]
pub const fn with_min_buffer_time(mut self, time_secs: f64) -> Self {
self.min_buffer_time_secs = time_secs;
self
}
#[must_use]
pub fn generate(&self, segments: &[SegmentInfo], init_segment: &str) -> String {
let mut mpd = String::new();
mpd.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
mpd.push_str("<MPD xmlns=\"urn:mpeg:dash:schema:mpd:2011\" ");
mpd.push_str("type=\"static\" ");
let _ = write!(
mpd,
"mediaPresentationDuration=\"PT{:.3}S\" ",
self.presentation_duration_secs
);
let _ = writeln!(
mpd,
"minBufferTime=\"PT{:.3}S\">",
self.min_buffer_time_secs
);
mpd.push_str(" <Period>\n");
mpd.push_str(" <AdaptationSet>\n");
mpd.push_str(" <Representation>\n");
let _ = writeln!(mpd, " <BaseURL>{init_segment}</BaseURL>");
mpd.push_str(" <SegmentList>\n");
for segment in segments {
if let Some(filename) = segment.filename() {
let _ = writeln!(mpd, " <SegmentURL media=\"{filename}\" />");
}
}
mpd.push_str(" </SegmentList>\n");
mpd.push_str(" </Representation>\n");
mpd.push_str(" </AdaptationSet>\n");
mpd.push_str(" </Period>\n");
mpd.push_str("</MPD>\n");
mpd
}
}
impl Default for DashManifestGenerator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("oximedia-container-fragment-segment-{name}"))
}
#[test]
fn test_segment_writer_config() {
let config = SegmentWriterConfig::new(tmp_path("segments"))
.with_filename_pattern("seg_%d.m4s")
.with_delete_old_segments(true)
.with_max_segments(10)
.with_playlist_generation(true);
assert_eq!(config.filename_pattern, "seg_%d.m4s");
assert!(config.delete_old_segments);
assert_eq!(config.max_segments, Some(10));
assert!(config.generate_playlist);
}
#[test]
fn test_segment_info() {
let info = SegmentInfo::new(1, tmp_path("seg1.m4s"), 1024, 2.0);
assert_eq!(info.sequence, 1);
assert_eq!(info.size, 1024);
assert_eq!(info.duration_secs, 2.0);
assert_eq!(
info.filename(),
Some("oximedia-container-fragment-segment-seg1.m4s")
);
}
#[test]
fn test_dash_manifest_generator() {
let generator = DashManifestGenerator::new()
.with_duration(10.0)
.with_min_buffer_time(2.0);
let segments = vec![
SegmentInfo::new(1, PathBuf::from("seg1.m4s"), 1024, 2.0),
SegmentInfo::new(2, PathBuf::from("seg2.m4s"), 1024, 2.0),
];
let manifest = generator.generate(&segments, "init.mp4");
assert!(manifest.contains("<?xml version=\"1.0\""));
assert!(manifest.contains("MPD"));
assert!(manifest.contains("seg1.m4s"));
assert!(manifest.contains("seg2.m4s"));
}
}