use ffmpeg_next as ffmpeg;
use std::path::Path;
use thiserror::Error;
pub const HLS_SEGMENT_DURATION: f64 = 10.0;
const MPEG_TS_TIME_BASE: i64 = 90_000;
const KBPS_TO_BPS: usize = 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TranscodeTarget {
Resolution720p,
Resolution480p,
}
impl TranscodeTarget {
pub fn height(&self) -> u32 {
match self {
TranscodeTarget::Resolution720p => 720,
TranscodeTarget::Resolution480p => 480,
}
}
pub fn width(&self) -> u32 {
match self {
TranscodeTarget::Resolution720p => 1280,
TranscodeTarget::Resolution480p => 854,
}
}
pub fn video_bitrate_kbps(&self) -> u32 {
match self {
TranscodeTarget::Resolution720p => 2500,
TranscodeTarget::Resolution480p => 1000,
}
}
pub fn audio_bitrate_kbps(&self) -> u32 {
match self {
TranscodeTarget::Resolution720p => 128,
TranscodeTarget::Resolution480p => 96,
}
}
pub fn url_suffix(&self) -> &'static str {
match self {
TranscodeTarget::Resolution720p => "-720p",
TranscodeTarget::Resolution480p => "-480p",
}
}
}
#[derive(Debug, Error)]
pub enum TranscodeError {
#[error("Failed to open video file: {}", path.display())]
OpenFailed {
path: std::path::PathBuf,
#[source]
source: ffmpeg::Error,
},
#[error("No video stream found in file: {}", path.display())]
NoVideoStream { path: std::path::PathBuf },
#[error("No audio stream found in file: {}", path.display())]
NoAudioStream { path: std::path::PathBuf },
#[error("Source video ({source_height}p) is not larger than target ({target_height}p)")]
SourceTooSmall {
source_height: u32,
target_height: u32,
},
#[error("Segment {segment_index} is out of range (video duration: {video_duration:.1}s)")]
SegmentOutOfRange {
segment_index: u32,
video_duration: f64,
},
#[error("Transcoding failed: {0}")]
TranscodeFailed(String),
#[error("Encoder not available: {0}")]
EncoderNotAvailable(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Unsupported video format")]
UnsupportedFormat,
}
#[derive(Debug, Clone)]
pub struct VideoResolution {
pub width: u32,
pub height: u32,
pub duration_secs: f64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HlsRequest {
Playlist {
video_path: String,
target: TranscodeTarget,
},
Segment {
video_path: String,
target: TranscodeTarget,
segment_index: u32,
},
}
pub fn parse_hls_request(path: &str) -> Option<HlsRequest> {
if let Some(base) = path.strip_suffix("-720p.m3u8") {
let video_path = find_original_video_path(base);
return Some(HlsRequest::Playlist {
video_path,
target: TranscodeTarget::Resolution720p,
});
}
if let Some(base) = path.strip_suffix("-480p.m3u8") {
let video_path = find_original_video_path(base);
return Some(HlsRequest::Playlist {
video_path,
target: TranscodeTarget::Resolution480p,
});
}
if let Some(rest) = path.strip_suffix(".ts")
&& let Some((base_with_res, segment_str)) = rest.rsplit_once('-')
&& let Ok(segment_index) = segment_str.parse::<u32>()
{
if let Some(base) = base_with_res.strip_suffix("-720p") {
let video_path = find_original_video_path(base);
return Some(HlsRequest::Segment {
video_path,
target: TranscodeTarget::Resolution720p,
segment_index,
});
}
if let Some(base) = base_with_res.strip_suffix("-480p") {
let video_path = find_original_video_path(base);
return Some(HlsRequest::Segment {
video_path,
target: TranscodeTarget::Resolution480p,
segment_index,
});
}
}
None
}
fn find_original_video_path(base: &str) -> String {
format!("{base}.mp4")
}
pub fn is_supported_video(path: &str) -> bool {
let path_lower = path.to_lowercase();
path_lower.ends_with(".mp4")
|| path_lower.ends_with(".mov")
|| path_lower.ends_with(".m4v")
|| path_lower.ends_with(".mkv")
|| path_lower.ends_with(".avi")
|| path_lower.ends_with(".webm")
}
pub fn probe_video_resolution(video_path: &Path) -> Result<VideoResolution, TranscodeError> {
let input = ffmpeg::format::input(video_path).map_err(|e| TranscodeError::OpenFailed {
path: video_path.to_path_buf(),
source: e,
})?;
let video_stream = input
.streams()
.best(ffmpeg::media::Type::Video)
.ok_or_else(|| TranscodeError::NoVideoStream {
path: video_path.to_path_buf(),
})?;
let codec_params = video_stream.parameters();
let decoder_ctx =
ffmpeg::codec::context::Context::from_parameters(codec_params).map_err(|e| {
TranscodeError::TranscodeFailed(format!("Failed to create decoder context: {e}"))
})?;
let decoder = decoder_ctx
.decoder()
.video()
.map_err(|e| TranscodeError::TranscodeFailed(format!("Failed to create decoder: {e}")))?;
let width = decoder.width();
let height = decoder.height();
let duration_secs = if input.duration() >= 0 {
input.duration() as f64 / f64::from(ffmpeg::ffi::AV_TIME_BASE)
} else {
let time_base = video_stream.time_base();
let stream_duration = video_stream.duration();
if stream_duration > 0 {
stream_duration as f64 * f64::from(time_base.numerator())
/ f64::from(time_base.denominator())
} else {
0.0
}
};
Ok(VideoResolution {
width,
height,
duration_secs,
})
}
pub fn should_transcode(source_height: u32, target: TranscodeTarget) -> bool {
source_height > target.height()
}
pub fn calculate_output_dimensions(
source_width: u32,
source_height: u32,
target: TranscodeTarget,
) -> (u32, u32) {
let target_height = target.height();
let aspect_ratio = source_width as f64 / source_height as f64;
let mut output_width = (target_height as f64 * aspect_ratio).round() as u32;
if !output_width.is_multiple_of(2) {
output_width += 1;
}
let output_height = if !target_height.is_multiple_of(2) {
target_height + 1
} else {
target_height
};
(output_width, output_height)
}
pub fn find_h264_encoder() -> &'static str {
let hw_encoders = [
"h264_videotoolbox", "h264_nvenc", "h264_vaapi", "h264_qsv", "h264_amf", ];
for encoder_name in hw_encoders {
if ffmpeg::encoder::find_by_name(encoder_name).is_some() {
tracing::debug!("Found hardware encoder: {}", encoder_name);
return encoder_name;
}
}
tracing::debug!("No hardware encoder found, using libx264");
"libx264"
}
pub fn generate_hls_playlist(
video_path: &Path,
target: TranscodeTarget,
base_name: &str,
) -> Result<String, TranscodeError> {
let resolution = probe_video_resolution(video_path)?;
if !should_transcode(resolution.height, target) {
return Err(TranscodeError::SourceTooSmall {
source_height: resolution.height,
target_height: target.height(),
});
}
let duration = resolution.duration_secs;
let num_segments = (duration / HLS_SEGMENT_DURATION).ceil() as u32;
let target_duration = HLS_SEGMENT_DURATION.ceil() as u32;
let suffix = target.url_suffix();
let mut playlist = String::with_capacity(512);
playlist.push_str("#EXTM3U\n");
playlist.push_str("#EXT-X-VERSION:3\n");
playlist.push_str(&format!("#EXT-X-TARGETDURATION:{target_duration}\n"));
playlist.push_str("#EXT-X-MEDIA-SEQUENCE:0\n");
playlist.push_str("#EXT-X-PLAYLIST-TYPE:VOD\n");
for i in 0..num_segments {
let segment_duration = if i == num_segments - 1 {
let remaining = duration - (i as f64 * HLS_SEGMENT_DURATION);
remaining.max(0.001) } else {
HLS_SEGMENT_DURATION
};
playlist.push_str(&format!("#EXTINF:{segment_duration:.3},\n"));
playlist.push_str(&format!("{base_name}{suffix}-{i:03}.ts\n"));
}
playlist.push_str("#EXT-X-ENDLIST\n");
Ok(playlist)
}
pub fn transcode_segment(
source_path: &Path,
target: TranscodeTarget,
segment_index: u32,
) -> Result<Vec<u8>, TranscodeError> {
let start_time = segment_index as f64 * HLS_SEGMENT_DURATION;
let resolution = probe_video_resolution(source_path)?;
if !should_transcode(resolution.height, target) {
return Err(TranscodeError::SourceTooSmall {
source_height: resolution.height,
target_height: target.height(),
});
}
if start_time >= resolution.duration_secs {
return Err(TranscodeError::SegmentOutOfRange {
segment_index,
video_duration: resolution.duration_secs,
});
}
let end_time = (start_time + HLS_SEGMENT_DURATION).min(resolution.duration_secs);
let (output_width, output_height) =
calculate_output_dimensions(resolution.width, resolution.height, target);
tracing::info!(
"Transcoding segment {} ({:.2}s - {:.2}s) of {} to {}x{}",
segment_index,
start_time,
end_time,
source_path.display(),
output_width,
output_height
);
let temp_dir = std::env::temp_dir();
let temp_file = temp_dir.join(format!(
"mbr_segment_{}_{}.ts",
std::process::id(),
segment_index
));
let mut input_ctx =
ffmpeg::format::input(source_path).map_err(|e| TranscodeError::OpenFailed {
path: source_path.to_path_buf(),
source: e,
})?;
let video_stream_index = input_ctx
.streams()
.best(ffmpeg::media::Type::Video)
.ok_or_else(|| TranscodeError::NoVideoStream {
path: source_path.to_path_buf(),
})?
.index();
let audio_stream_index = input_ctx
.streams()
.best(ffmpeg::media::Type::Audio)
.map(|s| s.index());
let video_stream = input_ctx.stream(video_stream_index).unwrap();
let video_codec_params = video_stream.parameters();
let video_time_base = video_stream.time_base();
let start_ts = (start_time * f64::from(video_time_base.denominator())
/ f64::from(video_time_base.numerator())) as i64;
input_ctx.seek(start_ts, ..start_ts).map_err(|e| {
TranscodeError::TranscodeFailed(format!("Seek to segment {segment_index} failed: {e}"))
})?;
let video_decoder_ctx = ffmpeg::codec::context::Context::from_parameters(video_codec_params)
.map_err(|e| {
TranscodeError::TranscodeFailed(format!("Failed to create video decoder context: {e}"))
})?;
let mut video_decoder = video_decoder_ctx.decoder().video().map_err(|e| {
TranscodeError::TranscodeFailed(format!("Failed to create video decoder: {e}"))
})?;
let mut audio_decoder = if let Some(audio_idx) = audio_stream_index {
let audio_stream = input_ctx.stream(audio_idx).unwrap();
let audio_codec_params = audio_stream.parameters();
let audio_decoder_ctx =
ffmpeg::codec::context::Context::from_parameters(audio_codec_params).map_err(|e| {
TranscodeError::TranscodeFailed(format!(
"Failed to create audio decoder context: {e}"
))
})?;
Some(audio_decoder_ctx.decoder().audio().map_err(|e| {
TranscodeError::TranscodeFailed(format!("Failed to create audio decoder: {e}"))
})?)
} else {
None
};
let mut output_ctx = ffmpeg::format::output_as(&temp_file, "mpegts").map_err(|e| {
TranscodeError::TranscodeFailed(format!("Failed to create MPEG-TS output: {e}"))
})?;
let encoder_name = find_h264_encoder();
let video_encoder_codec = ffmpeg::encoder::find_by_name(encoder_name)
.ok_or_else(|| TranscodeError::EncoderNotAvailable(encoder_name.to_string()))?;
let video_encoder_ctx = ffmpeg::codec::context::Context::new_with_codec(video_encoder_codec);
let mut video_encoder_setup = video_encoder_ctx.encoder().video().map_err(|e| {
TranscodeError::TranscodeFailed(format!("Failed to create video encoder: {e}"))
})?;
video_encoder_setup.set_width(output_width);
video_encoder_setup.set_height(output_height);
video_encoder_setup.set_format(ffmpeg::format::Pixel::YUV420P);
video_encoder_setup.set_time_base(video_time_base);
video_encoder_setup.set_bit_rate(target.video_bitrate_kbps() as usize * KBPS_TO_BPS);
let mut encoder_options = ffmpeg::Dictionary::new();
encoder_options.set("preset", "fast");
let mut video_encoder = video_encoder_setup
.open_with(encoder_options)
.map_err(|e| {
TranscodeError::TranscodeFailed(format!("Failed to open video encoder: {e}"))
})?;
let video_output_idx = {
let mut video_output_stream = output_ctx.add_stream(video_encoder_codec).map_err(|e| {
TranscodeError::TranscodeFailed(format!("Failed to add video stream: {e}"))
})?;
video_output_stream.set_parameters(&video_encoder);
video_output_stream.index()
};
let encoder_audio_format = ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Planar);
let mut audio_encoder_opt: Option<(usize, ffmpeg::encoder::Audio)> =
if let Some(audio_idx) = audio_stream_index {
let audio_stream = input_ctx.stream(audio_idx).unwrap();
let audio_time_base = audio_stream.time_base();
let aac_encoder = ffmpeg::encoder::find_by_name("aac")
.ok_or_else(|| TranscodeError::EncoderNotAvailable("aac".to_string()))?;
let audio_encoder_ctx = ffmpeg::codec::context::Context::new_with_codec(aac_encoder);
let mut audio_enc_setup = audio_encoder_ctx.encoder().audio().map_err(|e| {
TranscodeError::TranscodeFailed(format!("Failed to create audio encoder: {e}"))
})?;
let audio_dec = audio_decoder.as_ref().unwrap();
audio_enc_setup.set_rate(audio_dec.rate() as i32);
audio_enc_setup.set_channel_layout(audio_dec.channel_layout());
audio_enc_setup.set_format(encoder_audio_format);
audio_enc_setup.set_time_base(audio_time_base);
audio_enc_setup.set_bit_rate(target.audio_bitrate_kbps() as usize * KBPS_TO_BPS);
let audio_enc = audio_enc_setup.open().map_err(|e| {
TranscodeError::TranscodeFailed(format!("Failed to open audio encoder: {e}"))
})?;
let audio_out_idx = {
let mut audio_output_stream = output_ctx.add_stream(aac_encoder).map_err(|e| {
TranscodeError::TranscodeFailed(format!("Failed to add audio stream: {e}"))
})?;
audio_output_stream.set_parameters(&audio_enc);
audio_output_stream.index()
};
Some((audio_out_idx, audio_enc))
} else {
None
};
let mut audio_resampler: Option<ffmpeg::software::resampling::Context> =
if let Some(audio_dec) = &audio_decoder {
Some(
ffmpeg::software::resampling::Context::get(
audio_dec.format(),
audio_dec.channel_layout(),
audio_dec.rate(),
encoder_audio_format,
audio_dec.channel_layout(),
audio_dec.rate(),
)
.map_err(|e| {
TranscodeError::TranscodeFailed(format!(
"Failed to create audio resampler: {e}"
))
})?,
)
} else {
None
};
output_ctx
.write_header()
.map_err(|e| TranscodeError::TranscodeFailed(format!("Failed to write header: {e}")))?;
let mut scaler = ffmpeg::software::scaling::Context::get(
video_decoder.format(),
video_decoder.width(),
video_decoder.height(),
ffmpeg::format::Pixel::YUV420P,
output_width,
output_height,
ffmpeg::software::scaling::Flags::BILINEAR,
)
.map_err(|e| TranscodeError::TranscodeFailed(format!("Failed to create scaler: {e}")))?;
let video_segment_start_pts = (start_time * f64::from(video_time_base.denominator())
/ f64::from(video_time_base.numerator())) as i64;
let audio_segment_start_pts = if let Some(audio_idx) = audio_stream_index {
let audio_stream = input_ctx.stream(audio_idx).unwrap();
let audio_time_base = audio_stream.time_base();
(start_time * f64::from(audio_time_base.denominator())
/ f64::from(audio_time_base.numerator())) as i64
} else {
0
};
let mut decoded_frame = ffmpeg::frame::Video::empty();
let mut scaled_frame = ffmpeg::frame::Video::empty();
let mut audio_frame = ffmpeg::frame::Audio::empty();
let mut frames_written = 0;
for (stream, packet) in input_ctx.packets() {
let stream_time_base = stream.time_base();
let pkt_pts = packet.pts().unwrap_or(0);
let pkt_time = pkt_pts as f64 * f64::from(stream_time_base.numerator())
/ f64::from(stream_time_base.denominator());
if pkt_time >= end_time {
break;
}
if stream.index() == video_stream_index {
if pkt_time < start_time {
video_decoder.send_packet(&packet).ok();
while video_decoder.receive_frame(&mut decoded_frame).is_ok() {}
continue;
}
video_decoder.send_packet(&packet).map_err(|e| {
TranscodeError::TranscodeFailed(format!("Failed to send video packet: {e}"))
})?;
while video_decoder.receive_frame(&mut decoded_frame).is_ok() {
scaler.run(&decoded_frame, &mut scaled_frame).map_err(|e| {
TranscodeError::TranscodeFailed(format!("Failed to scale frame: {e}"))
})?;
let frame_pts = decoded_frame.pts().unwrap_or(0);
let adjusted_pts = (frame_pts - video_segment_start_pts).max(0);
let output_pts = (adjusted_pts as f64
* MPEG_TS_TIME_BASE as f64
* f64::from(video_time_base.numerator())
/ f64::from(video_time_base.denominator()))
as i64;
scaled_frame.set_pts(Some(output_pts));
video_encoder.send_frame(&scaled_frame).map_err(|e| {
TranscodeError::TranscodeFailed(format!("Failed to send frame to encoder: {e}"))
})?;
let mut encoded_packet = ffmpeg::Packet::empty();
while video_encoder.receive_packet(&mut encoded_packet).is_ok() {
encoded_packet.set_stream(video_output_idx);
encoded_packet
.write_interleaved(&mut output_ctx)
.map_err(|e| {
TranscodeError::TranscodeFailed(format!("Failed to write packet: {e}"))
})?;
frames_written += 1;
}
}
} else if Some(stream.index()) == audio_stream_index {
if pkt_time < start_time {
if let Some(audio_dec) = &mut audio_decoder {
audio_dec.send_packet(&packet).ok();
while audio_dec.receive_frame(&mut audio_frame).is_ok() {}
}
continue;
}
let audio_time_base = stream.time_base();
if let (Some(audio_dec), Some((audio_idx, audio_enc)), Some(resampler)) = (
&mut audio_decoder,
&mut audio_encoder_opt,
&mut audio_resampler,
) {
audio_dec.send_packet(&packet).map_err(|e| {
TranscodeError::TranscodeFailed(format!("Failed to send audio packet: {e}"))
})?;
while audio_dec.receive_frame(&mut audio_frame).is_ok() {
let frame_pts = audio_frame.pts().unwrap_or(0);
let adjusted_pts = (frame_pts - audio_segment_start_pts).max(0);
let output_audio_pts = (adjusted_pts as f64
* MPEG_TS_TIME_BASE as f64
* f64::from(audio_time_base.numerator())
/ f64::from(audio_time_base.denominator()))
as i64;
let mut resampled_frame = ffmpeg::frame::Audio::empty();
let delay = resampler
.run(&audio_frame, &mut resampled_frame)
.map_err(|e| {
TranscodeError::TranscodeFailed(format!(
"Failed to resample audio: {e}"
))
})?;
if resampled_frame.samples() > 0 || delay.is_some() {
resampled_frame.set_pts(Some(output_audio_pts));
audio_enc.send_frame(&resampled_frame).map_err(|e| {
TranscodeError::TranscodeFailed(format!(
"Failed to send audio frame to encoder: {e}"
))
})?;
let mut encoded_packet = ffmpeg::Packet::empty();
while audio_enc.receive_packet(&mut encoded_packet).is_ok() {
encoded_packet.set_stream(*audio_idx);
encoded_packet
.write_interleaved(&mut output_ctx)
.map_err(|e| {
TranscodeError::TranscodeFailed(format!(
"Failed to write audio packet: {e}"
))
})?;
}
}
}
}
}
}
video_decoder.send_eof().ok();
while video_decoder.receive_frame(&mut decoded_frame).is_ok() {
if scaler.run(&decoded_frame, &mut scaled_frame).is_ok() {
let frame_pts = decoded_frame.pts().unwrap_or(0);
let adjusted_pts = (frame_pts - video_segment_start_pts).max(0);
let output_pts = (adjusted_pts as f64
* MPEG_TS_TIME_BASE as f64
* f64::from(video_time_base.numerator())
/ f64::from(video_time_base.denominator())) as i64;
scaled_frame.set_pts(Some(output_pts));
video_encoder.send_frame(&scaled_frame).ok();
let mut encoded_packet = ffmpeg::Packet::empty();
while video_encoder.receive_packet(&mut encoded_packet).is_ok() {
encoded_packet.set_stream(video_output_idx);
encoded_packet.write_interleaved(&mut output_ctx).ok();
frames_written += 1;
}
}
}
video_encoder.send_eof().ok();
let mut encoded_packet = ffmpeg::Packet::empty();
while video_encoder.receive_packet(&mut encoded_packet).is_ok() {
encoded_packet.set_stream(video_output_idx);
encoded_packet.write_interleaved(&mut output_ctx).ok();
frames_written += 1;
}
if let (Some(audio_dec), Some((audio_idx, audio_enc)), Some(resampler)) = (
&mut audio_decoder,
&mut audio_encoder_opt,
&mut audio_resampler,
) {
let audio_time_base = if let Some(audio_idx) = audio_stream_index {
input_ctx.stream(audio_idx).unwrap().time_base()
} else {
ffmpeg::Rational::new(1, MPEG_TS_TIME_BASE as i32)
};
audio_dec.send_eof().ok();
while audio_dec.receive_frame(&mut audio_frame).is_ok() {
let frame_pts = audio_frame.pts().unwrap_or(0);
let adjusted_pts = (frame_pts - audio_segment_start_pts).max(0);
let output_audio_pts = (adjusted_pts as f64
* MPEG_TS_TIME_BASE as f64
* f64::from(audio_time_base.numerator())
/ f64::from(audio_time_base.denominator()))
as i64;
let mut resampled_frame = ffmpeg::frame::Audio::empty();
if resampler.run(&audio_frame, &mut resampled_frame).is_ok()
&& resampled_frame.samples() > 0
{
resampled_frame.set_pts(Some(output_audio_pts));
audio_enc.send_frame(&resampled_frame).ok();
let mut encoded_packet = ffmpeg::Packet::empty();
while audio_enc.receive_packet(&mut encoded_packet).is_ok() {
encoded_packet.set_stream(*audio_idx);
encoded_packet.write_interleaved(&mut output_ctx).ok();
}
}
}
let mut flush_frame = ffmpeg::frame::Audio::empty();
while resampler.flush(&mut flush_frame).is_ok() && flush_frame.samples() > 0 {
audio_enc.send_frame(&flush_frame).ok();
let mut encoded_packet = ffmpeg::Packet::empty();
while audio_enc.receive_packet(&mut encoded_packet).is_ok() {
encoded_packet.set_stream(*audio_idx);
encoded_packet.write_interleaved(&mut output_ctx).ok();
}
flush_frame = ffmpeg::frame::Audio::empty();
}
audio_enc.send_eof().ok();
let mut encoded_packet = ffmpeg::Packet::empty();
while audio_enc.receive_packet(&mut encoded_packet).is_ok() {
encoded_packet.set_stream(*audio_idx);
encoded_packet.write_interleaved(&mut output_ctx).ok();
}
}
output_ctx
.write_trailer()
.map_err(|e| TranscodeError::TranscodeFailed(format!("Failed to write trailer: {e}")))?;
let segment_data = std::fs::read(&temp_file)?;
let _ = std::fs::remove_file(&temp_file);
tracing::info!(
"Transcoded segment {}: {} bytes, {} frames",
segment_index,
segment_data.len(),
frames_written
);
Ok(segment_data)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_hls_playlist_720p() {
let result = parse_hls_request("videos/demo-720p.m3u8");
assert_eq!(
result,
Some(HlsRequest::Playlist {
video_path: "videos/demo.mp4".to_string(),
target: TranscodeTarget::Resolution720p,
})
);
}
#[test]
fn test_parse_hls_playlist_480p() {
let result = parse_hls_request("videos/demo-480p.m3u8");
assert_eq!(
result,
Some(HlsRequest::Playlist {
video_path: "videos/demo.mp4".to_string(),
target: TranscodeTarget::Resolution480p,
})
);
}
#[test]
fn test_parse_hls_segment_720p() {
let result = parse_hls_request("videos/demo-720p-005.ts");
assert_eq!(
result,
Some(HlsRequest::Segment {
video_path: "videos/demo.mp4".to_string(),
target: TranscodeTarget::Resolution720p,
segment_index: 5,
})
);
}
#[test]
fn test_parse_hls_segment_480p() {
let result = parse_hls_request("videos/demo-480p-000.ts");
assert_eq!(
result,
Some(HlsRequest::Segment {
video_path: "videos/demo.mp4".to_string(),
target: TranscodeTarget::Resolution480p,
segment_index: 0,
})
);
}
#[test]
fn test_parse_hls_segment_with_path() {
let result = parse_hls_request("videos/tutorials/intro-720p-012.ts");
assert_eq!(
result,
Some(HlsRequest::Segment {
video_path: "videos/tutorials/intro.mp4".to_string(),
target: TranscodeTarget::Resolution720p,
segment_index: 12,
})
);
}
#[test]
fn test_parse_original_mp4_not_matched() {
assert!(parse_hls_request("videos/demo.mp4").is_none());
}
#[test]
fn test_parse_invalid_segment_not_matched() {
assert!(parse_hls_request("videos/demo-005.ts").is_none());
assert!(parse_hls_request("videos/demo-1080p-005.ts").is_none());
}
#[test]
fn test_segment_count_exact() {
let duration = 30.0;
let segments = (duration / HLS_SEGMENT_DURATION).ceil() as u32;
assert_eq!(segments, 3);
}
#[test]
fn test_segment_count_partial() {
let duration = 65.0;
let segments = (duration / HLS_SEGMENT_DURATION).ceil() as u32;
assert_eq!(segments, 7);
}
#[test]
fn test_segment_count_short() {
let duration = 5.0;
let segments = (duration / HLS_SEGMENT_DURATION).ceil() as u32;
assert_eq!(segments, 1);
}
#[test]
fn test_should_transcode_larger_source() {
assert!(should_transcode(1080, TranscodeTarget::Resolution720p));
assert!(should_transcode(1080, TranscodeTarget::Resolution480p));
assert!(should_transcode(720, TranscodeTarget::Resolution480p));
}
#[test]
fn test_should_transcode_same_or_smaller() {
assert!(!should_transcode(720, TranscodeTarget::Resolution720p));
assert!(!should_transcode(480, TranscodeTarget::Resolution720p));
assert!(!should_transcode(480, TranscodeTarget::Resolution480p));
assert!(!should_transcode(360, TranscodeTarget::Resolution480p));
}
#[test]
fn test_calculate_output_dimensions_16_9() {
let (w, h) = calculate_output_dimensions(1920, 1080, TranscodeTarget::Resolution720p);
assert_eq!(h, 720);
assert_eq!(w, 1280); }
#[test]
fn test_calculate_output_dimensions_4_3() {
let (w, h) = calculate_output_dimensions(1440, 1080, TranscodeTarget::Resolution720p);
assert_eq!(h, 720);
assert_eq!(w, 960); }
#[test]
fn test_calculate_output_dimensions_even_width() {
let (w, h) = calculate_output_dimensions(1919, 1080, TranscodeTarget::Resolution720p);
assert_eq!(h, 720);
assert_eq!(w % 2, 0); }
#[test]
fn test_transcode_target_properties() {
assert_eq!(TranscodeTarget::Resolution720p.height(), 720);
assert_eq!(TranscodeTarget::Resolution720p.width(), 1280);
assert_eq!(TranscodeTarget::Resolution720p.url_suffix(), "-720p");
assert_eq!(TranscodeTarget::Resolution480p.height(), 480);
assert_eq!(TranscodeTarget::Resolution480p.width(), 854);
assert_eq!(TranscodeTarget::Resolution480p.url_suffix(), "-480p");
}
#[test]
fn test_is_supported_video() {
assert!(is_supported_video("video.mp4"));
assert!(is_supported_video("video.MP4"));
assert!(is_supported_video("video.mov"));
assert!(is_supported_video("video.mkv"));
assert!(is_supported_video("video.webm"));
assert!(!is_supported_video("video.txt"));
assert!(!is_supported_video("video.jpg"));
}
}