Skip to content
Closed
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
220 changes: 220 additions & 0 deletions crates/opentake-media/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,77 @@ impl MediaEngine {
pub fn export_pause(&self) -> ExportPause {
self.export_pause.clone()
}

/// Extract the audio track from `input` into `output` as a self-contained
/// audio file. The container/codec is picked from the output extension:
/// `.m4a` → AAC in MP4, `.mp3` → libmp3lame, `.wav` → PCM s16le. Video is
/// dropped (`-vn`). Streams the mux directly (input file → output file),
/// never holding the full audio in memory — suitable for long sources.
///
/// Returns the output path on success. Errors bubble up as `MediaError::Ffmpeg`
/// when ffmpeg is missing, exits non-zero, or the extension is unsupported.
pub fn extract_audio(&self, input: &Path, output: &Path) -> Result<std::path::PathBuf> {
extract_audio_file(input, output).map(|_| output.to_path_buf())
}
}

/// Pick the ffmpeg codec args for an audio output extension (Issue #39).
///
/// Returns `None` for unsupported extensions so the caller can surface a
/// friendly error before spawning ffmpeg. The table matches the save-dialog
/// filters in `MediaPanel.tsx` (`.m4a` / `.mp3` / `.wav`) plus the closely
/// related `.m4r` (ringtone) and `.aac` (raw AAC) containers, all of which
/// the AAC encoder can mux.
///
/// Extracted as a pure function so the codec selection can be unit-tested
/// without ffmpeg on PATH (review #3 — "缺 issue #39 验收测试").
fn audio_codec_args(ext: &str) -> Option<Vec<&'static str>> {
match ext {
"m4a" | "m4r" | "aac" => Some(vec!["-c:a", "aac", "-b:a", "192k"]),
"mp3" => Some(vec!["-c:a", "libmp3lame", "-b:a", "192k"]),
"wav" => Some(vec!["-c:a", "pcm_s16le"]),
_ => None,
}
}

/// Run `ffmpeg -y -i <input> -vn <codec args> <output>` to mux the audio track
/// into a standalone file. Codec is selected by `output`'s extension so the
/// caller just picks a save-path filter in the native dialog and the right
/// encoder falls out. `-y` overwrites (the save dialog already confirmed).
fn extract_audio_file(input: &Path, output: &Path) -> Result<()> {
let ext = output.extension().and_then(|e| e.to_str()).ok_or_else(|| {
MediaError::Ffmpeg("output path has no extension (use .m4a, .mp3, or .wav)".into())
})?;
let codec_args = audio_codec_args(ext).ok_or_else(|| {
MediaError::Ffmpeg(format!(
"unsupported audio extension: .{ext} (use m4a, mp3, or wav)"
))
})?;

let mut cmd = std::process::Command::new(ff::ffmpeg_path());
cmd.arg("-y")
.arg("-i")
.arg(input)
.arg("-vn")
.args(&codec_args)
.arg(output);

let out = cmd
.output()
.map_err(|e| MediaError::Ffmpeg(format!("ffmpeg spawn: {e}")))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(MediaError::Ffmpeg(format!(
"ffmpeg exited {}{}",
out.status,
if stderr.trim().is_empty() {
String::new()
} else {
format!(": {}", stderr.trim())
}
)));
}
Ok(())
}

#[cfg(test)]
Expand Down Expand Up @@ -205,4 +276,153 @@ mod tests {
let _ = PcmFormat::F32;
let _ = VideoCodec::H264;
}

// --- extract_audio codec selection (Issue #39 review #3) ---
//
// `audio_codec_args` is a pure function: no ffmpeg spawn, no filesystem
// access. These tests run on every platform without any binary on PATH.

#[test]
fn audio_codec_args_picks_aac_for_m4a_family() {
for ext in ["m4a", "m4r", "aac"] {
let args = audio_codec_args(ext).unwrap_or_else(|| panic!(".{ext}"));
assert_eq!(args, ["-c:a", "aac", "-b:a", "192k"], "mismatch for .{ext}");
}
}

#[test]
fn audio_codec_args_picks_lame_for_mp3() {
assert_eq!(
audio_codec_args("mp3").unwrap(),
["-c:a", "libmp3lame", "-b:a", "192k"]
);
}

#[test]
fn audio_codec_args_picks_pcm_for_wav() {
assert_eq!(audio_codec_args("wav").unwrap(), ["-c:a", "pcm_s16le"]);
}

