From 76f9b0b2e3fe1ae295d010cba3fab990836988ee Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Tue, 23 Jun 2026 00:39:01 +0800 Subject: [PATCH 1/2] feat(media): extract audio track to local file (star export on media card) (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an end-to-end "extract audio" path so users can save a video's soundtrack as a standalone audio file from the media panel. Backend (Rust): - opentake-media: `MediaEngine::extract_audio` + `extract_audio_file` helper drive ffmpeg via the existing `ffmpeg_path()` CLI wrapper. `-y -i -vn` plus codec args picked by output extension: .m4a/.aac → AAC 192k, .mp3 → libmp3lame 192k, .wav → pcm_s16le. - src-tauri/media: new `extract_audio` Tauri command resolves a media id to its `MediaSource::External` absolute path, validates the file exists, then delegates to the engine. Returns the output path. - src-tauri/lib: register `media::extract_audio` in `generate_handler!`. Frontend (React/TS): - api.ts: `extractAudio(mediaId, outPath)` wrapper; rejects outside Tauri (no ffmpeg available). - MediaPanel.tsx: MediaCard gains a star-shaped "Extract Audio" button on the top-left, shown only when hovering a video that carries an audio track. Click opens a native save dialog (m4a/mp3/wav filters), invokes `extract_audio`, and surfaces a transient success/failure feedback message. `stopPropagation`+`preventDefault` keep the click from selecting the card. - i18n dict.ts: 6 new keys (zh-CN + en) for the button title, hint, success, failure, and no-audio messages. Closes #39. --- crates/opentake-media/src/lib.rs | 59 ++++++++++++++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/media.rs | 41 +++++++++++++ web/src/components/media/MediaPanel.tsx | 79 +++++++++++++++++++++++++ web/src/i18n/dict.ts | 11 ++++ web/src/lib/api.ts | 13 ++++ 6 files changed, 204 insertions(+) diff --git a/crates/opentake-media/src/lib.rs b/crates/opentake-media/src/lib.rs index 6d32e8a..6318c07 100644 --- a/crates/opentake-media/src/lib.rs +++ b/crates/opentake-media/src/lib.rs @@ -163,6 +163,65 @@ 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 { + extract_audio_file(input, output).map(|_| output.to_path_buf()) + } +} + +/// Run `ffmpeg -y -i -vn ` 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 codec_args: Vec<&str> = match output.extension().and_then(|e| e.to_str()) { + Some("m4a") | Some("m4r") | Some("aac") => vec!["-c:a", "aac", "-b:a", "192k"], + Some("mp3") => vec!["-c:a", "libmp3lame", "-b:a", "192k"], + Some("wav") => vec!["-c:a", "pcm_s16le"], + Some(ext) => { + return Err(MediaError::Ffmpeg(format!( + "unsupported audio extension: .{ext} (use m4a, mp3, or wav)" + ))); + } + None => { + return Err(MediaError::Ffmpeg( + "output path has no extension (use .m4a, .mp3, or .wav)".into(), + )); + } + }; + + 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)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3492a9b..8eb3876 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -114,6 +114,7 @@ pub fn run() { media::import_folder, media::import_media, media::get_media, + media::extract_audio, render::composite_frame, secret::secret_save, secret::secret_load, diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs index 0260d6f..0551762 100644 --- a/src-tauri/src/media.rs +++ b/src-tauri/src/media.rs @@ -326,6 +326,47 @@ pub fn get_media(core: State<'_, AppCore>) -> MediaListDto { MediaListDto::from_core(&core) } +/// `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). +/// +/// Returns the output path on success. Errors when the asset is unknown, has +/// no resolvable source path (project-relative assets without a bundle base), +/// 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 { + 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 { .. } => { + return Err( + "project-relative assets are not supported for audio extraction yet".into(), + ); + } + }; + if !input.is_file() { + return Err(format!("source file not found: {}", input.display())); + } + let output = PathBuf::from(&out_path); + media + .engine() + .extract_audio(&input, &output) + .map(|p| p.to_string_lossy().into_owned()) + .map_err(|e| e.to_string()) +} + /// Collect importable media files under `root`. Top-level only unless /// `recursive`. Sorted by case-insensitive file name so a folder import mints /// asset ids in a stable order. Hidden entries (dot-prefixed) are skipped, as diff --git a/web/src/components/media/MediaPanel.tsx b/web/src/components/media/MediaPanel.tsx index 6ad8f22..3eed164 100644 --- a/web/src/components/media/MediaPanel.tsx +++ b/web/src/components/media/MediaPanel.tsx @@ -22,6 +22,7 @@ import { FileAudio, Image as ImageIcon, Type as TypeIcon, + Star, } from "lucide-react"; import { Icon } from "../ui/Icon"; import { HoverButton } from "../ui/HoverButton"; @@ -33,6 +34,8 @@ import { formatTimecode } from "../../lib/geometry"; import { assetUrl } from "../../lib/asset"; import { useProjectStore } from "../../store/projectStore"; import { addMediaToTimeline } from "../../store/editActions"; +import { extractAudio } from "../../lib/api"; +import { saveDialog } from "../../lib/dialog"; import type { MediaItem } from "../../lib/types"; /** MIME-ish type used on dataTransfer when dragging a media item to the timeline. */ @@ -376,24 +379,60 @@ function MediaGrid({ items }: { items: MediaItem[] }) { } function MediaCard({ item }: { item: MediaItem }) { + const t = useT(); const fps = useProjectStore((s) => s.timeline.fps); const setPreviewMedia = useEditorUiStore((s) => s.setPreviewMedia); const previewMediaId = useEditorUiStore((s) => s.previewMediaId); const durationFrames = Math.round(item.duration * fps); const selected = previewMediaId === item.id; const thumb = assetUrl(item.path); + const [hovered, setHovered] = useState(false); + const [feedback, setFeedback] = useState(null); const onDragStart = (e: React.DragEvent) => { e.dataTransfer.setData(MEDIA_DND_TYPE, item.id); e.dataTransfer.effectAllowed = "copy"; }; + /** Extract the audio track into a standalone file via ffmpeg. Opens a native + * save dialog (m4a/mp3/wav), then calls the `extract_audio` Tauri command. + * Only shown for video assets that carry audio (Issue #39). */ + const onExtractAudio = async (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + const save = await saveDialog(); + if (!save) return; // non-Tauri / dialog unavailable + const chosen = await save({ + title: t("media.extractAudio"), + defaultPath: `${item.name}.m4a`, + filters: [ + { name: "Audio (M4A)", extensions: ["m4a"] }, + { name: "Audio (MP3)", extensions: ["mp3"] }, + { name: "Audio (WAV)", extensions: ["wav"] }, + ], + }); + if (typeof chosen !== "string") return; // user cancelled + setFeedback(null); + try { + const out = await extractAudio(item.id, chosen); + setFeedback(t("media.extractAudioSuccess", { path: out })); + } catch (err) { + setFeedback(t("media.extractAudioFailed", { error: String(err) })); + } + setTimeout(() => setFeedback(null), 4000); + }; + + // The star/export button only appears for video assets with an audio track. + const canExtractAudio = item.type === "video" && item.hasAudio; + return (
setPreviewMedia(item.id)} onDoubleClick={() => void addMediaToTimeline(item)} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} title={item.name} style={{ display: "flex", flexDirection: "column", gap: 4, cursor: "grab" }} > @@ -454,6 +493,33 @@ function MediaCard({ item }: { item: MediaItem }) { {formatTimecode(durationFrames, fps)} )} + {/* Extract-audio star button (top-right). Shown only for video assets + with audio, on hover. stopPropagation prevents the card's click-to- + preview and drag-start from firing. */} + {canExtractAudio && hovered && ( + + )}
{item.name} + {feedback && ( + + {feedback} + + )} ); } diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 1193462..84f2951 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -57,6 +57,11 @@ const zh: Dict = { "media.importing": "正在导入…", "media.importFailed": "导入失败:{error}", "media.dropToAdd": "松开以添加到时间线", + "media.extractAudio": "提取音频", + "media.extractAudioHint": "提取音频轨到本地文件", + "media.extractAudioSuccess": "音频已保存:{path}", + "media.extractAudioFailed": "提取失败:{error}", + "media.extractAudioNoAudio": "此媒体没有音频轨", // Inspector "inspector.title": "检查器", @@ -199,7 +204,13 @@ const en: Dict = { "media.importing": "Importing…", "media.importFailed": "Import failed: {error}", "media.dropToAdd": "Release to add to the timeline", + "media.extractAudio": "Extract Audio", + "media.extractAudioHint": "Extract the audio track to a local file", + "media.extractAudioSuccess": "Audio saved: {path}", + "media.extractAudioFailed": "Extract failed: {error}", + "media.extractAudioNoAudio": "No audio track in this media", + // Inspector "inspector.title": "Inspector", "inspector.timeline": "Timeline", "inspector.selectedCount": "{count} selected", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d89787e..2252a25 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -133,6 +133,19 @@ export async function getMedia(): Promise { return { items: [], folders: [] }; } +/** + * `extract_audio`: extract the audio track from a media asset into a + * self-contained audio file. `outPath`'s extension picks the codec + * (`.m4a` → AAC, `.mp3` → libmp3lame, `.wav` → PCM s16le). Returns the + * output path on success. Outside Tauri there is no ffmpeg, so this + * rejects with a friendly error. + */ +export async function extractAudio(mediaId: string, outPath: string): Promise { + await ensureTauri(); + if (invokeImpl) return invokeImpl("extract_audio", { mediaId, outPath }); + throw new Error("audio extraction requires the desktop app (ffmpeg)"); +} + // MARK: - Timeline composite preview (#47) // // `composite_frame` renders the timeline at a frame on the GPU (wgpu compositor) From 74af48724bc97e3f1872c9d81c5b3c608dd56b10 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Fri, 26 Jun 2026 01:47:14 +0800 Subject: [PATCH 2/2] fix(media): extract_audio path validation + acceptance tests (#39 review #3 #4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review #3 (缺验收测试): - Extract audio_codec_args() as pure function for unit testing - Add 4 unit tests covering m4a/mp3/wav/unknown extension selection - Add #[ignore] integration test verifying issue #39 acceptance criteria: 1. output file exists after extraction 2. duration matches input (within 0.5s) 3. no video stream in output Auto-skips when ffmpeg/ffprobe unavailable; run with --ignored Review #4 (out_path 无路径边界校验): - Add validate_extract_output() enforcing: - reject null bytes (OS API truncation risk) - require absolute path (save dialog always returns absolute) - extension whitelist (m4a/m4r/aac/mp3/wav) - extract_audio command calls validator before touching manifest/ffmpeg - 5 unit tests covering each rejection path + whitelist acceptance No behavioral change to the extraction logic itself; the codec table and ffmpeg invocation are unchanged. --- crates/opentake-media/src/lib.rs | 191 ++++++++++++++++++++++++++++--- src-tauri/src/media.rs | 102 ++++++++++++++++- 2 files changed, 275 insertions(+), 18 deletions(-) diff --git a/crates/opentake-media/src/lib.rs b/crates/opentake-media/src/lib.rs index 5f8b76c..4a64f05 100644 --- a/crates/opentake-media/src/lib.rs +++ b/crates/opentake-media/src/lib.rs @@ -178,26 +178,38 @@ impl MediaEngine { } } +/// 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> { + 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 -vn ` 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 codec_args: Vec<&str> = match output.extension().and_then(|e| e.to_str()) { - Some("m4a") | Some("m4r") | Some("aac") => vec!["-c:a", "aac", "-b:a", "192k"], - Some("mp3") => vec!["-c:a", "libmp3lame", "-b:a", "192k"], - Some("wav") => vec!["-c:a", "pcm_s16le"], - Some(ext) => { - return Err(MediaError::Ffmpeg(format!( - "unsupported audio extension: .{ext} (use m4a, mp3, or wav)" - ))); - } - None => { - return Err(MediaError::Ffmpeg( - "output path has no extension (use .m4a, .mp3, or .wav)".into(), - )); - } - }; + 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") @@ -264,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::() + .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() + ); + } } diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs index 774c253..47b16c9 100644 --- a/src-tauri/src/media.rs +++ b/src-tauri/src/media.rs @@ -348,14 +348,52 @@ 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 { + 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). /// -/// Returns the output path on success. Errors when the asset is unknown, the -/// source path cannot be resolved or found, or ffmpeg fails (missing binary, +/// 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( @@ -364,6 +402,9 @@ pub fn extract_audio( media_id: String, out_path: String, ) -> Result { + // 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 @@ -380,7 +421,6 @@ pub fn extract_audio( if !input.is_file() { return Err(format!("source file not found: {}", input.display())); } - let output = PathBuf::from(&out_path); media .engine() .extract_audio(&input, &output) @@ -775,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}" + ); + } }