From 31dca34bddf50b6ddf915b3c3e26b0bfa95ace68 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 2 Jul 2026 14:42:10 +0800 Subject: [PATCH] feat(media): widen import formats + skipped-file toast + eager poster on import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three import-coverage fixes vs upstream (MediaTab / MediaAsset): - Whitelist widened to what the ffmpeg backend actually decodes (upstream accepts anything AVFoundation recognizes, MediaTab.swift:754-756): video 3->14 exts (+mkv/webm/avi/mts/m2ts/mpg/mpeg/3gp/wmv/flv/ts), audio 4->11 (+flac/ogg/opus/aiff/aif/wma/caf), image 6->9 (+bmp/gif/avif). json/lottie still excluded (separate feature). Deliberate cross-platform divergence from upstream's UTType filter, documented. Dialog filters in mediaActions match. - No more silent drops: MediaListDto gains `skipped: Vec` (camelCase, default-empty, only import populates it); import_media/import_folder collect unsupported-type file names; frontend toasts "已跳过 N 个不支持的文件" (upstream mediaPanelToast, EditorViewModel+MediaLibrary.swift:65-68) — closes the "import did nothing" complaint class. - Eager grid poster at import (upstream loadMetadata 320px poster, MediaAsset.swift:127-160): proven gap — import_one never generated a poster, the DTO only read an ALREADY-cached file, so fresh imports showed placeholders until the panel's lazy queue got to them. warm_import_poster now best-effort disk-caches the same 120x68 grid poster at import; failures swallowed (placeholder as before, never an import error). Removed collect_media_files/collect_into (this change deleted their only production caller; replaced by list_dir/list_top_level with equivalent tests; zero remaining references). Gates: fmt/clippy -D warnings clean; cargo test --workspace 1376; pnpm build clean; pnpm test 330. --- crates/opentake-core/src/session.rs | 92 +++++++++- src-tauri/src/media.rs | 260 ++++++++++++++++++++-------- web/src/i18n/dict.ts | 2 + web/src/lib/types.ts | 6 + web/src/store/mediaActions.ts | 33 +++- 5 files changed, 307 insertions(+), 86 deletions(-) diff --git a/crates/opentake-core/src/session.rs b/crates/opentake-core/src/session.rs index ebf2858..6488603 100644 --- a/crates/opentake-core/src/session.rs +++ b/crates/opentake-core/src/session.rs @@ -65,14 +65,28 @@ pub struct ProbedMedia { } /// File extensions the importer accepts, grouped by the [`ClipType`] they map to. -/// Mirrors upstream `ClipType(fileExtension:)` minus the Lottie special-case -/// (Lottie needs a content sniff the bare extension can't provide, so JSON files -/// are not auto-imported here). -pub const SUPPORTED_VIDEO_EXTENSIONS: [&str; 3] = ["mov", "mp4", "m4v"]; +/// +/// Upstream's picker (`MediaTab.swift:754` — `allowedContentTypes = [.movie, +/// .image, .audio, .json]`) surfaces *anything* AVFoundation recognizes for those +/// UTTypes, far more than upstream's own bare-extension `ClipType(fileExtension:)` +/// list. OpenTake's importer routes every decode through the system `ffmpeg`, +/// which handles a much wider set of containers/codecs cross-platform, so the +/// white-list is widened to the formats ffmpeg reads well rather than mirroring +/// upstream's narrow macOS-native list. The Lottie/JSON special-case is still +/// excluded (it needs a content sniff the bare extension can't provide, so JSON +/// files are not auto-imported here). +pub const SUPPORTED_VIDEO_EXTENSIONS: [&str; 14] = [ + "mov", "mp4", "m4v", "mkv", "webm", "avi", "mts", "m2ts", "mpg", "mpeg", "3gp", "wmv", "flv", + "ts", +]; /// Accepted audio extensions. -pub const SUPPORTED_AUDIO_EXTENSIONS: [&str; 4] = ["mp3", "wav", "aac", "m4a"]; +pub const SUPPORTED_AUDIO_EXTENSIONS: [&str; 11] = [ + "mp3", "wav", "aac", "m4a", "flac", "ogg", "opus", "aiff", "aif", "wma", "caf", +]; /// Accepted image extensions. -pub const SUPPORTED_IMAGE_EXTENSIONS: [&str; 6] = ["png", "jpg", "jpeg", "tiff", "heic", "webp"]; +pub const SUPPORTED_IMAGE_EXTENSIONS: [&str; 9] = [ + "png", "jpg", "jpeg", "tiff", "heic", "webp", "bmp", "gif", "avif", +]; /// The [`ClipType`] for `path` if its (lowercased) extension is on the import /// white-list, else `None`. JSON/Lottie are intentionally excluded (see @@ -506,6 +520,72 @@ mod tests { assert_eq!(importable_clip_type(Path::new("/x/noext")), None); } + #[test] + fn importable_clip_type_maps_every_whitelisted_extension() { + // Each list must map to exactly its ClipType, case-insensitively, so a + // new extension can never silently fall through to `None`. + for ext in SUPPORTED_VIDEO_EXTENSIONS { + let p = format!("/x/clip.{ext}"); + assert_eq!( + importable_clip_type(Path::new(&p)), + Some(ClipType::Video), + "video ext .{ext} should import as Video" + ); + // Same extension upper-cased still maps (extension is lowercased). + let up = format!("/x/clip.{}", ext.to_ascii_uppercase()); + assert_eq!(importable_clip_type(Path::new(&up)), Some(ClipType::Video)); + } + for ext in SUPPORTED_AUDIO_EXTENSIONS { + let p = format!("/x/song.{ext}"); + assert_eq!( + importable_clip_type(Path::new(&p)), + Some(ClipType::Audio), + "audio ext .{ext} should import as Audio" + ); + } + for ext in SUPPORTED_IMAGE_EXTENSIONS { + let p = format!("/x/pic.{ext}"); + assert_eq!( + importable_clip_type(Path::new(&p)), + Some(ClipType::Image), + "image ext .{ext} should import as Image" + ); + } + } + + #[test] + fn importable_clip_type_covers_newly_added_extensions() { + // Spot-check a representative newcomer from each widened list plus junk. + assert_eq!( + importable_clip_type(Path::new("/x/a.mkv")), + Some(ClipType::Video) + ); + assert_eq!( + importable_clip_type(Path::new("/x/a.webm")), + Some(ClipType::Video) + ); + assert_eq!( + importable_clip_type(Path::new("/x/s.flac")), + Some(ClipType::Audio) + ); + assert_eq!( + importable_clip_type(Path::new("/x/s.opus")), + Some(ClipType::Audio) + ); + assert_eq!( + importable_clip_type(Path::new("/x/p.gif")), + Some(ClipType::Image) + ); + assert_eq!( + importable_clip_type(Path::new("/x/p.avif")), + Some(ClipType::Image) + ); + // Junk / documents still rejected. + assert_eq!(importable_clip_type(Path::new("/x/a.pdf")), None); + assert_eq!(importable_clip_type(Path::new("/x/a.exe")), None); + assert_eq!(importable_clip_type(Path::new("/x/a.doc")), None); + } + #[test] fn import_video_builds_external_entry_with_probe_metadata() { let mut s = EditorSession::new_project(); diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs index 62e6074..e0ed53a 100644 --- a/src-tauri/src/media.rs +++ b/src-tauri/src/media.rs @@ -175,11 +175,29 @@ pub struct MediaListDto { pub items: Vec, /// All library folders (flat list; nest via `parentFolderId`). pub folders: Vec, + /// File names that were dropped during this import because their type is not + /// importable (mirrors upstream `addMediaAsset` → `mediaPanelToast`). Always + /// empty for pure listing/relink; only import commands populate it so the + /// front end can toast "skipped N unsupported files" instead of dropping them + /// silently. Serialized as `skipped`. + #[serde(default)] + pub skipped: Vec, } impl MediaListDto { - /// Build the list from the core's current manifest snapshot. + /// Build the list from the core's current manifest snapshot, with no skipped + /// files (listing / relink / non-import surfaces). fn from_core(core: &AppCore, cache_root: Option<&Path>) -> Self { + Self::from_core_with_skipped(core, cache_root, Vec::new()) + } + + /// Build the list from the core's current manifest snapshot, carrying the + /// names of files an import skipped as unsupported. + fn from_core_with_skipped( + core: &AppCore, + cache_root: Option<&Path>, + skipped: Vec, + ) -> Self { let manifest = core.media(); let project_dir = core.project_dir(); MediaListDto { @@ -197,6 +215,7 @@ impl MediaListDto { parent_folder_id: f.parent_folder_id.clone(), }) .collect(), + skipped, } } } @@ -544,16 +563,55 @@ fn display_name(path: &Path) -> String { .unwrap_or_default() } +/// The full file name (with extension) for a skipped-file report — what the user +/// sees in a picker (mirrors upstream `url.lastPathComponent` in the toast). +fn display_file_name(path: &Path) -> String { + path.file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default() +} + /// Import one file into the core, probing it first. Returns the created entry, or /// `None` when the extension is not importable (the file is skipped, not an /// error — matches upstream's per-file tolerance during folder/batch import). +/// +/// On a successful import the grid poster is warmed best-effort (Item 3): upstream +/// `MediaAsset.loadMetadata` eagerly generates a 320px poster in +/// `finalizeImportedAsset` so the panel shows a real frame immediately. Here the +/// same small (120×68) poster the media grid would otherwise decode lazily on +/// first render is generated now and disk-cached, so the freshly-returned +/// [`MediaItemDto`] already carries a `thumbnail` path. A decode failure is +/// swallowed (the card falls back to a type placeholder, exactly as today) and +/// never turns an import into an error. fn import_one(core: &AppCore, engine: &MediaEngine, path: &Path) -> Option { importable_clip_type(path)?; let probe = probe_media(engine, path); // `import_media_file` re-validates the extension; the type check above only // lets us skip probing unsupported files. - core.import_media_file(path, display_name(path), &probe) - .ok() + let entry = core + .import_media_file(path, display_name(path), &probe) + .ok()?; + warm_import_poster(engine, &entry, path); + Some(entry) +} + +/// Best-effort eager poster generation for a freshly imported asset (Item 3). +/// Decodes and disk-caches the small grid poster the media panel reads via +/// [`cached_thumbnail_path_for_entry`], so the first grid paint shows a real +/// frame instead of a placeholder — the port of upstream's eager +/// `AVAssetImageGenerator` at import. Only video/image assets have a frame; audio +/// and anything else are no-ops. Every failure path is intentionally ignored: a +/// warm poster is a nicety, never a precondition for import. +fn warm_import_poster(engine: &MediaEngine, entry: &MediaManifestEntry, path: &Path) { + if !matches!(entry.kind, ClipType::Video | ClipType::Image) { + return; + } + if !path.is_file() { + return; + } + // Reuse the exact single-poster path the lazy grid request produces, so the + // cache the panel later reads is already populated. Ignore errors. + let _ = generate_thumbnail_for_entry(engine, entry, path, None, None, false); } /// `import_folder`: bring a local directory into the library. @@ -578,25 +636,41 @@ pub fn import_folder( } let engine = media.engine(); + let mut skipped = Vec::new(); if recursive.unwrap_or(false) { - mirror_dir(&core, engine, &root, None); + mirror_dir(&core, engine, &root, None, &mut skipped); } else { - for file in &collect_media_files(&root, false) { + let (files, skipped_files) = list_top_level(&root); + for file in &files { let _ = import_one(&core, engine, file); } + skipped = skipped_files; } - Ok(MediaListDto::from_core(&core, Some(engine.cache_root()))) + Ok(MediaListDto::from_core_with_skipped( + &core, + Some(engine.cache_root()), + skipped, + )) } /// Recursively mirror `dir` into the library: create a folder for `dir` (nested /// under `parent_folder_id`), import its direct media files into that folder, and -/// recurse into subdirectories. Hidden entries (dot-prefixed) are skipped. -fn mirror_dir(core: &AppCore, engine: &MediaEngine, dir: &Path, parent_folder_id: Option) { +/// recurse into subdirectories. Hidden entries (dot-prefixed) are skipped. Names +/// of non-importable visible files are appended to `skipped` so the caller can +/// toast them. +fn mirror_dir( + core: &AppCore, + engine: &MediaEngine, + dir: &Path, + parent_folder_id: Option, + skipped: &mut Vec, +) { let folder_id = create_folder(core, &dir_name(dir), parent_folder_id); - // Partition this directory's visible entries into media files + subdirs, - // both in case-insensitive name order. - let (files, subdirs) = list_dir(dir); + // Partition this directory's visible entries into media files + subdirs + // (both case-insensitive name order) plus the names of unsupported files. + let (files, subdirs, mut dir_skipped) = list_dir(dir); + skipped.append(&mut dir_skipped); let mut imported_ids = Vec::new(); for file in &files { @@ -614,7 +688,7 @@ fn mirror_dir(core: &AppCore, engine: &MediaEngine, dir: &Path, parent_folder_id } for sub in subdirs { - mirror_dir(core, engine, &sub, folder_id.clone()); + mirror_dir(core, engine, &sub, folder_id.clone(), skipped); } } @@ -637,13 +711,16 @@ fn dir_name(dir: &Path) -> String { .unwrap_or_else(|| "folder".to_string()) } -/// One directory's visible media files + subdirectories, each sorted by -/// case-insensitive name (skipping dot-prefixed entries). -fn list_dir(dir: &Path) -> (Vec, Vec) { +/// One directory's visible media files + subdirectories (each sorted by +/// case-insensitive name), plus the names of visible non-importable files. +/// Dot-prefixed (hidden) entries are ignored entirely — an unsupported *type* is +/// a skip the user should hear about; a hidden dotfile is not. +fn list_dir(dir: &Path) -> (Vec, Vec, Vec) { let mut files = Vec::new(); let mut subdirs = Vec::new(); + let mut skipped = Vec::new(); let Ok(entries) = std::fs::read_dir(dir) else { - return (files, subdirs); + return (files, subdirs, skipped); }; for entry in entries.flatten() { let path = entry.path(); @@ -659,6 +736,8 @@ fn list_dir(dir: &Path) -> (Vec, Vec) { subdirs.push(path); } else if importable_clip_type(&path).is_some() { files.push(path); + } else { + skipped.push(display_file_name(&path)); } } let by_name = |a: &PathBuf, b: &PathBuf| { @@ -668,12 +747,23 @@ fn list_dir(dir: &Path) -> (Vec, Vec) { }; files.sort_by(by_name); subdirs.sort_by(by_name); - (files, subdirs) + skipped.sort_by_key(|s| s.to_lowercase()); + (files, subdirs, skipped) +} + +/// The top-level importable media files + the names of unsupported files in +/// `dir`, for a flat (non-recursive) folder import. Subdirectories are ignored +/// (as before); their contents are neither imported nor reported skipped. +fn list_top_level(dir: &Path) -> (Vec, Vec) { + let (files, _subdirs, skipped) = list_dir(dir); + (files, skipped) } /// `import_media`: import an explicit list of file paths, returning the updated -/// catalog. Unsupported or unreadable paths are skipped (not fatal); the -/// returned list reflects whatever imported successfully. +/// catalog. Unsupported or unreadable paths are skipped (not fatal); the returned +/// list reflects whatever imported successfully and carries the names of skipped +/// unsupported files in `skipped` so the front end can toast them (upstream +/// `mediaPanelToast`) instead of dropping them silently. #[tauri::command] pub fn import_media( core: State<'_, AppCore>, @@ -681,13 +771,27 @@ pub fn import_media( paths: Vec, ) -> Result { let engine = media.engine(); + let mut skipped = Vec::new(); for p in &paths { let path = PathBuf::from(p); - if path.is_file() { - let _ = import_one(&core, engine, &path); + if !path.is_file() { + continue; } + // Only an unsupported *type* is a user-visible "skip"; a supported file + // that fails to import (unreadable etc.) is not reported here (matches the + // pre-existing best-effort behavior and upstream, which only toasts the + // unsupported-type case). + if importable_clip_type(&path).is_none() { + skipped.push(display_file_name(&path)); + continue; + } + let _ = import_one(&core, engine, &path); } - Ok(MediaListDto::from_core(&core, Some(engine.cache_root()))) + Ok(MediaListDto::from_core_with_skipped( + &core, + Some(engine.cache_root()), + skipped, + )) } /// `get_media`: the current media catalog for the panel. Infallible. @@ -933,45 +1037,6 @@ pub fn preload_media( Ok(()) } -/// 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 -/// upstream does (`.skipsHiddenFiles`). -fn collect_media_files(root: &Path, recursive: bool) -> Vec { - let mut out = Vec::new(); - collect_into(root, recursive, &mut out); - out.sort_by(|a, b| { - let an = a.file_name().map(|s| s.to_string_lossy().to_lowercase()); - let bn = b.file_name().map(|s| s.to_string_lossy().to_lowercase()); - an.cmp(&bn) - }); - out -} - -fn collect_into(dir: &Path, recursive: bool, out: &mut Vec) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - let is_hidden = path - .file_name() - .and_then(|s| s.to_str()) - .map(|s| s.starts_with('.')) - .unwrap_or(false); - if is_hidden { - continue; - } - if path.is_dir() { - if recursive { - collect_into(&path, recursive, out); - } - } else if importable_clip_type(&path).is_some() { - out.push(path); - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -1142,7 +1207,8 @@ mod tests { let core = AppCore::new(); let engine = engine_for(tmp.path()); - mirror_dir(&core, &engine, &root, None); + let mut skipped = Vec::new(); + mirror_dir(&core, &engine, &root, None, &mut skipped); let m = core.media(); // Folders: Trip (root) + Day1 + Empty, nested under Trip. @@ -1160,6 +1226,8 @@ mod tests { let b = m.entries.iter().find(|e| e.name == "b").unwrap(); assert_eq!(a.folder_id.as_deref(), Some(trip.id.as_str())); assert_eq!(b.folder_id.as_deref(), Some(day1f.id.as_str())); + // The unsupported note.txt is reported skipped, not dropped silently. + assert_eq!(skipped, vec!["note.txt"]); } #[test] @@ -1170,7 +1238,8 @@ mod tests { touch(&root.join("x.png")); let core = AppCore::new(); let engine = engine_for(tmp.path()); - mirror_dir(&core, &engine, &root, None); + let mut skipped = Vec::new(); + mirror_dir(&core, &engine, &root, None, &mut skipped); let dto = MediaListDto::from_core(&core, None); assert_eq!(dto.folders.len(), 1); @@ -1189,39 +1258,46 @@ mod tests { } #[test] - fn collect_top_level_only_skips_subdirs_and_hidden_and_unsupported() { + fn list_top_level_keeps_media_reports_unsupported_and_ignores_subdirs_and_hidden() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); touch(&root.join("a.mp4")); touch(&root.join("b.png")); - touch(&root.join("c.txt")); // unsupported - touch(&root.join(".hidden.mp4")); // hidden + touch(&root.join("c.txt")); // unsupported → reported skipped + touch(&root.join("readme.md")); // unsupported → reported skipped + touch(&root.join(".hidden.mp4")); // hidden → ignored entirely (not skipped) fs::create_dir(root.join("sub")).unwrap(); - touch(&root.join("sub").join("d.mov")); + touch(&root.join("sub").join("d.mov")); // subdir contents ignored in flat mode - let files = collect_media_files(root, false); + let (files, skipped) = list_top_level(root); let names: Vec = files .iter() .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) .collect(); assert_eq!(names, vec!["a.mp4", "b.png"]); + // Unsupported top-level files are reported (sorted, case-insensitive); + // the hidden dotfile and the subdir file are NOT reported. + assert_eq!(skipped, vec!["c.txt", "readme.md"]); } #[test] - fn collect_recursive_includes_subdirs_sorted() { + fn list_dir_partitions_files_subdirs_and_skipped_sorted() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); touch(&root.join("z.mp4")); + touch(&root.join("A.mov")); + touch(&root.join("junk.bin")); // unsupported fs::create_dir(root.join("sub")).unwrap(); - touch(&root.join("sub").join("a.mov")); - let files = collect_media_files(root, true); - let names: Vec = files + let (files, subdirs, skipped) = list_dir(root); + let fnames: Vec = files .iter() .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) .collect(); - // Sorted case-insensitively by file name: a.mov before z.mp4. - assert_eq!(names, vec!["a.mov", "z.mp4"]); + // Files sorted case-insensitively: A.mov before z.mp4. + assert_eq!(fnames, vec!["A.mov", "z.mp4"]); + assert_eq!(subdirs.len(), 1); + assert_eq!(skipped, vec!["junk.bin"]); } #[test] @@ -1251,6 +1327,42 @@ mod tests { assert_eq!(list.items[0].path.as_deref(), Some(good.to_str().unwrap())); } + #[test] + fn media_list_dto_serializes_skipped_camel_case() { + // Listing surfaces carry an empty `skipped`; the field name stays + // `skipped` in JSON (single word, so camelCase == snake_case here) and is + // always present so the front end can read it unconditionally. + let empty = MediaListDto { + items: vec![], + folders: vec![], + skipped: vec![], + }; + let json = serde_json::to_string(&empty).unwrap(); + assert!(json.contains("\"skipped\":[]")); + + let with_skips = MediaListDto { + items: vec![], + folders: vec![], + skipped: vec!["a.txt".into(), "b.pdf".into()], + }; + let json = serde_json::to_string(&with_skips).unwrap(); + assert!(json.contains("\"skipped\":[\"a.txt\",\"b.pdf\"]")); + } + + #[test] + fn from_core_default_skipped_is_empty_and_with_skipped_carries_names() { + let core = AppCore::new(); + // Non-import surfaces report no skips. + assert!(MediaListDto::from_core(&core, None).skipped.is_empty()); + // Import surfaces thread the skipped file names through unchanged. + let dto = MediaListDto::from_core_with_skipped( + &core, + None, + vec!["note.txt".into(), "archive.zip".into()], + ); + assert_eq!(dto.skipped, vec!["note.txt", "archive.zip"]); + } + #[test] fn get_media_reflects_imported_items() { let tmp = tempfile::tempdir().unwrap(); diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index bd8274b..472cd5a 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -131,6 +131,7 @@ const zh: Dict = { "media.importHint": "导入媒体 (⌘I)", "media.importFolder": "导入文件夹", "media.importFiles": "导入文件", + "media.importSkipped": "已跳过 {count} 个不支持的文件", "media.generate": "生成", "media.search": "搜索", "media.viewMode": "视图模式", @@ -567,6 +568,7 @@ const en: Dict = { "media.importHint": "Import Media (⌘I)", "media.importFolder": "Import Folder", "media.importFiles": "Import Files", + "media.importSkipped": "Skipped {count} unsupported file(s)", "media.generate": "Generate", "media.search": "Search", "media.viewMode": "View mode", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index e38bd98..39aea0c 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -408,6 +408,12 @@ export interface MediaFolder { export interface MediaList { items: MediaItem[]; folders: MediaFolder[]; + /** File names dropped during the import that produced this list because their + * type is not importable. Empty for plain listing / relink; only `import_*` + * populates it so the panel can toast the skips (mirrors upstream + * `mediaPanelToast`) instead of dropping them silently. Optional because the + * browser-fallback catalogs omit it. */ + skipped?: string[]; } // MARK: - BYOK secret store (mirror of src-tauri SecretStatus) diff --git a/web/src/store/mediaActions.ts b/web/src/store/mediaActions.ts index aeb3114..f592a49 100644 --- a/web/src/store/mediaActions.ts +++ b/web/src/store/mediaActions.ts @@ -13,12 +13,22 @@ import * as api from "../lib/api"; import { useMediaStore, refreshMedia } from "./mediaStore"; import { useSettingsStore } from "./settingsStore"; +import { useEditorUiStore } from "./uiStore"; import { openDialog } from "../lib/dialog"; +import { t } from "../i18n"; +import type { MediaList } from "../lib/types"; -/** Extensions the Rust importer accepts (mirrors `session.rs` white-lists). */ -const VIDEO_EXTS = ["mov", "mp4", "m4v"]; -const AUDIO_EXTS = ["mp3", "wav", "aac", "m4a"]; -const IMAGE_EXTS = ["png", "jpg", "jpeg", "tiff", "heic", "webp"]; +/** Extensions the Rust importer accepts (mirrors the `session.rs` white-lists). + * These populate the native file-picker filter so the dialog surfaces the same + * formats the backend can decode; keep in sync with + * `crates/opentake-core/src/session.rs`. */ +const VIDEO_EXTS = [ + "mov", "mp4", "m4v", "mkv", "webm", "avi", "mts", "m2ts", "mpg", "mpeg", "3gp", "wmv", "flv", "ts", +]; +const AUDIO_EXTS = [ + "mp3", "wav", "aac", "m4a", "flac", "ogg", "opus", "aiff", "aif", "wma", "caf", +]; +const IMAGE_EXTS = ["png", "jpg", "jpeg", "tiff", "heic", "webp", "bmp", "gif", "avif"]; function getErrorMessage(error: unknown): string { if (typeof error === "string") return error; @@ -26,6 +36,15 @@ function getErrorMessage(error: unknown): string { return String(error); } +/** Toast the count of files an import skipped as unsupported, if any (mirrors + * upstream `mediaPanelToast`). A no-op when nothing was skipped so a clean + * import stays quiet. */ +function reportSkipped(list: MediaList): void { + const skipped = list.skipped ?? []; + if (skipped.length === 0) return; + useEditorUiStore.getState().pushToast(t("media.importSkipped", { count: skipped.length })); +} + /** Pick a folder and import every supported file inside it. */ export async function importFolderViaDialog(): Promise { const open = await openDialog(); @@ -40,8 +59,9 @@ export async function importFolderViaDialog(): Promise { }); if (typeof selected !== "string") return; // cancelled store.setImporting(true); - await api.importFolder(selected, true); + const list = await api.importFolder(selected, true); await refreshMedia(); + reportSkipped(list); } catch (error: unknown) { store.setError(getErrorMessage(error)); } finally { @@ -95,8 +115,9 @@ export async function importFilesViaDialog(): Promise { const paths = Array.isArray(selected) ? selected : selected ? [selected] : []; if (paths.length === 0) return; // cancelled store.setImporting(true); - await api.importMedia(paths); + const list = await api.importMedia(paths); await refreshMedia(); + reportSkipped(list); } catch (error: unknown) { store.setError(getErrorMessage(error)); } finally {