Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions src/commands/upload/dry_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub fn run_dry_run(
in_app_path: &Option<String>,
auth_bearer: &Option<String>,
force_upload: bool,
disable_description_generation: bool,
_disable_description_generation: bool,
local_encoding: bool,
) -> Result<(), String> {
let bearer_env = auth_bearer
Expand Down Expand Up @@ -255,14 +255,8 @@ pub fn run_dry_run(
.unwrap_or_default()
.to_string_lossy()
.to_string();
let generate_time_based_media_description = !disable_description_generation;
output::plain(format!(" file: {}", file_name));
output::plain(" cutter_sensitivity: 0.2");
output::plain(format!(
" generate_time_based_media_description: {}",
generate_time_based_media_description
));
output::plain(" override_entity_ids: omitted");
if local_encoding {
output::plain(" generate_proxy: []");
} else {
Expand Down
7 changes: 1 addition & 6 deletions src/commands/upload/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,6 @@ fn run_two_queue_pipeline(
let in_app_path = args.in_app_path.clone();
let user_id = user_id.to_string();
let upload_request_id = upload_request_id.to_string();
let disable_description_generation = args.disable_description_generation;
let block_result = rt.block_on(async move {
// Start render loop inside runtime so tokio::spawn has a current runtime
let render_handle = progress.start_render_loop(progress_handle.clone());
Expand Down Expand Up @@ -938,8 +937,6 @@ fn run_two_queue_pipeline(
None::<tellers_api_client::models::VersionReference>,
);
preproc_req.cutter_sensitivity = Some(0.2);
preproc_req.generate_time_based_media_description =
Some(!disable_description_generation);
preproc_req.generate_proxy = Some(vec![]);
let preproc_tasks = api::process_assets_users_assets_preprocess_post(
&cfg,
Expand Down Expand Up @@ -1168,7 +1165,7 @@ async fn upload_with_per_file_presigned(
cfg: &Configuration,
api_key: &str,
bearer_opt: Option<&str>,
disable_description_generation: bool,
_disable_description_generation: bool,
) -> Result<Vec<UploadedAssetInfo>, String> {
let http = Arc::new(
reqwest::Client::builder()
Expand Down Expand Up @@ -1271,8 +1268,6 @@ async fn upload_with_per_file_presigned(
None::<tellers_api_client::models::VersionReference>,
);
preproc_req.cutter_sensitivity = Some(0.2);
preproc_req.generate_time_based_media_description =
Some(!disable_description_generation);
if let Err(e) = api::process_assets_users_assets_preprocess_post(
&cfg_clone,
preproc_req,
Expand Down
247 changes: 225 additions & 22 deletions src/media/transcode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ use std::process::Command;
use crate::media::metadata::get_ffprobe_json;
use crate::media::video_quality::VideoQuality;

#[derive(Clone, Debug)]
struct VideoSpeedCorrection {
frame_rate: String,
}

/// Returns true if the first video stream is interlaced (field_order is tb, bt, tt, or bb).
fn is_interlaced(path: &PathBuf) -> Result<bool, String> {
let probe = match get_ffprobe_json(path)? {
Expand Down Expand Up @@ -89,6 +94,130 @@ fn probe_video_frames(path: &PathBuf) -> Result<Value, String> {
.map_err(|e| format!("failed to parse ffprobe frame JSON output: {}", e))
}

fn parse_rational(value: Option<&str>) -> Option<f64> {
let value = value?;
let (num, den) = value.split_once('/')?;
let num: f64 = num.parse().ok()?;
let den: f64 = den.parse().ok()?;
if den == 0.0 {
return None;
}
Some(num / den)
}

fn first_video_stream(probe: &Value) -> Option<&Value> {
probe
.get("streams")
.and_then(|s| s.as_array())?
.iter()
.find(|s| s.get("codec_type").and_then(|c| c.as_str()) == Some("video"))
}

fn detect_mxf_half_rate_video(probe: &Value) -> Option<VideoSpeedCorrection> {
let format_name = probe
.get("format")
.and_then(|f| f.get("format_name"))
.and_then(|f| f.as_str())
.unwrap_or("");
if !format_name.split(',').any(|name| name == "mxf") {
return None;
}

let video_stream = first_video_stream(probe)?;
if video_stream.get("codec_name").and_then(|c| c.as_str()) != Some("mpeg2video") {
return None;
}

let r_frame_rate = video_stream.get("r_frame_rate").and_then(|v| v.as_str());
let avg_frame_rate = video_stream.get("avg_frame_rate").and_then(|v| v.as_str());
let r_rate = parse_rational(r_frame_rate)?;
let avg_rate = parse_rational(avg_frame_rate)?;

if avg_rate <= 0.0 || (r_rate * 2.0 - avg_rate).abs() > 0.001 {
return None;
}

avg_frame_rate.map(|frame_rate| VideoSpeedCorrection {
frame_rate: frame_rate.to_string(),
})
}

fn detect_video_speed_correction(path: &PathBuf) -> Result<Option<VideoSpeedCorrection>, String> {
let Some(probe) = get_ffprobe_json(path)? else {
return Ok(None);
};
Ok(detect_mxf_half_rate_video(&probe))
}

fn compute_video_essence_output_path(input: &PathBuf, out_base: &PathBuf) -> PathBuf {
out_base.join(format!(
"{}_video_essence.m2v",
input.file_stem().unwrap().to_string_lossy()
))
}

fn extract_video_essence(input: &PathBuf, output: &PathBuf) -> Result<(), String> {
if let (Ok(in_md), Ok(out_md)) = (std::fs::metadata(input), std::fs::metadata(output)) {
if let (Ok(in_time), Ok(out_time)) = (in_md.modified(), out_md.modified()) {
if out_time >= in_time {
return Ok(());
}
}
}

let command_output = Command::new("ffmpeg")
.args([
"-y",
"-hide_banner",
"-i",
&input.to_string_lossy(),
"-map",
"0:v:0",
"-c:v",
"copy",
"-an",
&output.to_string_lossy(),
])
.output()
.map_err(|e| format!("failed to run ffmpeg video essence extraction: {}", e))?;

if !command_output.status.success() {
let stderr = filtered_ffmpeg_stderr(&command_output.stderr);
let log_suffix = if stderr.is_empty() {
String::new()
} else {
format!("\nffmpeg log:\n{}", stderr)
};
return Err(format!(
"ffmpeg failed extracting video essence: {} -> {}{}",
input.display(),
output.display(),
log_suffix
));
}

Ok(())
}

fn is_ignorable_ffmpeg_log(line: &str) -> bool {
line.contains("mpeg2video @")
&& line.contains("Application provided invalid, non monotonically increasing dts to muxer")
}

fn filtered_ffmpeg_stderr(stderr: &[u8]) -> String {
String::from_utf8_lossy(stderr)
.lines()
.filter(|line| !is_ignorable_ffmpeg_log(line))
.collect::<Vec<_>>()
.join("\n")
}

fn push_ffmpeg_log_line(lines: &mut Vec<String>, line: &str) {
if !is_ignorable_ffmpeg_log(line) {
lines.push(line.to_string());
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -123,6 +252,59 @@ mod tests {

assert!(!probe_has_interlaced_video(&probe));
}

#[test]
fn detects_mxf_mpeg2_half_rate_video() {
let probe = json!({
"format": {"format_name": "mxf"},
"streams": [{
"codec_type": "video",
"codec_name": "mpeg2video",
"r_frame_rate": "25/2",
"avg_frame_rate": "25/1"
}]
});

let correction = detect_mxf_half_rate_video(&probe).unwrap();

assert_eq!(correction.frame_rate, "25/1");
}

#[test]
fn ignores_avid_style_half_avg_rate_video() {
let probe = json!({
"format": {"format_name": "mxf"},
"streams": [{
"codec_type": "video",
"codec_name": "h264",
"r_frame_rate": "25/1",
"avg_frame_rate": "25/2"
}]
});

assert!(detect_mxf_half_rate_video(&probe).is_none());
}

#[test]
fn filters_known_mpeg2_non_monotonic_dts_warning() {
let stderr = b"[mpeg2video @ 0x7fd4d1f04180] Application provided invalid, non monotonically increasing dts to muxer in stream 0: 1708 >= 1681\nreal failure\n";

let filtered = filtered_ffmpeg_stderr(stderr);

assert_eq!(filtered, "real failure");
}

#[test]
fn keeps_unrelated_ffmpeg_errors() {
let stderr = b"[aac @ 0x123] Application provided invalid, non monotonically increasing dts to muxer in stream 1: 2 >= 1\n";

let filtered = filtered_ffmpeg_stderr(stderr);

assert_eq!(
filtered,
"[aac @ 0x123] Application provided invalid, non monotonically increasing dts to muxer in stream 1: 2 >= 1"
);
}
}

/// Parse ffmpeg time string e.g. "00:01:23.45" into seconds.
Expand Down Expand Up @@ -223,22 +405,25 @@ pub fn create_rendition(
) -> Result<PathBuf, String> {
let temp_base = get_temp_rendition_dir()?;
let output = compute_rendition_output_path(input, &definition, &temp_base);

if let (Ok(in_md), Ok(out_md)) = (std::fs::metadata(input), std::fs::metadata(&output)) {
if let (Ok(in_time), Ok(out_time)) = (in_md.modified(), out_md.modified()) {
if out_time >= in_time {
let msg = format!(
"Reusing existing rendition for {} at {} ({})",
input.display(),
output.display(),
definition.to_name()
);
if let Some(f) = info_cb {
f(&msg);
} else if progress_cb.is_none() {
crate::output::info(msg);
let speed_correction = detect_video_speed_correction(input).unwrap_or(None);

if speed_correction.is_none() {
if let (Ok(in_md), Ok(out_md)) = (std::fs::metadata(input), std::fs::metadata(&output)) {
if let (Ok(in_time), Ok(out_time)) = (in_md.modified(), out_md.modified()) {
if out_time >= in_time {
let msg = format!(
"Reusing existing rendition for {} at {} ({})",
input.display(),
output.display(),
definition.to_name()
);
if let Some(f) = info_cb {
f(&msg);
} else if progress_cb.is_none() {
crate::output::info(msg);
}
return Ok(output);
}
return Ok(output);
}
}
}
Expand All @@ -250,9 +435,26 @@ pub fn create_rendition(
}
}

let source_input = if let Some(correction) = &speed_correction {
if let Some(f) = info_cb {
f("Detected MXF half-rate video timing; extracting video essence before downscale");
}
let essence_output = compute_video_essence_output_path(input, &temp_base);
extract_video_essence(input, &essence_output)?;
if let Some(f) = info_cb {
f(&format!(
"Using extracted video essence at {} fps",
correction.frame_rate
));
}
essence_output
} else {
input.clone()
};

let mut cmd = FfmpegCommand::new();
cmd.overwrite()
.input(input.to_string_lossy())
.input(source_input.to_string_lossy())
.codec_video("libx264")
.args(["-pix_fmt", "yuv420p"]);

Expand All @@ -273,6 +475,9 @@ pub fn create_rendition(
if let Some(crf) = definition.crf {
cmd.crf(crf as u32);
}
if let Some(correction) = &speed_correction {
cmd.args(["-r", &correction.frame_rate]);
}

cmd.args(["-movflags", "+faststart"]).codec_audio("aac");
if let Some(abr_kbps) = definition.audio_bitrate {
Expand Down Expand Up @@ -310,10 +515,10 @@ pub fn create_rendition(
}
}
FfmpegEvent::Error(e) => {
stderr_lines.push(e.clone());
push_ffmpeg_log_line(&mut stderr_lines, e);
}
FfmpegEvent::Log(LogLevel::Error, msg) | FfmpegEvent::Log(LogLevel::Fatal, msg) => {
stderr_lines.push(msg.clone());
push_ffmpeg_log_line(&mut stderr_lines, msg);
}
_ => {}
}
Expand Down Expand Up @@ -451,10 +656,10 @@ fn run_ffmpeg_with_progress(
}
}
FfmpegEvent::Error(e) => {
stderr_lines.push(e.clone());
push_ffmpeg_log_line(&mut stderr_lines, e);
}
FfmpegEvent::Log(LogLevel::Error, msg) | FfmpegEvent::Log(LogLevel::Fatal, msg) => {
stderr_lines.push(msg.clone());
push_ffmpeg_log_line(&mut stderr_lines, msg);
}
_ => {}
}
Expand Down Expand Up @@ -583,5 +788,3 @@ pub fn has_video_streams(path: &PathBuf) -> Result<bool, String> {
let output_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(output_str == "video")
}


12 changes: 1 addition & 11 deletions src/tellers_api/openapi.tellers_public_api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2946,6 +2946,7 @@ components:
- image
- placeholder
- group_clip
- master_clip
- aaf
- folder
- project
Expand Down Expand Up @@ -3069,17 +3070,6 @@ components:
type: number
title: Cutter Sensitivity
default: 0.5
generate_time_based_media_description:
type: boolean
title: Generate Time Based Media Description
default: false
override_entity_ids:
anyOf:
- items:
type: string
type: array
- type: 'null'
title: Override Entity Ids
generate_proxy:
items:
type: string
Expand Down
Loading