#[test]
fn audio_codec_args_rejects_unknown_extensions() {
// Video containers + empty + uppercase (extension matching is
// case-sensitive by design — the save dialog emits lowercase).
for ext in ["mp4", "mov", "", "M4A"] {
assert!(
audio_codec_args(ext).is_none(),
".{ext:?} should not map to a codec"
);
}
}

/// End-to-end verification of Issue #39 acceptance criteria:
/// 1. the output file exists after extraction;
/// 2. its duration matches the input (within 0.5s);
/// 3. it contains no video stream.
///
/// Requires `ffmpeg` + `ffprobe` on PATH; auto-skips when either is
/// unavailable (Windows local dev has neither, CI Linux has both — see
/// `.github/workflows/ci.yml` `Install system deps`). Run explicitly with
/// `cargo test -p opentake-media --ignored extract_audio`.
#[test]
#[ignore = "requires ffmpeg + ffprobe on PATH; run with --ignored"]
fn extract_audio_file_produces_audio_only_output_matching_input_duration() {
use std::process::Command;
// Skip when ffmpeg/ffprobe unavailable.
if Command::new(ff::ffmpeg_path())
.arg("-version")
.output()
.is_err()
{
eprintln!("skipping: ffmpeg unavailable");
return;
}
if Command::new("ffprobe").arg("-version").output().is_err() {
eprintln!("skipping: ffprobe unavailable");
return;
}

let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("src.mp4");
let output = tmp.path().join("out.m4a");

// Generate a 2s fixture: 320x240 black video + 440Hz sine audio.
// `-shortest` trims to the shorter stream so both are exactly 2s.
let gen = Command::new(ff::ffmpeg_path())
.arg("-y")
.args(["-f", "lavfi", "-i", "sine=frequency=440:duration=2"])
.args(["-f", "lavfi", "-i", "color=size=320x240:rate=24:duration=2"])
.args(["-c:a", "aac"])
.args(["-c:v", "libx264"])
.arg("-shortest")
.arg(&input)
.output()
.expect("ffmpeg fixture gen spawn failed");
assert!(
gen.status.success(),
"fixture gen failed: {}",
String::from_utf8_lossy(&gen.stderr)
);

// Run the extraction under test.
extract_audio_file(&input, &output).expect("extract_audio_file failed");

// #1: output exists.
assert!(
output.is_file(),
"output file not created: {}",
output.display()
);

// Helper: probe duration (seconds) of a file via ffprobe.
let probe_duration = |path: &Path| -> f64 {
let out = Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
])
.arg(path)
.output()
.expect("ffprobe spawn failed");
String::from_utf8_lossy(&out.stdout)
.trim()
.parse::<f64>()
.expect("ffprobe returned non-numeric duration")
};

// #2: duration matches input (within 0.5s — muxing overhead can shift
// by a few hundred ms).
let dur_in = probe_duration(&input);
let dur_out = probe_duration(&output);
assert!(
(dur_in - dur_out).abs() < 0.5,
"duration mismatch: in={dur_in} out={dur_out}"
);

