diff --git a/crates/opentake-media/src/lib.rs b/crates/opentake-media/src/lib.rs index a98448c..4a64f05 100644 --- a/crates/opentake-media/src/lib.rs +++ b/crates/opentake-media/src/lib.rs @@ -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 { + 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> { + 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 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)] @@ -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::() + .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/lib.rs b/src-tauri/src/lib.rs index 523e467..590e3e1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs index 62f4b2d..47b16c9 100644 --- a/src-tauri/src/media.rs +++ b/src-tauri/src/media.rs @@ -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 { + 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 { + // 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 @@ -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}" + ); + } } diff --git a/web/src/components/media/MediaPanel.tsx b/web/src/components/media/MediaPanel.tsx index c242ba9..39767aa 100644 --- a/web/src/components/media/MediaPanel.tsx +++ b/web/src/components/media/MediaPanel.tsx @@ -36,6 +36,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"; import { MediaTabBar, MediaSubTabBar } from "./MediaTabBar"; import { useFavoritesStore, useIsFavorite } from "./favorites"; @@ -331,18 +333,53 @@ function MediaCard({ item }: { item: MediaItem }) { const toggleFavorite = useFavoritesStore((s) => s.toggle); // Offline assets shouldn't try to load a (now-missing) thumbnail. const thumb = item.missing ? null : 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); + }; + + // Only local, present video assets with an audio track can be extracted. + const canExtractAudio = item.type === "video" && item.hasAudio && !item.missing; + 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" }} > @@ -475,6 +512,32 @@ function MediaCard({ item }: { item: MediaItem }) { > + {canExtractAudio && hovered && ( + + )}
{item.name} + {feedback && ( + + {feedback} + + )} ); } diff --git a/web/src/components/preview/TimelinePlaybackLayer.tsx b/web/src/components/preview/TimelinePlaybackLayer.tsx index bcf9d86..2bd01e6 100644 --- a/web/src/components/preview/TimelinePlaybackLayer.tsx +++ b/web/src/components/preview/TimelinePlaybackLayer.tsx @@ -96,7 +96,7 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin if (!playing) { // Paused: keep elements in DOM but silence the clock and media. mediaClock.release(); - + for (const el of els.current.values()) el.pause(); return; } diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 7aac2fa..ed3b5b2 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -75,6 +75,11 @@ const zh: Dict = { "media.importing": "正在导入…", "media.importFailed": "导入失败:{error}", "media.dropToAdd": "松开以添加到时间线", + "media.extractAudio": "提取音频", + "media.extractAudioHint": "提取音频轨到本地文件", + "media.extractAudioSuccess": "音频已保存:{path}", + "media.extractAudioFailed": "提取失败:{error}", + "media.extractAudioNoAudio": "此媒体没有音频轨", "media.offline": "媒体离线", "media.relink": "重新链接", @@ -275,9 +280,15 @@ 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", "media.offline": "Media Offline", "media.relink": "Relink", + // 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 1e05822..4c9a713 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -147,6 +147,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)"); +} + /** * Relink an offline asset to a newly chosen file, KEEPING its id so every clip * that references it recovers in place (the fix for "lost media stays red after