// #3: no video stream in output (`-vn` must have dropped it).
let v_streams = Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v",
"-show_entries",
"stream=index",
"-of",
"csv=p=0",
])
.arg(&output)
.output()
.expect("ffprobe spawn failed");
let v_streams = String::from_utf8_lossy(&v_streams.stdout);
assert!(
v_streams.trim().is_empty(),
"output has video stream(s): {}",
v_streams.trim()
);
}
}
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ pub fn run() {
media::import_media,
media::relink_media,
media::get_media,
media::extract_audio,
media::get_waveform,
render::composite_frame,
export::export_video,
Expand Down
136 changes: 136 additions & 0 deletions src-tauri/src/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,86 @@ pub fn get_media(core: State<'_, AppCore>) -> MediaListDto {
MediaListDto::from_core(&core)
}

/// Validate the user-chosen output path for [`extract_audio`] (Issue #39
/// review #4 — "out_path 无后端路径边界校验").
///
/// Enforces a path-safety boundary so an `out_path` arriving from the WebView
/// cannot:
/// - smuggle null bytes (`\0`) which some OS APIs silently truncate, leaving
/// the written file at an unexpected location;
/// - be relative (the native save dialog always returns absolute, but the
/// command is also callable directly via the Tauri API);
/// - use an extension ffmpeg would otherwise fall back on an arbitrary codec
/// for — only `.m4a` / `.m4r` / `.aac` / `.mp3` / `.wav` are allowed,
/// matching the codec table in
/// [`opentake_media::MediaEngine::extract_audio`] and the save-dialog
/// filters in `MediaPanel.tsx`.
///
/// Returns the parsed absolute [`PathBuf`] on success.
fn validate_extract_output(out_path: &str) -> Result<PathBuf, String> {
if out_path.contains('\0') {
return Err("output path contains null byte".into());
}
let output = PathBuf::from(out_path);
if !output.is_absolute() {
return Err(format!(
"output path must be absolute: {}",
output.display()
));
}
match output.extension().and_then(|e| e.to_str()) {
Some("m4a") | Some("m4r") | Some("aac") | Some("mp3") | Some("wav") => Ok(output),
Some(ext) => Err(format!(
"unsupported audio extension: .{ext} (use .m4a, .mp3, or .wav)"
)),
None => Err("output path has no extension (use .m4a, .mp3, or .wav)".into()),
}
}

/// `extract_audio`: extract the audio track from a media asset into a
/// self-contained audio file (`.m4a` / `.mp3` / `.wav`). The output path is
/// chosen by the caller via a native save dialog; the codec falls out of the
/// extension. Used by the media panel's per-card "extract audio" action
/// (Issue #39).
///
/// The `out_path` is first run through [`validate_extract_output`] to enforce
/// path-safety boundaries (review #4). Returns the output path on success.
/// Errors when the asset is unknown, the source path cannot be resolved or
/// found, the output path is invalid, or ffmpeg fails (missing binary,
/// non-zero exit, unsupported extension).
#[tauri::command]
pub fn extract_audio(
core: State<'_, AppCore>,
media: State<'_, MediaState>,
media_id: String,
out_path: String,
) -> Result<String, String> {
// Path boundary check first (review #4): fail fast on a bad output path
// before touching the manifest or spawning ffmpeg.
let output = validate_extract_output(&out_path)?;
let manifest = core.media();
let entry = manifest
.entries
.iter()
.find(|e| e.id == media_id)
.ok_or_else(|| format!("unknown media id: {media_id}"))?;
let input = match &entry.source {
MediaSource::External { absolute_path } => PathBuf::from(absolute_path),
MediaSource::Project { relative_path } => match core.project_dir() {
Some(base) => base.join(relative_path),
None => return Err("project not saved; cannot resolve media path".into()),
},
};
if !input.is_file() {
return Err(format!("source file not found: {}", input.display()));
}
media
.engine()
.extract_audio(&input, &output)
.map(|p| p.to_string_lossy().into_owned())
.map_err(|e| e.to_string())
}

/// `relink_media`: point a missing/offline asset at a newly chosen file, KEEPING
/// the same asset id so every clip that references it recovers in place. This is
/// the fix for "lost media stays red after re-selecting the path": the old flow
Expand Down Expand Up @@ -735,4 +815,60 @@ mod tests {
let probe = probe_media(&engine_for(tmp.path()), &f);
assert!(core.relink_media_file("nope", &f, &probe).is_err());
}

// --- extract_audio output-path validation (Issue #39 review #4) ---
//
// The command is callable from the WebView with an arbitrary string; these
// tests lock down the boundary that `validate_extract_output` enforces
// before any ffmpeg work begins. They run without ffmpeg on PATH.

#[test]
fn validate_extract_output_accepts_whitelisted_extensions() {
// All five extensions accepted by the codec table + the native save
// dialog filters should parse to an absolute PathBuf.
for ext in ["m4a", "m4r", "aac", "mp3", "wav"] {
let p = validate_extract_output(&format!("/tmp/out.{ext}"))
.unwrap_or_else(|e| panic!(".{ext}: {e}"));
assert_eq!(p.extension().unwrap().to_str().unwrap(), ext);
assert!(p.is_absolute());
}
}

#[test]
fn validate_extract_output_rejects_relative_path() {
let err = validate_extract_output("out.m4a").unwrap_err();
assert!(
err.contains("absolute"),
"relative path must be rejected: got {err}"
);
}

#[test]
fn validate_extract_output_rejects_null_byte() {
// A null byte would be silently truncated by some OS path APIs,
// writing the file at an unexpected location.
let err = validate_extract_output("/tmp/out\0.m4a").unwrap_err();
assert!(
err.contains("null"),
"null byte must be rejected: got {err}"
);
}

#[test]
fn validate_extract_output_rejects_unknown_extension() {
let err = validate_extract_output("/tmp/out.mp4").unwrap_err();
assert!(
err.contains("unsupported audio extension"),
"video extension must be rejected: got {err}"
);
}

#[test]
fn validate_extract_output_rejects_missing_extension() {
let err = validate_extract_output("/tmp/out").unwrap_err();
assert!(
err.contains("no extension"),
"extensionless path must be rejected: got {err}"
);
}
}
Loading
Loading