From a34bc7f038a23e6cd4e2ee884d1a09f03ef26a8a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 11:08:58 -0400 Subject: [PATCH] =?UTF-8?q?M3.11:=20remaining=20Premiere=20menu=20items=20?= =?UTF-8?q?=E2=80=94=20Scene=20Edit=20Detection,=20Normalize=20Mix=20Track?= =?UTF-8?q?,=20Simplify=20Sequence,=20Find=20/=20search=20bins,=20Automate?= =?UTF-8?q?=20to=20Sequence,=20Project=20Settings,=20ALE=20export=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scene Edit Detection: pure-Rust cut detection (render::scene: luma grid + RGB histogram difference, adaptive threshold with sensitivity, min shot length), background job with progress/cancel; applies cuts (linked audio too), clip markers and subclips in one undo step. - Sequence: Normalize Mix Track…, Simplify Sequence…, Transcribe Sequence…, Captions ▸ Show Active Caption Tracks Only, Apply Default Transitions to Selection works on selected clips; Sequence menu in Premiere's order. - Markers: Add Flash Cue Marker… (MarkerKind::FlashCue). - File: New ▸ Search Bin (live saved queries), Close, Close All (Other) Projects, Save as Template… (+ new project from template), Import from Media Browser, Get Media File Properties for ▸ File…/Selection…, Project Settings ▸ General…/Scratch Disks… (scratch disks drive previews, proxies, auto-save), Export ▸ Selection as FilmCraft Project…, Export ▸ Avid Log Exchange… - Edit: Find… / Find Next (project columns/operators/all-any, timeline clips and markers), Edit Original (OS opener host hook). - Clip: Edit Offline…, Source Settings…, Update Metadata… (XMP sidecars), Generate Audio Waveform, Restore Captions from Source Clip (disabled: no embedded caption reader), Automate to Sequence… - View ▸ Dynamic Audio Waveforms; Help ▸ Reveal Log Files…, System Compatibility Report… (with the wgpu adapter). - Dialogs for all of the above (panels::menu_dialogs, ui.set menuDialog). - Project schema v11 (no-op v10_to_v11, fixture, docs row). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- apps/filmcraft/src/main.rs | 28 + crates/engine/src/autosave.rs | 9 + crates/engine/src/clip_ops.rs | 6 +- crates/engine/src/clip_ops_tests.rs | 3 +- crates/engine/src/commands.rs | 7 +- crates/engine/src/lib.rs | 12 +- crates/engine/src/project_tools.rs | 1232 +++++++++++++++++ crates/engine/src/project_tools_tests.rs | 403 ++++++ crates/engine/src/proxies.rs | 5 +- crates/engine/src/scene_detect.rs | 318 +++++ crates/engine/src/scene_detect_tests.rs | 207 +++ crates/engine/src/sequence_extras.rs | 496 +++++++ crates/engine/src/sequence_extras_tests.rs | 237 ++++ crates/format/src/lib.rs | 10 +- crates/format/tests/fixtures.rs | 19 + .../format/tests/fixtures/v10-minimal.fcproj | 1 + crates/interchange/src/ale.rs | 294 ++++ crates/interchange/src/lib.rs | 1 + crates/project/src/find.rs | 252 ++++ crates/project/src/lib.rs | 50 + crates/render/src/lib.rs | 1 + crates/render/src/scene.rs | 246 ++++ crates/ui-egui/src/control.rs | 7 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/menus.rs | 11 +- crates/ui-egui/src/panels/dialogs.rs | 1 + crates/ui-egui/src/panels/menu_dialogs.rs | 896 ++++++++++++ crates/ui-egui/src/panels/misc.rs | 17 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/monitor.rs | 2 +- crates/ui-egui/src/panels/project.rs | 12 +- crates/ui-egui/src/panels/timeline.rs | 12 +- crates/ui-egui/src/state.rs | 5 + crates/ui-egui/tests/menus_ui.rs | 242 ++++ docs/control-protocol.md | 2 +- docs/project-files.md | 5 +- docs/testing.md | 1 + 38 files changed, 5035 insertions(+), 21 deletions(-) create mode 100644 crates/engine/src/project_tools.rs create mode 100644 crates/engine/src/project_tools_tests.rs create mode 100644 crates/engine/src/scene_detect.rs create mode 100644 crates/engine/src/scene_detect_tests.rs create mode 100644 crates/engine/src/sequence_extras.rs create mode 100644 crates/engine/src/sequence_extras_tests.rs create mode 100644 crates/format/tests/fixtures/v10-minimal.fcproj create mode 100644 crates/interchange/src/ale.rs create mode 100644 crates/project/src/find.rs create mode 100644 crates/render/src/scene.rs create mode 100644 crates/ui-egui/src/panels/menu_dialogs.rs create mode 100644 crates/ui-egui/tests/menus_ui.rs diff --git a/ROADMAP.md b/ROADMAP.md index d2fce8bc..d489f8b8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -31,7 +31,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining | M0 | Skeleton + visual shell | ✅ | Workspace, 20 crates, dock/workspaces, Premiere 26 look, native menus, control channel, MCP, xtask gates (layers, wasm) | — | — | | M1 | Media I/O | ✅ | MP4/MOV demux+mux, WAV, stills, MJPEG, symphonia audio (MP3/FLAC/ALAC/Vorbis), GOP seek + frame cache, import | Media Browser polish | 2 | | M2 | H.264 decoder | ✅ | Own decoder, bit-exact on 37+ streams, 500–600 fps 1080p | — | — | -| M3 | Editing core | 🟡 | Edit algebra (insert/overwrite/razor/lift/extract/ripple/roll/slip/slide/rate-stretch/nest/paste), tools, markers, trim mode + Trim Monitor + dynamic J/K/L trimming, Keyboard Shortcuts editor with FilmCraft/Premiere/FCP/Avid presets; Edit/Clip/File menu commands (Label colours and Select Label Group, Paste/Remove Attributes, Select All Matching, Remove Unused, Consolidate Duplicates, Sequence From Clip, Bin From Selection, Offline File, Close Project, Make/Edit Subclip, Modify Audio Channels/Timecode, Frame Hold Options/Add Frame Hold/Insert Frame Hold Segment, Time Interpolation with frame blending, Fit/Fill frame, Breakout to Mono, Extract Audio, Replace With Clip); multicam (Create Multi-Camera Source Sequence, Multi-Camera view with live switching on 1–9, angle switching, Enable/Flatten, Edit Cameras) and sync (Synchronize, Merge Clips; In/Out/timecode/marker/audio — GCC-PHAT, sample-accurate) | Multicam paging >16 angles and grid thumbnails, optical flow (renders as frame blending), ~200 Premiere default shortcuts whose commands don't exist yet | 6–10 | +| M3 | Editing core | 🟡 | Edit algebra (insert/overwrite/razor/lift/extract/ripple/roll/slip/slide/rate-stretch/nest/paste), tools, markers, trim mode + Trim Monitor + dynamic J/K/L trimming, Keyboard Shortcuts editor with FilmCraft/Premiere/FCP/Avid presets; Edit/Clip/File menu commands (Label colours and Select Label Group, Paste/Remove Attributes, Select All Matching, Remove Unused, Consolidate Duplicates, Sequence From Clip, Bin From Selection, Offline File, Close Project, Make/Edit Subclip, Modify Audio Channels/Timecode, Frame Hold Options/Add Frame Hold/Insert Frame Hold Segment, Time Interpolation with frame blending, Fit/Fill frame, Breakout to Mono, Extract Audio, Replace With Clip; M3.11: Scene Edit Detection (pure-Rust cut detection, background job), Normalize Mix Track, Simplify Sequence, Transcribe Sequence, Find/Find Next and search bins, Automate to Sequence, Edit Original, Edit Offline, Source Settings, Update Metadata (XMP), Generate Audio Waveform, Project Settings General/Scratch Disks, Get Media File Properties, Save as Template, Selection as FilmCraft Project, Avid Log Exchange export, Flash Cue markers, Dynamic Audio Waveforms, Reveal Log Files, System Compatibility Report); multicam (Create Multi-Camera Source Sequence, Multi-Camera view with live switching on 1–9, angle switching, Enable/Flatten, Edit Cameras) and sync (Synchronize, Merge Clips; In/Out/timecode/marker/audio — GCC-PHAT, sample-accurate) | Multicam paging >16 angles and grid thumbnails, optical flow (renders as frame blending), ~200 Premiere default shortcuts whose commands don't exist yet | 6–10 | | M4 | Playback | 🟡 | Audio-clock master, prefetch with cancellation, J/K/L, correct dropped-frame stats, playback resolution, render bar + content-hashed render previews, App Nap opt-out; 1080p H.264 and 3 stacked 1080p streams play with 0 dropped frames | 4K under load, 8K, frame-threaded AV1 decode, reduced-resolution decode for multicam grids | 6–10 | | M5 | Effects, keyframes, GPU | 🟡 | ~60 CPU effects, 30 transitions, keyframes + value/velocity graphs, wgpu compositor, effect + opacity masks (ellipse/polygon/pen, feather, expansion, modes; CPU/WGSL parity), mask tracking (Lucas–Kanade + RANSAC), adjustment layers, effect presets (built-in + user, JSON import/export) | Full ~150-effect catalogue, WGSL parity for all effects and masks in the live GPU path, Warp Stabilizer, Morph Cut | 20–30 | | M6 | Export | ✅ | Own H.264 encoder (High/Main/Baseline, B-frames, VBR/CBR/2-pass) → MP4 + own AAC; ProRes, MJPEG, PNG, GIF, WAV; background jobs | Preset library, queue UI, smart render | 6–8 | diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 8c9271ae..489ddd69 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -116,6 +116,7 @@ fn main() -> eframe::Result { rfd::FileDialog::new().add_filter(filter, exts).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()) })); app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); + app.hooks.open_path = Some(Box::new(open_path)); app.hooks.raise_without_focus = Some(Box::new(|| { window_raise::raise_without_focus(); })); @@ -140,6 +141,33 @@ fn main() -> eframe::Result { ) } +/// Open a file in its default application, or reveal it in the file manager (Edit Original, +/// Reveal Log Files). +fn open_path(path: &str, reveal: bool) -> Result<(), String> { + let mut cmd = if cfg!(target_os = "macos") { + let mut c = std::process::Command::new("open"); + if reveal { + c.arg("-R"); + } + c.arg(path); + c + } else if cfg!(target_os = "windows") { + let mut c = std::process::Command::new("explorer"); + if reveal { + c.arg(format!("/select,{path}")); + } else { + c.arg(path); + } + c + } else { + let mut c = std::process::Command::new("xdg-open"); + let p = std::path::Path::new(path); + c.arg(if reveal { p.parent().unwrap_or(p) } else { p }); + c + }; + cmd.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}")) +} + /// Local UTC offset at a unix time (auto-save file names and recovery times use local time). fn local_offset(unix: i64) -> i32 { chrono::Local.timestamp_opt(unix, 0).single().map(|d| d.offset().local_minus_utc()).unwrap_or(0) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index c129da86..0f328a28 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -802,7 +802,16 @@ impl Worker { fn auto_save(&mut self) -> Result { let Some(s) = &self.latest else { return Err("nothing to save".into()) }; + let scratch = s.project.settings.scratch.auto_save.clone().filter(|d| !d.is_empty()); let (dir, name) = match &s.path { + // Project Settings ▸ Scratch Disks ▸ Project Auto Save + Some(p) if scratch.is_some() => { + let p = Path::new(p); + ( + PathBuf::from(scratch.clone().unwrap_or_default()), + p.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| s.project.name.clone()), + ) + } Some(p) => { let p = Path::new(p); (names::auto_save_dir(p), p.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| s.project.name.clone())) diff --git a/crates/engine/src/clip_ops.rs b/crates/engine/src/clip_ops.rs index 2ce26488..366456c4 100644 --- a/crates/engine/src/clip_ops.rs +++ b/crates/engine/src/clip_ops.rs @@ -440,7 +440,7 @@ fn items_p(s: &Session, p: &Value) -> Vec { } /// Fold the undo steps pushed since the history had `n0` entries into one step named `label`. -fn collapse_history(s: &mut Session, n0: usize, label: &str) { +pub(crate) fn collapse_history(s: &mut Session, n0: usize, label: &str) { if s.history.undo.len() > n0 { let first = s.history.undo[n0].1.clone(); s.history.undo.truncate(n0); @@ -482,7 +482,7 @@ fn used_items(p: &Project) -> BTreeSet { } /// Item ids mentioned in a JSON value (keys named `item`/`items`/`video`/`audio` holding ids). -fn collect_ids(v: &Value, out: &mut BTreeSet, p: &Project) { +pub(crate) fn collect_ids(v: &Value, out: &mut BTreeSet, p: &Project) { match v { Value::Object(m) => { for (k, x) in m { @@ -590,7 +590,7 @@ fn sequence_from_clip(s: &mut Session, p: &Value) -> Result { } /// The media range an item edits in with: its In/Out marks, a subclip's range, or the whole item. -fn item_range(p: &Project, id: ItemId) -> Option { +pub(crate) fn item_range(p: &Project, id: ItemId) -> Option { let it = p.item(id)?; let rate = item_rate(p, id); Some(match &it.kind { diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index a0d5e3b4..bdae5a64 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -53,7 +53,8 @@ fn menus_and_shortcuts_follow_premiere() { assert!(pos("edit.removeUnused") > pos("edit.label.yellow") && pos("edit.consolidateDuplicates") == pos("edit.removeUnused") + 1); assert_eq!(pos("file.newSequenceFromClip"), pos("file.newSequence") + 1); assert_eq!(pos("file.newBinFromSelection"), pos("file.newBin") + 1); - assert_eq!(pos("file.closeProject") + 1, pos("file.save")); + // Close Project, Close All Projects, Close All Other Projects (M3.11), Save + assert_eq!(pos("file.closeProject") + 3, pos("file.save")); assert_eq!(pos("file.saveAll"), pos("file.saveCopy") + 1); assert_eq!(menu_of("clip.timeInterpolation.opticalFlow"), ["Clip", "Video Options", "Time Interpolation"]); assert_eq!(menu_of("clip.audioGain"), ["Clip", "Audio Options"]); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 9db12448..30a3b003 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2019,6 +2019,8 @@ fn build() -> Vec { // Edit ▸ Label ▸ , Paste Attributes, subclips, Video / Audio Options, Replace With Clip… // and their menu order crate::clip_ops::apply_layout(&mut v); + // Search Bin, Find, Project Settings, Scene Edit Detection, Normalize Mix Track… (M3.11) + crate::project_tools::apply_layout(&mut v); v.shrink_to_fit(); v } @@ -2542,7 +2544,7 @@ fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Opti if let Some(p) = &path && !cfg!(target_arch = "wasm32") { - s.previews.set_dir(Some(crate::previews::dir_for_project(p))); + s.previews.set_dir(Some(crate::project_tools::previews_dir_for(&s.project, p))); } s.path = path; s.revision += 1; @@ -2590,6 +2592,9 @@ fn recover(s: &mut Session, id: Option<&str>) -> Result { /// Where auto-saves of the current project go. fn auto_save_dir(s: &Session) -> std::path::PathBuf { + if let Some(d) = s.project.settings.scratch.auto_save.as_deref().filter(|d| !d.is_empty() && s.path.is_some()) { + return std::path::PathBuf::from(d); + } match (&s.path, &s.persistence) { (Some(p), _) => filmcraft_format::autosave::auto_save_dir(std::path::Path::new(p)), (None, Some(per)) => per.data_dir.join(filmcraft_format::autosave::AUTO_SAVE_DIR), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index aa392e95..69704d40 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -24,8 +24,11 @@ pub mod multicam; pub mod presets; pub mod previews; pub mod project_manager; +pub mod project_tools; pub mod proxies; pub mod relink; +pub mod scene_detect; +pub mod sequence_extras; pub mod sequence_tools; pub mod shortcut_presets; pub mod shortcuts; @@ -212,6 +215,9 @@ pub struct EditorState { /// Sequence markers copied with the clipboard clips (start relative to the copied range). #[serde(skip)] pub clipboard_markers: Vec, + /// The last Edit ▸ Find… (Find Next continues it). + #[serde(default)] + pub find: Option, } /// Events for frontends (drained each frame). @@ -261,6 +267,8 @@ pub struct Session { pub media_jobs: Vec, /// Mask tracking jobs whose keyframes are still being written. pub mask_jobs: Vec, + /// Scene Edit Detection jobs whose results are applied when they finish. + pub scene_jobs: Vec, /// Effect presets (built-in + the user's, persisted in the data directory). pub presets: presets::PresetLibrary, /// Exports run a batch at a time by [`Session::pump_jobs`] (hosts without threads: web). @@ -345,6 +353,7 @@ impl Session { offline: Default::default(), media_jobs: Vec::new(), mask_jobs: Vec::new(), + scene_jobs: Vec::new(), presets: Default::default(), stepped: Vec::new(), transcriber: None, @@ -429,6 +438,7 @@ impl Session { pub fn poll_persistence(&mut self) { proxies::poll(self); masks::poll(self); + scene_detect::poll(self); let Some(p) = self.persistence.as_mut() else { return }; for ev in p.drain_events() { match ev { @@ -465,7 +475,7 @@ impl Session { if let Some(p) = &self.path && !cfg!(target_arch = "wasm32") { - self.previews.move_to(previews::dir_for_project(p)); + self.previews.move_to(project_tools::previews_dir(self).unwrap_or_else(|| previews::dir_for_project(p))); } } diff --git a/crates/engine/src/project_tools.rs b/crates/engine/src/project_tools.rs new file mode 100644 index 00000000..873e180a --- /dev/null +++ b/crates/engine/src/project_tools.rs @@ -0,0 +1,1232 @@ +//! File / Edit / Clip menu long tail (M3.11), and [`apply_layout`], which puts these commands (and +//! those of `sequence_extras` and `scene_detect`) in Premiere's menu order. +//! +//! | Id | Menu | +//! |---|---| +//! | `file.newSearchBin` | File ▸ New ▸ Search Bin (`project.searchBinItems`, `project.editSearchBin`, `project.deleteSearchBin`) | +//! | `file.close` | File ▸ Close (⌘W): closes the active sequence | +//! | `file.closeAllProjects` / `file.closeAllOtherProjects` | File ▸ Close All Projects / Close All Other Projects | +//! | `file.saveAsTemplate` | File ▸ Save as Template… (`file.templates`, `file.newProjectFromTemplate`) | +//! | `file.importFromMediaBrowser` | File ▸ Import from Media Browser (⌥⌘I) | +//! | `file.exportSelectionProject` | File ▸ Export ▸ Selection as FilmCraft Project… | +//! | `file.exportAle` | File ▸ Export ▸ Avid Log Exchange… | +//! | `file.mediaPropertiesFile` / `file.mediaProperties` | File ▸ Get Media File Properties for ▸ File… / Selection… (⇧⌘H) | +//! | `file.projectSettings.general` / `.scratchDisks` | File ▸ Project Settings ▸ General… / Scratch Disks… | +//! | `edit.find` / `edit.findNext` | Edit ▸ Find… (⌘F) / Find Next | +//! | `edit.editOriginal` | Edit ▸ Edit Original (⌘E): the frontend opens the files | +//! | `clip.editOffline` | Clip ▸ Edit Offline… | +//! | `clip.sourceSettings` | Clip ▸ Source Settings… | +//! | `clip.restoreCaptionsFromSource` | Clip ▸ Restore Captions from Source Clip (always disabled: no embedded caption reader) | +//! | `clip.updateMetadata` | Clip ▸ Update Metadata… (XMP sidecars) | +//! | `clip.generateAudioWaveform` | Clip ▸ Generate Audio Waveform (the frontend rebuilds its peaks) | +//! | `clip.automateToSequence` | Clip ▸ Automate to Sequence… | +//! | `help.systemReport` | query behind Help ▸ System Compatibility Report… | + +use std::collections::BTreeSet; + +use filmcraft_project::{BinEntry, FindOp, FindQuery, FindRow, ItemId, ItemKind, MarkerKind, MediaRef, Project, SearchBin, TrackKind}; +use filmcraft_time::{Tick, TimeDisplay, TimeRange}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, always, bad, bool_p, has_project_selection, has_seq, str_p, u64_p}; +use crate::{EngineError, Result, Session}; + +type Run = fn(&mut Session, &Value) -> Result; +type Enabled = fn(&Session) -> std::result::Result<(), String>; + +fn spec( + id: &'static str, + label: &'static str, + menu: &'static [&'static str], + shortcut: Option<&'static str>, + params: &'static str, + enabled: Enabled, + run: Run, +) -> CommandSpec { + CommandSpec { id, label, menu, shortcut, params, enabled, run, journal: true } +} + +fn query( + id: &'static str, + label: &'static str, + menu: &'static [&'static str], + shortcut: Option<&'static str>, + params: &'static str, + enabled: Enabled, + run: Run, +) -> CommandSpec { + CommandSpec { id, label, menu, shortcut, params, enabled, run, journal: false } +} + +const FIND_PARAMS: &str = r#"{"scope":"project|timeline"?,"column":str?,"operator":"contains|matches|beginsWith|endsWith|doesNotContain"?,"text":str?,"rows":[{"column":str,"operator":str,"text":str}]?,"matchAll":bool=true,"caseSensitive":bool=false,"in":"all|clips|markers"?}"#; + +pub(crate) fn commands() -> Vec { + vec![ + // ---------------- File ---------------- + spec( + "file.newSearchBin", + "Search Bin", + &["File", "New"], + None, + r#"{"name":str?,"column":str?,"operator":str?,"text":str?,"rows":[..]?,"matchAll":bool?,"caseSensitive":bool?}"#, + always, + new_search_bin, + ), + query("project.searchBinItems", "Search Bin Contents", &[], None, r#"{"bin":id}"#, always, search_bin_items), + spec( + "project.editSearchBin", + "Edit Search Bin", + &[], + None, + r#"{"bin":id,"name":str?,"column":str?,"operator":str?,"text":str?,"rows":[..]?,"matchAll":bool?,"caseSensitive":bool?}"#, + always, + edit_search_bin, + ), + spec("project.deleteSearchBin", "Delete Search Bin", &[], None, r#"{"bin":id}"#, always, delete_search_bin), + spec("file.close", "Close", &["File"], Some("Cmd+W"), r#"{"item":id?}"#, has_seq, |s, p| s.execute("sequence.close", p.clone())), + spec("file.closeAllProjects", "Close All Projects", &["File"], None, r#"{"force":bool?}"#, always, |s, p| s.execute("file.closeProject", p.clone())), + spec("file.closeAllOtherProjects", "Close All Other Projects", &["File"], None, "{}", only_one_project, |_, _| { + Err(EngineError::Other("only one project is open".into())) + }), + spec("file.saveAsTemplate", "Save as Template…", &["File"], None, r#"{"name":str?,"path":str?}"#, always, save_as_template), + query("file.templates", "List Project Templates", &[], None, "{}", always, list_templates), + spec("file.newProjectFromTemplate", "New Project from Template", &[], None, r#"{"template":name|path,"name":str?}"#, always, new_from_template), + spec("file.importFromMediaBrowser", "Import from Media Browser", &["File"], Some("Cmd+Alt+I"), r#"{"paths":[str]}"#, always, |s, p| { + if p.get("paths").and_then(Value::as_array).is_none_or(|a| a.is_empty()) { + return Err(bad("file.importFromMediaBrowser", "select files in the Media Browser")); + } + s.execute("file.import", json!({"paths": p["paths"]})) + }), + spec( + "file.exportSelectionProject", + "Selection as FilmCraft Project…", + &["File", "Export"], + None, + r#"{"path":str,"items":[id]?}"#, + has_project_selection, + export_selection, + ), + spec("file.exportAle", "Avid Log Exchange…", &["File", "Export"], None, r#"{"path":str,"items":[id]?}"#, has_loggable, export_ale), + query("file.mediaPropertiesFile", "File…", &["File", "Get Media File Properties for"], None, r#"{"path":str}"#, always, media_properties_file), + query( + "file.mediaProperties", + "Selection…", + &["File", "Get Media File Properties for"], + Some("Cmd+Shift+H"), + r#"{"items":[id]?}"#, + has_media_selection, + media_properties, + ), + spec( + "file.projectSettings.general", + "General…", + &["File", "Project Settings"], + None, + r#"{"renderer":"gpu|software"?,"videoDisplay":"timecode|feet35|feet16|frames"?,"audioDisplay":"samples|milliseconds"?,"captureFormat":"DV|HDV"?,"titleSafe":[h,v]?,"actionSafe":[h,v]?}"#, + always, + project_settings_general, + ), + spec( + "file.projectSettings.scratchDisks", + "Scratch Disks…", + &["File", "Project Settings"], + None, + r#"{"captured":path|null?,"videoPreviews":path|null?,"audioPreviews":path|null?,"autoSave":path|null?}"#, + always, + project_settings_scratch, + ), + // ---------------- Edit ---------------- + spec("edit.find", "Find…", &["Edit"], Some("Cmd+F"), FIND_PARAMS, always, find), + spec("edit.findNext", "Find Next", &["Edit"], None, "{}", has_find, find_next), + query("edit.editOriginal", "Edit Original", &["Edit"], Some("Cmd+E"), r#"{"items":[id]?}"#, has_original, edit_original), + // ---------------- Clip ---------------- + spec( + "clip.editOffline", + "Edit Offline…", + &["Clip"], + None, + r#"{"item":id?,"mediaName":str?,"tapeName":str?,"description":str?,"scene":str?,"shot":str?,"logNote":str?}"#, + has_offline_selection, + edit_offline, + ), + query("clip.sourceSettings", "Source Settings…", &["Clip"], None, r#"{"item":id?}"#, has_media_selection, source_settings), + spec("clip.restoreCaptionsFromSource", "Restore Captions from Source Clip", &["Clip"], None, "{}", no_embedded_captions, |_, _| { + Err(EngineError::Other(NO_CAPTIONS.into())) + }), + spec("clip.updateMetadata", "Update Metadata…", &["Clip"], None, r#"{"items":[id]?}"#, has_file_selection, update_metadata), + query("clip.generateAudioWaveform", "Generate Audio Waveform", &["Clip"], None, r#"{"items":[id]?}"#, has_audio_selection, generate_waveform), + spec( + "clip.automateToSequence", + "Automate to Sequence…", + &["Clip"], + None, + r#"{"items":[id]?,"ordering":"sort|selection","placement":"sequentially|unnumberedMarkers","method":"insert|overwrite","overlapFrames":n=30,"stillFrames":n?,"videoTransition":bool=true,"audioTransition":bool=true,"ignoreAudio":bool=false,"ignoreVideo":bool=false}"#, + can_automate, + automate_to_sequence, + ), + // ---------------- Help ---------------- + query("help.systemReport", "System Compatibility Report", &[], None, "{}", always, |_, _| Ok(system_report())), + ] +} + +// --------------------------------------------------------------------------------------------- +// menu layout +// --------------------------------------------------------------------------------------------- + +enum At { + After(&'static str), + Before(&'static str), +} + +/// Add this module's, `sequence_extras`' and `scene_detect`'s commands and arrange them (and a few +/// existing ones) in Premiere's menu order. +pub(crate) fn apply_layout(v: &mut Vec) { + let mut pool = commands(); + pool.extend(crate::sequence_extras::commands()); + pool.extend(crate::scene_detect::commands()); + // Apply Default Transitions to Selection works on selected clips too (⇧D), not only on + // selected edit points; the Trim Monitor button keeps using the same id. + if let Some(c) = v.iter_mut().find(|c| c.id == "trim.applyDefaultTransition") + && let Some(i) = pool.iter().position(|c| c.id == "sequence.applyDefaultTransitionsToSelection") + { + let new = pool.remove(i); + c.enabled = new.enabled; + c.run = new.run; + c.params = new.params; + } + // Project Settings ▸ Ingest Settings… + if let Some(c) = v.iter_mut().find(|c| c.id == "project.ingestSettings") { + c.menu = &["File", "Project Settings"]; + } + let layout: Vec<(At, Vec<&'static str>)> = vec![ + (At::After("file.newBinFromSelection"), vec!["file.newSearchBin"]), + (At::Before("file.closeProject"), vec!["file.close"]), + (At::After("file.closeProject"), vec!["file.closeAllProjects", "file.closeAllOtherProjects"]), + (At::After("file.saveCopy"), vec!["file.saveAsTemplate"]), + (At::After("file.exportEdl"), vec!["file.exportSelectionProject", "file.exportAle", "file.exportOtio", "file.exportFcp7Xml", "file.exportFcpxml"]), + ( + At::Before("file.projectManager"), + vec![ + "file.mediaPropertiesFile", + "file.mediaProperties", + "file.projectSettings.general", + "file.projectSettings.scratchDisks", + "project.ingestSettings", + ], + ), + (At::After("edit.deselectAll"), vec!["edit.find", "edit.findNext"]), + (At::After("edit.consolidateDuplicates"), vec!["edit.editOriginal"]), + (At::After("clip.editSubclip"), vec!["clip.editOffline", "clip.sourceSettings"]), + (At::After("clip.speedDuration"), vec!["clip.sceneEditDetection"]), + ( + At::After("clip.replaceFromBin"), + vec!["clip.restoreCaptionsFromSource", "clip.updateMetadata", "clip.generateAudioWaveform", "clip.automateToSequence"], + ), + (At::After("sequence.applyAudioTransition"), vec!["trim.applyDefaultTransition"]), + (At::After("sequence.showThroughEdits"), vec!["sequence.normalizeMixTrack"]), + (At::After("sequence.makeSubsequence"), vec!["sequence.transcribe", "sequence.simplify"]), + (At::After("captions.showAll"), vec!["captions.showActiveOnly"]), + (At::After("markers.addChapter"), vec!["markers.addFlashCue"]), + (At::Before("file.close"), vec!["file.open"]), + (At::After("media.makeOffline"), vec!["file.importFromMediaBrowser", "file.import"]), + ]; + for (at, ids) in layout { + let mut run = Vec::new(); + for id in ids { + if let Some(i) = pool.iter().position(|c| c.id == id) { + run.push(pool.remove(i)); + } else if let Some(i) = v.iter().position(|c| c.id == id) { + run.push(v.remove(i)); + } + } + let idx = match at { + At::After(a) => v.iter().position(|c| c.id == a).map(|i| i + 1), + At::Before(b) => v.iter().position(|c| c.id == b), + } + .unwrap_or(v.len()); + v.splice(idx..idx, run); + } + v.extend(pool); + reorder(v, &SEQUENCE_MENU); +} + +/// The Sequence menu in Premiere's order (submenu items where their submenu goes). +const SEQUENCE_MENU: [&str; 34] = [ + "sequence.settings", + "sequence.renderEffectsInToOut", + "sequence.renderInToOut", + "sequence.renderSelection", + "sequence.renderAudio", + "sequence.deleteRenderFiles", + "sequence.deleteRenderFilesInToOut", + "sequence.matchFrame", + "sequence.reverseMatchFrame", + "sequence.addEdit", + "sequence.addEditAllTracks", + "trim.edit", + "sequence.applyVideoTransition", + "sequence.applyAudioTransition", + "trim.applyDefaultTransition", + "sequence.lift", + "sequence.extract", + "sequence.closeGap", + "sequence.goToNextGap", + "sequence.goToPrevGap", + "sequence.goToNextGapInTrack", + "sequence.goToPrevGapInTrack", + "sequence.snap", + "sequence.linkedSelection", + "sequence.selectionFollowsPlayhead", + "sequence.showThroughEdits", + "sequence.normalizeMixTrack", + "sequence.makeSubsequence", + "sequence.transcribe", + "sequence.simplify", + "sequence.addTracks", + "sequence.deleteTracks", + "sequence.colorSettings", + "mixer.addSubmix", +]; + +/// Put the commands `ids` (those present) in this order, at the position of the first of them. +fn reorder(v: &mut Vec, ids: &[&str]) { + let Some(at) = v.iter().position(|c| ids.contains(&c.id)) else { return }; + let mut run = Vec::new(); + for id in ids { + if let Some(i) = v.iter().position(|c| c.id == *id) { + run.push(v.remove(i)); + } + } + let at = at.min(v.len()); + v.splice(at..at, run); +} + +// --------------------------------------------------------------------------------------------- +// enablement +// --------------------------------------------------------------------------------------------- + +fn only_one_project(_: &Session) -> std::result::Result<(), String> { + Err("only one project is open".into()) +} + +const NO_CAPTIONS: &str = "the clip's media has no embedded captions FilmCraft can read (embedded CEA-608/708 caption streams are not supported yet)"; + +fn no_embedded_captions(_: &Session) -> std::result::Result<(), String> { + Err(NO_CAPTIONS.into()) +} + +fn has_find(s: &Session) -> std::result::Result<(), String> { + if s.state.find.is_some() { Ok(()) } else { Err("use Find… first".into()) } +} + +/// Project items the command works on: `items`, else the Project panel selection, else the media +/// of the selected timeline clips. +fn targets(s: &Session, p: &Value) -> Vec { + if let Some(a) = p.get("items").and_then(Value::as_array) { + return a.iter().filter_map(|v| v.as_u64().map(ItemId)).filter(|i| s.project.items.contains_key(i)).collect(); + } + if let Some(i) = u64_p(p, "item") { + return vec![ItemId(i)]; + } + if !s.state.project_selection.is_empty() { + return s.state.project_selection.clone(); + } + let mut out = Vec::new(); + if let Some(q) = s.active_sequence() { + for c in &s.state.selection { + if let Some((_, it)) = q.find_item(*c) + && !out.contains(&it.item) + { + out.push(it.item); + } + } + } + out +} + +/// The media clip behind an item (subclips resolve to their parent). +fn media_of(p: &Project, id: ItemId) -> Option<(ItemId, &filmcraft_project::MediaClip)> { + match &p.item(id)?.kind { + ItemKind::Media(m) => Some((id, m)), + ItemKind::Subclip { parent, .. } => media_of(p, *parent), + _ => None, + } +} + +fn file_path(m: &filmcraft_project::MediaClip) -> Option<&str> { + match &m.media { + MediaRef::File { path } => Some(path), + MediaRef::Generator(_) => None, + } +} + +fn has_media_selection(s: &Session) -> std::result::Result<(), String> { + if targets(s, &Value::Null).iter().any(|i| media_of(&s.project, *i).is_some()) { Ok(()) } else { Err("select a clip".into()) } +} +fn has_file_selection(s: &Session) -> std::result::Result<(), String> { + if targets(s, &Value::Null).iter().any(|i| media_of(&s.project, *i).is_some_and(|(_, m)| file_path(m).is_some())) { + Ok(()) + } else { + Err("select a clip with a media file".into()) + } +} +fn has_original(s: &Session) -> std::result::Result<(), String> { + has_file_selection(s) +} +fn has_audio_selection(s: &Session) -> std::result::Result<(), String> { + if targets(s, &Value::Null).iter().any(|i| media_of(&s.project, *i).is_some_and(|(_, m)| m.info.has_audio())) { + Ok(()) + } else { + Err("select a clip with audio".into()) + } +} +fn is_offline(s: &Session, id: ItemId) -> bool { + media_of(&s.project, id).is_some_and(|(mid, m)| m.offline || s.media.offline_status(mid).is_some()) +} +fn has_offline_selection(s: &Session) -> std::result::Result<(), String> { + if targets(s, &Value::Null).iter().any(|i| is_offline(s, *i)) { Ok(()) } else { Err("select an offline clip".into()) } +} +fn has_loggable(s: &Session) -> std::result::Result<(), String> { + if s.project.items.keys().any(|i| media_of(&s.project, *i).is_some()) { Ok(()) } else { Err("the project has no clips to log".into()) } +} +fn can_automate(s: &Session) -> std::result::Result<(), String> { + has_seq(s)?; + has_project_selection(s)?; + if s.state.project_selection.iter().any(|i| s.project.item(*i).is_some_and(|it| !matches!(it.kind, ItemKind::Graphic { .. }))) { + Ok(()) + } else { + Err("select clips in the Project panel".into()) + } +} + +// --------------------------------------------------------------------------------------------- +// queries (Find, search bins) +// --------------------------------------------------------------------------------------------- + +/// Read a query from params: `rows`, or one `column` / `operator` / `text` row. +pub fn query_p(p: &Value, cmd: &str) -> Result { + let row = |v: &Value| -> Result { + let op = match str_p(v, "operator").or(str_p(v, "op")) { + Some(o) => FindOp::from_name(o).ok_or_else(|| bad(cmd, format!("unknown operator `{o}`")))?, + None => FindOp::Contains, + }; + Ok(FindRow { column: str_p(v, "column").unwrap_or("All").to_string(), op, text: str_p(v, "text").unwrap_or_default().to_string() }) + }; + let rows = match p.get("rows").and_then(Value::as_array) { + Some(a) => a.iter().map(row).collect::>>()?, + None => vec![row(p)?], + }; + Ok(FindQuery { rows, match_all: bool_p(p, "matchAll").unwrap_or(true), case_sensitive: bool_p(p, "caseSensitive").unwrap_or(false) }) +} + +fn search_bin_json(p: &Project, b: &SearchBin) -> Value { + json!({"bin": b.id.0, "name": b.name, "query": b.query, "items": b.query.find(p).iter().map(|i| i.0).collect::>()}) +} + +fn new_search_bin(s: &mut Session, p: &Value) -> Result { + let q = query_p(p, "file.newSearchBin")?; + if q.is_empty() { + return Err(bad("file.newSearchBin", "enter text to search for")); + } + let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| q.rows.iter().find(|r| !r.text.is_empty()).map(|r| r.text.clone()).unwrap_or_default()); + let b = s.edit("New Search Bin", |pr, _| { + let id = filmcraft_project::BinId(pr.alloc_id()); + let b = SearchBin { id, name, query: q }; + pr.search_bins.push(b.clone()); + Ok(b) + })?; + Ok(search_bin_json(&s.project, &b)) +} + +fn bin_p(s: &Session, p: &Value, cmd: &str) -> Result { + let id = u64_p(p, "bin").ok_or_else(|| bad(cmd, "need `bin`"))?; + s.project.search_bins.iter().find(|b| b.id.0 == id).cloned().ok_or_else(|| bad(cmd, format!("no search bin {id}"))) +} + +fn search_bin_items(s: &mut Session, p: &Value) -> Result { + let b = bin_p(s, p, "project.searchBinItems")?; + Ok(search_bin_json(&s.project, &b)) +} + +fn edit_search_bin(s: &mut Session, p: &Value) -> Result { + let b = bin_p(s, p, "project.editSearchBin")?; + let q = if p.get("rows").is_some() || p.get("text").is_some() { Some(query_p(p, "project.editSearchBin")?) } else { None }; + let name = str_p(p, "name").map(str::to_string); + let out = s.edit("Edit Search Bin", |pr, _| { + let x = pr.search_bins.iter_mut().find(|x| x.id == b.id).ok_or_else(|| bad("project.editSearchBin", "gone"))?; + if let Some(q) = q { + x.query = q; + } + if let Some(n) = name { + x.name = n; + } + Ok(x.clone()) + })?; + Ok(search_bin_json(&s.project, &out)) +} + +fn delete_search_bin(s: &mut Session, p: &Value) -> Result { + let b = bin_p(s, p, "project.deleteSearchBin")?; + s.edit("Delete Search Bin", |pr, _| { + pr.search_bins.retain(|x| x.id != b.id); + Ok(()) + })?; + Ok(Value::Null) +} + +/// The last Find: Find Next walks its results. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct FindState { + /// `project` or `timeline`. + pub scope: String, + pub query: FindQuery, + /// Timeline: search clip names, marker names / comments, or both (`all`). + pub within: String, + /// Index of the current result. + pub index: usize, +} + +/// One timeline Find hit. +#[derive(Clone, Debug, PartialEq)] +enum Hit { + Clip(filmcraft_project::ClipId, Tick), + Marker(filmcraft_project::MarkerId, Tick), +} + +fn timeline_hits(s: &Session, f: &FindState) -> Vec { + let Some(q) = s.active_sequence() else { return Vec::new() }; + let test = |text: &str| { + f.query.rows.iter().filter(|r| !r.text.is_empty()).fold(f.query.match_all, |acc, r| { + let (h, n) = if f.query.case_sensitive { (text.to_string(), r.text.clone()) } else { (text.to_lowercase(), r.text.to_lowercase()) }; + let ok = match r.op { + FindOp::Contains => h.contains(&n), + FindOp::Matches => h == n, + FindOp::BeginsWith => h.starts_with(&n), + FindOp::EndsWith => h.ends_with(&n), + FindOp::DoesNotContain => !h.contains(&n), + }; + if f.query.match_all { acc && ok } else { acc || ok } + }) + }; + if f.query.is_empty() { + return Vec::new(); + } + let mut hits = Vec::new(); + if f.within != "markers" { + for t in q.all_tracks() { + for it in &t.items { + let name = if it.name.is_empty() { s.project.item(it.item).map(|i| i.name.clone()).unwrap_or_default() } else { it.name.clone() }; + if test(&name) { + hits.push(Hit::Clip(it.id, it.start)); + } + } + } + } + if f.within != "clips" { + for m in &q.markers { + if test(&m.name) || (!m.comment.is_empty() && test(&m.comment)) { + hits.push(Hit::Marker(m.id, m.start)); + } + } + } + hits.sort_by_key(|h| match h { + Hit::Clip(_, t) | Hit::Marker(_, t) => *t, + }); + hits +} + +/// Select / reveal result `f.index`. +fn show_result(s: &mut Session, f: &FindState) -> Result { + if f.scope == "timeline" { + let hits = timeline_hits(s, f); + let Some(h) = hits.get(f.index % hits.len().max(1)).cloned() else { return Err(EngineError::Other("no matches".into())) }; + let out = match h { + Hit::Clip(c, t) => { + s.state.selection = vec![c]; + s.set_playhead(t); + json!({"clip": c.0, "time": t.0}) + } + Hit::Marker(m, t) => { + s.set_playhead(t); + json!({"marker": m.0, "time": t.0}) + } + }; + let mut out = out; + out["matches"] = json!(hits.len()); + out["index"] = json!(f.index % hits.len().max(1)); + return Ok(out); + } + let items = f.query.find(&s.project); + let Some(id) = items.get(f.index % items.len().max(1)).copied() else { return Err(EngineError::Other("no matches".into())) }; + s.state.project_selection = vec![id]; + Ok(json!({"item": id.0, "matches": items.len(), "index": f.index % items.len().max(1), "items": items.iter().map(|i| i.0).collect::>()})) +} + +fn find(s: &mut Session, p: &Value) -> Result { + let scope = str_p(p, "scope").unwrap_or("project").to_ascii_lowercase(); + if !matches!(scope.as_str(), "project" | "timeline") { + return Err(bad("edit.find", "`scope` is project or timeline")); + } + let within = str_p(p, "in").unwrap_or("all").to_ascii_lowercase(); + if scope == "timeline" { + has_seq(s).map_err(EngineError::Other)?; + } + let mut query = query_p(p, "edit.find")?; + // timeline Find searches names: an `All` / empty column is fine + if scope == "timeline" { + query.rows.iter_mut().for_each(|r| r.column = "Name".into()); + } + if query.is_empty() { + return Err(bad("edit.find", "enter text to find")); + } + let f = FindState { scope, query, within, index: 0 }; + s.state.find = Some(f.clone()); + show_result(s, &f) +} + +fn find_next(s: &mut Session, _: &Value) -> Result { + let mut f = s.state.find.clone().ok_or_else(|| EngineError::Other("use Find… first".into()))?; + f.index += 1; + let r = show_result(s, &f); + if let Ok(v) = &r { + f.index = v["index"].as_u64().unwrap_or(0) as usize; + s.state.find = Some(f); + } + r +} + +// --------------------------------------------------------------------------------------------- +// templates, closing +// --------------------------------------------------------------------------------------------- + +/// Where project templates live: `/Templates` (None without a data directory). +pub fn templates_dir(s: &Session) -> Option { + s.prefs_path.as_ref().and_then(|p| p.parent()).map(|d| d.join("Templates")) +} + +fn save_as_template(s: &mut Session, p: &Value) -> Result { + let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| s.project.name.clone()); + let path = match str_p(p, "path") { + Some(x) => x.to_string(), + None => { + let dir = templates_dir(s).ok_or_else(|| bad("file.saveAsTemplate", "need `path` (no data directory for templates)"))?; + let _ = std::fs::create_dir_all(&dir); + dir.join(format!("{}.fcproj", sanitize(&name))).to_string_lossy().to_string() + } + }; + let mut proj = (*s.project).clone(); + proj.name = name.clone(); + proj.root.name = name.clone(); + s.services.write_file(&path, &filmcraft_format::encode(&proj, false)).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(json!({"path": path, "name": name})) +} + +fn sanitize(n: &str) -> String { + n.chars().map(|c| if c.is_alphanumeric() || " -_().".contains(c) { c } else { '_' }).collect::().trim().to_string() +} + +fn list_templates(s: &mut Session, _: &Value) -> Result { + let Some(dir) = templates_dir(s) else { return Ok(json!([])) }; + let mut v: Vec = std::fs::read_dir(&dir) + .map(|rd| { + rd.flatten() + .filter(|e| e.path().extension().is_some_and(|x| x == "fcproj")) + .map(|e| json!({"name": e.path().file_stem().map(|x| x.to_string_lossy().to_string()), "path": e.path().to_string_lossy()})) + .collect() + }) + .unwrap_or_default(); + v.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + Ok(Value::Array(v)) +} + +fn new_from_template(s: &mut Session, p: &Value) -> Result { + let t = str_p(p, "template").ok_or_else(|| bad("file.newProjectFromTemplate", "need `template`"))?; + let path = if t.ends_with(".fcproj") || t.contains('/') || t.contains('\\') { + t.to_string() + } else { + templates_dir(s).map(|d| d.join(format!("{}.fcproj", sanitize(t))).to_string_lossy().to_string()).unwrap_or_else(|| t.to_string()) + }; + let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let mut proj = filmcraft_format::decode(&bytes).map_err(|e| EngineError::Other(e.to_string()))?.project; + if s.is_dirty() && !bool_p(p, "force").unwrap_or(false) { + return Err(EngineError::Other("the project has unsaved changes: save it first, or pass {\"force\": true} to discard them".into())); + } + let name = str_p(p, "name").unwrap_or("Untitled").to_string(); + proj.name = name.clone(); + proj.root.name = name; + s.project = std::sync::Arc::new(proj); + s.history = Default::default(); + s.history.limit = 200; + s.state = Session::default().state; + s.state.active_sequence = s.project.sequences().next().map(|i| i.id); + s.state.open_sequences = s.state.active_sequence.into_iter().collect(); + if s.path.take().is_some() { + s.previews.reset_temp(); + } + s.media.clear(); + s.revision += 1; + s.saved_revision = s.revision; + s.events.push(crate::Event::ProjectChanged { revision: s.revision }); + if let Some(q) = s.state.active_sequence { + s.events.push(crate::Event::OpenSequence(q)); + } + Ok(json!({"items": s.project.items.len()})) +} + +// --------------------------------------------------------------------------------------------- +// exports +// --------------------------------------------------------------------------------------------- + +/// The selected items plus everything they need: sequence contents (recursively), subclip +/// parents, multi-camera / merged-clip sources and graphic canvases. +pub fn closure(p: &Project, roots: &[ItemId]) -> BTreeSet { + let mut keep: BTreeSet = BTreeSet::new(); + let mut todo: Vec = roots.to_vec(); + while let Some(id) = todo.pop() { + if !keep.insert(id) { + continue; + } + let Some(it) = p.item(id) else { continue }; + match &it.kind { + ItemKind::Subclip { parent, .. } => todo.push(*parent), + ItemKind::Sequence(q) => { + for t in q.all_tracks() { + todo.extend(t.items.iter().map(|i| i.item)); + } + let mut ids = BTreeSet::new(); + for v in [serde_json::to_value(&q.multicam).unwrap_or_default(), serde_json::to_value(&q.merged).unwrap_or_default()] { + crate::clip_ops::collect_ids(&v, &mut ids, p); + } + todo.extend(ids); + } + _ => {} + } + } + keep +} + +/// A copy of `p` with only `keep` (bins that end up empty are dropped). +pub fn project_subset(p: &Project, keep: &BTreeSet) -> Project { + fn prune(b: &mut filmcraft_project::Bin, keep: &BTreeSet) { + b.children.retain_mut(|c| match c { + BinEntry::Item(i) => keep.contains(i), + BinEntry::Bin(x) => { + prune(x, keep); + !x.children.is_empty() + } + }); + } + let mut out = p.clone(); + out.items.retain(|id, _| keep.contains(id)); + prune(&mut out.root, keep); + out.transcripts.retain(|id, _| keep.contains(id)); + out.search_bins.clear(); + out +} + +fn export_selection(s: &mut Session, p: &Value) -> Result { + let path = str_p(p, "path").ok_or_else(|| bad("file.exportSelectionProject", "need `path`"))?.to_string(); + let roots = match p.get("items").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect(), + None => s.state.project_selection.clone(), + }; + if roots.is_empty() { + return Err(bad("file.exportSelectionProject", "select items in the Project panel")); + } + let keep = closure(&s.project, &roots); + let mut sub = project_subset(&s.project, &keep); + let name = std::path::Path::new(&path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| sub.name.clone()); + sub.name = name.clone(); + sub.root.name = name; + s.services.write_file(&path, &filmcraft_format::encode(&sub, false)).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(json!({"path": path, "items": keep.iter().map(|i| i.0).collect::>()})) +} + +fn export_ale(s: &mut Session, p: &Value) -> Result { + let path = str_p(p, "path").ok_or_else(|| bad("file.exportAle", "need `path`"))?.to_string(); + let mut items: Vec = match p.get("items").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect(), + None => s.state.project_selection.clone(), + }; + if items.is_empty() { + // the whole project, in Project panel order + s.project.root.all_items(&mut items); + } + let doc = filmcraft_interchange::ale::export_items(&s.project, &items); + if doc.rows.is_empty() { + return Err(EngineError::Other("none of the items is a clip that can be logged".into())); + } + s.services.write_file(&path, filmcraft_interchange::ale::write(&doc).as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(json!({"path": path, "clips": doc.rows.len()})) +} + +// --------------------------------------------------------------------------------------------- +// media properties, settings +// --------------------------------------------------------------------------------------------- + +/// File properties of a probed media file (Get Media File Properties). +pub fn info_json(name: &str, path: Option<&str>, info: &filmcraft_media::MediaInfo) -> Value { + let rate = info.frame_rate(); + let secs = info.duration.seconds(); + let mut v = json!({ + "name": name, + "path": path, + "type": format!("{:?}", info.kind), + "container": info.container, + "fileSize": info.file_size, + "duration": {"ticks": info.duration.0, "seconds": secs, "timecode": filmcraft_time::format_timecode_frames(rate.frame_at(info.duration), rate, false)}, + "startTimecode": info.start_timecode.map(|f| filmcraft_time::format_timecode_frames(f, rate, false)), + "dataRateKbps": info.file_size.filter(|_| secs > 0.0).map(|b| (b as f64 * 8.0 / secs / 1000.0).round()), + }); + if let Some(vs) = &info.video { + v["video"] = json!({ + "codec": vs.codec, "width": vs.width, "height": vs.height, "frameRate": vs.frame_rate.as_f64(), "frameRateLabel": vs.frame_rate.label(), + "pixelAspectRatio": format!("{}:{}", vs.par.0, vs.par.1), "pixelFormat": vs.pixel_format, "alpha": vs.has_alpha, + "bitrate": vs.bitrate, "color": format!("{:?} / {:?} / {:?}", vs.color.primaries, vs.color.transfer, vs.color.matrix), + }); + } + if let Some(a) = &info.audio { + v["audio"] = json!({"codec": a.codec, "sampleRate": a.sample_rate, "channels": a.channels, "bitsPerSample": a.bits_per_sample}); + } + v +} + +fn media_properties(s: &mut Session, p: &Value) -> Result { + let mut out = Vec::new(); + for id in targets(s, p) { + let Some((mid, m)) = media_of(&s.project, id) else { continue }; + let name = s.project.item(id).map(|i| i.name.clone()).unwrap_or_default(); + let mut v = info_json(&name, file_path(m), &m.info); + v["item"] = json!(id.0); + v["offline"] = json!(m.offline || s.media.offline_status(mid).is_some()); + out.push(v); + } + if out.is_empty() { + return Err(EngineError::Other("select a clip".into())); + } + Ok(Value::Array(out)) +} + +fn media_properties_file(s: &mut Session, p: &Value) -> Result { + let path = str_p(p, "path").ok_or_else(|| bad("file.mediaPropertiesFile", "need `path`"))?; + let src = s.media.open_file(path, &*s.services)?; + let name = std::path::Path::new(path).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default(); + Ok(info_json(&name, Some(path), src.info())) +} + +fn pair_p(p: &Value, k: &str) -> Option<(f64, f64)> { + let a = p.get(k)?.as_array()?; + Some((a.first()?.as_f64()?.clamp(0.0, 50.0), a.get(1)?.as_f64()?.clamp(0.0, 50.0))) +} + +pub const RENDERER_GPU: &str = "FilmCraft GPU Acceleration (wgpu)"; +pub const RENDERER_SOFTWARE: &str = "FilmCraft Software Only (CPU)"; + +fn general_json(st: &filmcraft_project::ProjectSettings) -> Value { + json!({ + "renderer": st.renderer, + "renderers": [RENDERER_GPU, RENDERER_SOFTWARE], + "videoDisplay": st.video_display.label(), + "audioDisplay": if st.audio_display_samples { "Audio Samples" } else { "Milliseconds" }, + "captureFormat": st.capture_format, + "titleSafe": [st.title_safe.0, st.title_safe.1], + "actionSafe": [st.action_safe.0, st.action_safe.1], + }) +} + +fn project_settings_general(s: &mut Session, p: &Value) -> Result { + let cmd = "file.projectSettings.general"; + let mut st = s.project.settings.clone(); + if let Some(r) = str_p(p, "renderer") { + st.renderer = match r.to_ascii_lowercase().as_str() { + "gpu" => RENDERER_GPU.into(), + "software" | "cpu" => RENDERER_SOFTWARE.into(), + _ if r == RENDERER_GPU || r == RENDERER_SOFTWARE => r.into(), + _ => return Err(bad(cmd, "`renderer` is gpu or software")), + }; + } + if let Some(d) = str_p(p, "videoDisplay") { + let k = d.to_ascii_lowercase().replace([' ', '-'], ""); + st.video_display = TimeDisplay::ALL + .into_iter() + .find(|x| x.label().to_ascii_lowercase().replace([' ', '-'], "") == k || format!("{x:?}").to_ascii_lowercase() == k) + .ok_or_else(|| bad(cmd, format!("unknown video display format `{d}`")))?; + } + if let Some(a) = str_p(p, "audioDisplay") { + st.audio_display_samples = match a.to_ascii_lowercase().as_str() { + "samples" | "audio samples" => true, + "milliseconds" | "ms" => false, + _ => return Err(bad(cmd, "`audioDisplay` is samples or milliseconds")), + }; + } + if let Some(c) = str_p(p, "captureFormat") { + if !matches!(c, "DV" | "HDV") { + return Err(bad(cmd, "`captureFormat` is DV or HDV")); + } + st.capture_format = c.into(); + } + if let Some(t) = pair_p(p, "titleSafe") { + st.title_safe = t; + } + if let Some(a) = pair_p(p, "actionSafe") { + st.action_safe = a; + } + if st != s.project.settings { + s.edit("Project Settings", |pr, _| { + pr.settings = st; + Ok(()) + })?; + } + Ok(general_json(&s.project.settings)) +} + +/// Where each scratch disk resolves to now (`Same as Project` = next to the project file, or the +/// data directory for an unsaved project). +pub fn scratch_paths(s: &Session) -> Value { + let sc = &s.project.settings.scratch; + let project_dir = s.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map(|d| d.to_string_lossy().to_string()); + let same = |o: &Option| o.clone().or(project_dir.clone()).unwrap_or_else(|| "(data directory until the project is saved)".into()); + json!({ + "captured": {"setting": sc.captured, "path": same(&sc.captured)}, + "videoPreviews": {"setting": sc.video_previews, "path": previews_dir(s).map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| same(&sc.video_previews))}, + "audioPreviews": {"setting": sc.audio_previews, "path": same(&sc.audio_previews)}, + "autoSave": {"setting": sc.auto_save, "path": sc.auto_save.clone().or_else(|| s.path.as_deref().map(|p| filmcraft_format::autosave::auto_save_dir(std::path::Path::new(p)).to_string_lossy().to_string())).unwrap_or_else(|| same(&None))}, + }) +} + +/// Render previews folder of the open project: Scratch Disks ▸ Video Previews / ``, else `FilmCraft Previews/` next to the project (None while unsaved). +pub fn previews_dir(s: &Session) -> Option { + s.path.as_deref().map(|p| previews_dir_for(&s.project, p)) +} + +/// [`previews_dir`] for `project` saved at `path`. +pub fn previews_dir_for(project: &Project, path: &str) -> std::path::PathBuf { + match &project.settings.scratch.video_previews { + Some(d) if !d.is_empty() => { + let stem = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled".into()); + std::path::Path::new(d).join(stem) + } + _ => crate::previews::dir_for_project(path), + } +} + +fn project_settings_scratch(s: &mut Session, p: &Value) -> Result { + let mut sc = s.project.settings.scratch.clone(); + for (k, slot) in + [("captured", &mut sc.captured), ("videoPreviews", &mut sc.video_previews), ("audioPreviews", &mut sc.audio_previews), ("autoSave", &mut sc.auto_save)] + { + match p.get(k) { + None => {} + Some(Value::Null) => *slot = None, + Some(Value::String(x)) if x.is_empty() || x.eq_ignore_ascii_case("same as project") => *slot = None, + Some(Value::String(x)) => *slot = Some(x.clone()), + Some(_) => return Err(bad("file.projectSettings.scratchDisks", format!("`{k}` is a folder path or null"))), + } + } + if sc != s.project.settings.scratch { + s.edit("Scratch Disks", |pr, _| { + pr.settings.scratch = sc; + Ok(()) + })?; + s.previews_follow_path(); + } + Ok(scratch_paths(s)) +} + +// --------------------------------------------------------------------------------------------- +// Edit Original, clip items +// --------------------------------------------------------------------------------------------- + +fn edit_original(s: &mut Session, p: &Value) -> Result { + let mut paths = Vec::new(); + for id in targets(s, p) { + if let Some(path) = media_of(&s.project, id).and_then(|(_, m)| file_path(m)) + && !paths.iter().any(|x: &String| x == path) + { + paths.push(path.to_string()); + } + } + if paths.is_empty() { + return Err(EngineError::Other("select a clip with a media file".into())); + } + Ok(json!({"paths": paths})) +} + +/// Metadata fields of the Edit Offline dialog: (param, metadata key). +pub const OFFLINE_FIELDS: [(&str, &str); 5] = + [("tapeName", "Tape Name"), ("description", "Description"), ("scene", "Scene"), ("shot", "Shot"), ("logNote", "Log Note")]; + +fn edit_offline(s: &mut Session, p: &Value) -> Result { + let id = targets(s, p).into_iter().find(|i| is_offline(s, *i)).ok_or_else(|| EngineError::Other("select an offline clip".into()))?; + let name = str_p(p, "mediaName").map(str::to_string); + let fields: Vec<(&'static str, String)> = OFFLINE_FIELDS.iter().filter_map(|(k, key)| str_p(p, k).map(|v| (*key, v.to_string()))).collect(); + s.edit("Edit Offline", |pr, _| { + let it = pr.item_mut(id).ok_or_else(|| bad("clip.editOffline", "no such item"))?; + if let Some(n) = name.filter(|n| !n.is_empty()) { + it.name = n; + } + for (k, v) in fields { + if v.is_empty() { + it.metadata.remove(k); + } else { + it.metadata.insert(k.to_string(), v); + } + } + Ok(()) + })?; + let it = s.project.item(id).ok_or_else(|| bad("clip.editOffline", "no such item"))?; + Ok(json!({"item": id.0, "name": it.name, "metadata": it.metadata})) +} + +fn source_settings(s: &mut Session, p: &Value) -> Result { + let id = targets(s, p).into_iter().find(|i| media_of(&s.project, *i).is_some()).ok_or_else(|| EngineError::Other("select a clip".into()))?; + let (_, m) = media_of(&s.project, id).ok_or_else(|| EngineError::Other("select a clip".into()))?; + let codec = m.info.video.as_ref().map(|v| v.codec.clone()).or_else(|| m.info.audio.as_ref().map(|a| a.codec.clone())).unwrap_or_default(); + Ok(json!({ + "item": id.0, + "codec": codec, + "container": m.info.container, + "settings": [], + "message": format!("{} has no source settings in FilmCraft. Source settings apply to camera raw formats (R3D, ARRIRAW, CinemaDNG, ProRes RAW…), which FilmCraft does not decode.", if codec.is_empty() { "This clip".to_string() } else { codec.to_uppercase() }), + })) +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +/// An XMP packet (ISO 16684-1) with the item's name and log metadata (Dublin Core and the XMP +/// Dynamic Media schema). +pub fn xmp_packet(it: &filmcraft_project::ProjectItem) -> String { + let meta = |k: &str| it.metadata.iter().find(|(x, _)| x.eq_ignore_ascii_case(k)).map(|(_, v)| xml_escape(v)); + let mut body = format!(" {}\n", xml_escape(&it.name)); + if let Some(d) = meta("Description") { + body.push_str(&format!(" {d}\n")); + } + for (k, tag) in [("Scene", "scene"), ("Shot", "shotName"), ("Log Note", "logComment"), ("Tape Name", "tapeName"), ("Comment", "comment")] { + if let Some(v) = meta(k) { + body.push_str(&format!(" {v}\n")); + } + } + format!( + "\n\n \n \n{body} \n \n\n\n" + ) +} + +/// The XMP sidecar of a media file: `/.xmp`. +pub fn sidecar_path(media: &str) -> String { + std::path::Path::new(media).with_extension("xmp").to_string_lossy().to_string() +} + +/// Clip ▸ Update Metadata…: write the items' metadata to XMP sidecars next to their media. An +/// existing sidecar written by another application is left alone. +fn update_metadata(s: &mut Session, p: &Value) -> Result { + let mut written = Vec::new(); + let mut skipped = Vec::new(); + for id in targets(s, p) { + let Some(it) = s.project.item(id) else { continue }; + let Some(path) = media_of(&s.project, id).and_then(|(_, m)| file_path(m)) else { continue }; + let side = sidecar_path(path); + if let Ok(old) = s.services.read_file(&side) + && !String::from_utf8_lossy(&old).contains("xmp:CreatorTool=\"FilmCraft\"") + { + skipped.push(json!({"item": id.0, "path": side, "reason": "an XMP file written by another application exists"})); + continue; + } + match s.services.write_file(&side, xmp_packet(it).as_bytes()) { + Ok(()) => written.push(json!({"item": id.0, "path": side})), + Err(e) => skipped.push(json!({"item": id.0, "path": side, "reason": e.to_string()})), + } + } + if written.is_empty() && skipped.is_empty() { + return Err(EngineError::Other("select a clip with a media file".into())); + } + Ok(json!({"written": written, "skipped": skipped})) +} + +fn generate_waveform(s: &mut Session, p: &Value) -> Result { + let items: Vec = targets(s, p).into_iter().filter_map(|i| media_of(&s.project, i).filter(|(_, m)| m.info.has_audio()).map(|(mid, _)| mid.0)).collect(); + if items.is_empty() { + return Err(EngineError::Other("select a clip with audio".into())); + } + Ok(json!({"items": items})) +} + +// --------------------------------------------------------------------------------------------- +// Automate to Sequence +// --------------------------------------------------------------------------------------------- + +/// Clip ▸ Automate to Sequence…: place the selected Project panel items in the active sequence +/// at the playhead (sequentially, or at the sequence's unnumbered — unnamed — markers), by insert +/// or overwrite, on the source-patched tracks. Consecutive clips overlap by `overlapFrames` +/// (taken from the outgoing clip's tail) and get the default transitions over the overlap. One +/// undo step. +fn automate_to_sequence(s: &mut Session, p: &Value) -> Result { + let cmd = "clip.automateToSequence"; + let ordering = str_p(p, "ordering").unwrap_or("sort"); + let placement = str_p(p, "placement").unwrap_or("sequentially"); + let method = str_p(p, "method").unwrap_or("insert"); + if !matches!(ordering, "sort" | "selection") || !matches!(placement, "sequentially" | "unnumberedMarkers") || !matches!(method, "insert" | "overwrite") { + return Err(bad(cmd, "ordering: sort|selection, placement: sequentially|unnumberedMarkers, method: insert|overwrite")); + } + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let rate = q.settings.frame_rate; + let fd = rate.frame_duration(); + let mut items: Vec = match p.get("items").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect(), + None => s.state.project_selection.clone(), + }; + let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?; + items.retain(|i| *i != seq_id && s.project.item(*i).is_some_and(|it| !matches!(it.kind, ItemKind::Graphic { .. }))); + if ordering == "sort" { + let mut order = Vec::new(); + s.project.root.all_items(&mut order); + items.sort_by_key(|i| order.iter().position(|x| x == i).unwrap_or(usize::MAX)); + } + if items.is_empty() { + return Err(EngineError::Other("select clips in the Project panel".into())); + } + let tg = s.targeting(); + let vdest = if bool_p(p, "ignoreVideo").unwrap_or(false) { None } else { tg.video_dest }; + let adest = if bool_p(p, "ignoreAudio").unwrap_or(false) { None } else { tg.audio_dest }; + if vdest.is_none() && adest.is_none() { + return Err(EngineError::Other("no destination track (check source patching, or don't ignore both audio and video)".into())); + } + let overlap = if placement == "sequentially" { rate.tick_of(u64_p(p, "overlapFrames").unwrap_or(30) as i64) } else { Tick::ZERO }; + let still = u64_p(p, "stillFrames").map(|f| rate.tick_of(f as i64)); + // each item's range (In/Out marks; stills: `stillFrames` or the default still duration) + let mut plan: Vec<(ItemId, TimeRange)> = Vec::new(); + for id in &items { + let Some(mut r) = crate::clip_ops::item_range(&s.project, *id) else { continue }; + let is_still = s.project.item(*id).and_then(|i| i.as_media()).is_some_and(|m| matches!(m.info.kind, filmcraft_media::MediaKind::Still)); + if is_still && let Some(d) = still { + r = TimeRange::new(r.start, d); + } + // snap to whole sequence frames + let d = rate.tick_of(rate.frame_at(r.duration + Tick(fd.0 / 2)).max(1)); + plan.push((*id, TimeRange::new(r.start, d))); + } + // destinations + let starts: Vec = if placement == "unnumberedMarkers" { + let mut m: Vec = q.markers.iter().filter(|m| m.name.is_empty() && m.kind == MarkerKind::Comment).map(|m| rate.snap(m.start)).collect(); + m.sort(); + if m.is_empty() { + return Err(EngineError::Other("the sequence has no unnumbered markers".into())); + } + plan.truncate(m.len()); + m.truncate(plan.len()); + m + } else { + // the overlap can't exceed half of either clip + let mut at = s.playhead(); + let mut v = Vec::new(); + for (k, (_, r)) in plan.iter().enumerate() { + if k > 0 { + let prev = plan[k - 1].1.duration; + let ov = overlap.min(Tick(prev.0 / 2)).min(Tick(r.duration.0 / 2)); + at -= rate.snap(ov); + } + v.push(at); + at += r.duration; + } + v + }; + let n0 = s.history.undo.len(); + // insert: open room first (sync-locked / destination tracks ripple), then overwrite into it + if method == "insert" && placement == "sequentially" { + let first = starts[0]; + let end = starts.iter().zip(&plan).map(|(a, (_, r))| *a + r.duration).max().unwrap_or(first); + let len = end - first; + let dests: Vec<_> = [vdest, adest].into_iter().flatten().collect(); + s.edit_sequence("Automate to Sequence", |q, ctx, _| { + let mut links = std::collections::HashMap::new(); + for t in q.video_tracks.iter_mut().chain(q.audio_tracks.iter_mut()) { + if !t.locked && (t.sync_lock || dests.contains(&t.id)) { + filmcraft_edit::insert_track_gap(t, first, len, ctx, &mut links); + } + } + Ok(()) + })?; + } + let mut placed = Vec::new(); + let mut result: Result<()> = Ok(()); + for (k, ((id, r), at)) in plan.iter().zip(&starts).enumerate() { + let insert = method == "insert" && placement == "unnumberedMarkers"; + match crate::commands::place_item(s, *id, *r, *at, vdest, adest, insert, "Automate to Sequence", None) { + Ok(ids) => placed.push((k, ids)), + Err(e) => { + result = Err(e); + break; + } + } + } + if let Err(e) = result { + // undo everything this command did + while s.history.undo.len() > n0 { + s.undo(); + } + s.history.redo.clear(); + return Err(e); + } + // default transitions over the overlaps (and at the cuts when there is no overlap) + let vt = bool_p(p, "videoTransition").unwrap_or(true); + let at = bool_p(p, "audioTransition").unwrap_or(true); + let mut transitions = 0; + if placement == "sequentially" && (vt || at) { + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let mut edges = Vec::new(); + for (_, ids) in placed.iter().skip(1) { + for c in ids { + let Some((tid, _)) = q.find_item(*c) else { continue }; + let kind = q.track(tid).map(|t| t.kind); + if (kind == Some(TrackKind::Video) && vt) || (kind == Some(TrackKind::Audio) && at) { + edges.push((*c, kind == Some(TrackKind::Video))); + } + } + } + let frames = if overlap > Tick::ZERO { rate.frame_at(overlap) } else { s.project.settings.default_transition_duration_frames }; + for (c, video) in edges { + let id = if video { "sequence.applyVideoTransition" } else { "sequence.applyAudioTransition" }; + if s.execute(id, json!({"clip": c.0, "edge": "in", "frames": frames})).is_ok() { + transitions += 1; + } + } + } + crate::clip_ops::collapse_history(s, n0, "Automate to Sequence"); + let clips: Vec = placed.iter().flat_map(|(_, ids)| ids.iter().map(|c| c.0)).collect(); + s.state.selection = clips.iter().map(|c| filmcraft_project::ClipId(*c)).collect(); + Ok(json!({"placed": placed.len(), "clips": clips, "transitions": transitions, "starts": starts.iter().map(|t| t.0).collect::>()})) +} + +// --------------------------------------------------------------------------------------------- +// System Compatibility Report +// --------------------------------------------------------------------------------------------- + +/// What this build supports: OS, CPU, decoders / containers and export formats. The frontend adds +/// the GPU adapter. +pub fn system_report() -> Value { + let cpus = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); + let formats: Vec = + filmcraft_export::Format::ALL.iter().map(|f| json!({"format": f.label(), "available": filmcraft_export::available(*f)})).collect(); + json!({ + "app": format!("FilmCraft {}", env!("CARGO_PKG_VERSION")), + "os": std::env::consts::OS, + "family": std::env::consts::FAMILY, + "arch": std::env::consts::ARCH, + "cpuThreads": cpus, + "decoders": ["H.264 (AVC)", "H.265 (HEVC) Main / Main 10", "VP9", "AV1 Main", "Apple ProRes", "Avid DNxHD / DNxHR", "Motion JPEG", "AAC-LC", "Opus", "PCM"], + "containers": ["MP4 / MOV (ISO BMFF)", "Matroska / WebM", "WAV", "PNG / JPEG stills"], + "exportFormats": formats, + "speechToText": crate::transcript::speech_available(), + "checks": [ + {"name": "Operating system", "ok": true, "detail": format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)}, + {"name": "CPU", "ok": cpus >= 4, "detail": format!("{cpus} hardware threads{}", if cpus < 4 { " — 4 or more recommended for 1080p playback" } else { "" })}, + ], + }) +} + +#[cfg(test)] +#[path = "project_tools_tests.rs"] +mod tests; diff --git a/crates/engine/src/project_tools_tests.rs b/crates/engine/src/project_tools_tests.rs new file mode 100644 index 00000000..5ff4ae63 --- /dev/null +++ b/crates/engine/src/project_tools_tests.rs @@ -0,0 +1,403 @@ +//! Tests of the M3.11 File / Edit / Clip commands (`project_tools`). + +use filmcraft_media::DemoScene; +use filmcraft_project::{ItemId, ItemKind}; +use filmcraft_time::Tick; +use serde_json::json; + +use crate::Session; +use crate::media_test_util::{make_movie, session_with, tmp_dir}; + +fn demo() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s +} + +fn item_named(s: &Session, name: &str) -> ItemId { + s.project.items.values().find(|i| i.name == name).map(|i| i.id).unwrap_or_else(|| panic!("no item {name}")) +} + +fn menu_ids(path: &[&str]) -> Vec<&'static str> { + crate::commands::command_specs().iter().filter(|c| c.menu == path).map(|c| c.id).collect() +} + +fn pos(ids: &[&str], id: &str) -> usize { + ids.iter().position(|x| *x == id).unwrap_or_else(|| panic!("{id} not in {ids:?}")) +} + +fn assert_order(ids: &[&str], order: &[&str]) { + for w in order.windows(2) { + assert!(pos(ids, w[0]) < pos(ids, w[1]), "{} before {}: {ids:?}", w[0], w[1]); + } +} + +#[test] +fn file_edit_clip_menu_order_and_shortcuts_match_premiere() { + assert_order(&menu_ids(&["File", "New"]), &["file.newBin", "file.newBinFromSelection", "file.newSearchBin", "file.newOfflineFile"]); + assert_order( + &menu_ids(&["File"]), + &[ + "file.open", + "file.close", + "file.closeProject", + "file.closeAllProjects", + "file.closeAllOtherProjects", + "file.save", + "file.saveCopy", + "file.saveAsTemplate", + "file.saveAll", + "file.revert", + "media.makeOffline", + "file.importFromMediaBrowser", + "file.import", + "file.projectManager", + ], + ); + assert_order(&menu_ids(&["File", "Export"]), &["file.exportEdl", "file.exportSelectionProject", "file.exportAle", "file.exportOtio", "file.exportFcp7Xml"]); + assert_eq!(menu_ids(&["File", "Get Media File Properties for"]), vec!["file.mediaPropertiesFile", "file.mediaProperties"]); + assert_eq!(menu_ids(&["File", "Project Settings"]), vec!["file.projectSettings.general", "file.projectSettings.scratchDisks", "project.ingestSettings"]); + assert_order(&menu_ids(&["Edit"]), &["edit.deselectAll", "edit.find", "edit.findNext", "edit.consolidateDuplicates", "edit.editOriginal"]); + let clip: Vec<&str> = crate::commands::command_specs().iter().filter(|c| c.menu.first() == Some(&"Clip")).map(|c| c.id).collect(); + assert_order( + &clip, + &[ + "clip.editSubclip", + "clip.editOffline", + "clip.sourceSettings", + "clip.speedDuration", + "clip.sceneEditDetection", + "clip.replaceFromBin", + "clip.restoreCaptionsFromSource", + "clip.updateMetadata", + "clip.generateAudioWaveform", + "clip.automateToSequence", + ], + ); + for (id, label, sc) in [ + ("file.close", "Close", Some("Cmd+W")), + ("file.importFromMediaBrowser", "Import from Media Browser", Some("Cmd+Alt+I")), + ("file.mediaProperties", "Selection…", Some("Cmd+Shift+H")), + ("file.exportSelectionProject", "Selection as FilmCraft Project…", None), + ("file.exportAle", "Avid Log Exchange…", None), + ("edit.find", "Find…", Some("Cmd+F")), + ("edit.editOriginal", "Edit Original", Some("Cmd+E")), + ("clip.sceneEditDetection", "Scene Edit Detection…", None), + ("clip.automateToSequence", "Automate to Sequence…", None), + ] { + let c = crate::commands::find(id).unwrap(); + assert_eq!((c.label, c.shortcut), (label, sc), "{id}"); + } + // the default shortcut set binds them (no conflicts with existing keys) + let s = Session::default(); + assert_eq!(s.shortcuts.primary("edit.find").as_deref(), Some("Cmd+F")); + assert_eq!(s.shortcuts.primary("edit.editOriginal").as_deref(), Some("Cmd+E")); +} + +#[test] +fn search_bins_update_live() { + let mut s = demo(); + let r = s.execute("file.newSearchBin", json!({"column": "Name", "operator": "contains", "text": "ocean"})).unwrap(); + let bin = r["bin"].as_u64().unwrap(); + assert_eq!(r["name"], "ocean"); + let ocean = item_named(&s, "Ocean_Sunset.mp4"); + assert_eq!(r["items"], json!([ocean.0])); + // renaming another item to match adds it to the bin + let dunes = item_named(&s, "Desert_Dunes.mp4"); + s.execute("clip.rename", json!({"item": dunes.0, "name": "Ocean Dunes"})).unwrap(); + let r = s.execute("project.searchBinItems", json!({"bin": bin})).unwrap(); + assert_eq!(r["items"].as_array().unwrap().len(), 2); + // edit the query, rename, delete; all undoable + s.execute("project.editSearchBin", json!({"bin": bin, "name": "Movies", "column": "Media Type", "operator": "matches", "text": "movie"})).unwrap(); + let r = s.execute("project.searchBinItems", json!({"bin": bin})).unwrap(); + assert_eq!(r["name"], "Movies"); + assert_eq!(r["items"].as_array().unwrap().len(), 6, "the six demo scenes: {r}"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.search_bins[0].query.rows[0].text, "ocean"); + s.execute("project.deleteSearchBin", json!({"bin": bin})).unwrap(); + assert!(s.project.search_bins.is_empty()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.search_bins.len(), 1); + // saved with the project + let back = filmcraft_format::decode(&filmcraft_format::encode(&s.project, false)).unwrap().project; + assert_eq!(back.search_bins, s.project.search_bins); + assert!(s.execute("file.newSearchBin", json!({"text": ""})).is_err()); + assert!(s.execute("file.newSearchBin", json!({"text": "x", "operator": "near"})).is_err()); +} + +#[test] +fn find_and_find_next_in_the_project_and_timeline() { + let mut s = demo(); + assert!(!s.is_enabled("edit.findNext")); + let r = s.execute("edit.find", json!({"column": "Name", "operator": "endsWith", "text": ".mp4"})).unwrap(); + let n = r["matches"].as_u64().unwrap(); + assert_eq!(n, 5, "{r}"); + let first = r["item"].as_u64().unwrap(); + assert_eq!(s.state.project_selection, vec![ItemId(first)]); + let r2 = s.execute("edit.findNext", json!({})).unwrap(); + assert_ne!(r2["item"], r["item"]); + for _ in 0..(n - 1) { + s.execute("edit.findNext", json!({})).unwrap(); + } + assert_eq!(s.state.project_selection, vec![ItemId(first)], "Find Next wraps around"); + // two rows, match any + let r = s + .execute( + "edit.find", + json!({"rows": [{"column": "Name", "operator": "beginsWith", "text": "Neon"}, {"column": "Label", "operator": "matches", "text": "Caribbean"}], "matchAll": false}), + ) + .unwrap(); + assert_eq!(r["matches"], 2); + assert!(s.execute("edit.find", json!({"text": "zzz"})).is_err()); + // timeline: clip names, then markers + let r = s.execute("edit.find", json!({"scope": "timeline", "text": "city"})).unwrap(); + let c = filmcraft_project::ClipId(r["clip"].as_u64().unwrap()); + assert_eq!(s.state.selection, vec![c]); + assert_eq!(s.playhead(), s.active_sequence().unwrap().find_item(c).unwrap().1.start); + assert_eq!(r["matches"], 2, "video and audio clip: {r}"); + s.execute("markers.add", json!({"seconds": 3.0, "name": "Pickup line"})).unwrap(); + let r = s.execute("edit.find", json!({"scope": "timeline", "text": "pickup", "in": "markers"})).unwrap(); + assert!(r.get("marker").is_some()); + assert_eq!(s.playhead(), s.sequence_rate().snap(Tick::from_seconds_f64(3.0))); +} + +#[test] +fn close_commands_and_templates() { + let mut s = demo(); + let seq = s.state.active_sequence.unwrap(); + s.execute("file.close", json!({})).unwrap(); + assert!(!s.state.open_sequences.contains(&seq)); + assert!(!s.is_enabled("file.closeAllOtherProjects")); + assert!(s.execute("file.closeAllOtherProjects", json!({})).is_err()); + // template round trip + let dir = tmp_dir("templates"); + let path = dir.join("Doc Template.fcproj").to_string_lossy().to_string(); + let r = s.execute("file.saveAsTemplate", json!({"path": path, "name": "Doc Template"})).unwrap(); + assert_eq!(r["path"], path); + let n = s.project.items.len(); + s.execute("file.newBin", json!({"name": "Unsaved"})).unwrap(); + assert!(s.execute("file.closeAllProjects", json!({})).is_err(), "dirty: needs force"); + s.execute("file.closeAllProjects", json!({"force": true})).unwrap(); + assert!(s.project.items.is_empty()); + s.execute("file.newProjectFromTemplate", json!({"template": path, "name": "Ep 2"})).unwrap(); + assert!(s.project.items.values().any(|i| i.name == "Main Edit")); + assert_eq!(s.project.items.len(), n); + assert_eq!(s.project.name, "Ep 2"); + assert!(s.path.is_none() && !s.is_dirty()); +} + +#[test] +fn export_selection_project_and_ale() { + let mut s = demo(); + let main = s.state.active_sequence.unwrap(); + s.execute("project.select", json!({"items": [main.0]})).unwrap(); + let dir = tmp_dir("export-sel"); + let path = dir.join("Main Only.fcproj").to_string_lossy().to_string(); + let r = s.execute("file.exportSelectionProject", json!({"path": path})).unwrap(); + let p = filmcraft_format::decode(&std::fs::read(&path).unwrap()).unwrap().project; + assert_eq!(p.name, "Main Only"); + assert!(p.item(main).is_some()); + // the six scenes used by Main Edit come along; Rough Cut, the score, bars and leader don't + for n in ["Ocean_Sunset.mp4", "Neon_Loop.mov", "Misty_Forest.mp4"] { + assert!(p.items.values().any(|i| i.name == n), "{n}"); + } + for n in ["Rough Cut", "Bars and Tone", "Color Matte"] { + assert!(!p.items.values().any(|i| i.name == n), "{n}"); + } + assert_eq!(r["items"].as_array().unwrap().len(), p.items.len()); + // every bin left has something in it, and every kept item is still in a bin + let mut listed = Vec::new(); + p.root.all_items(&mut listed); + assert!(p.items.keys().all(|k| listed.contains(k) || matches!(p.item(*k).unwrap().kind, ItemKind::Graphic { .. }))); + // ALE of the selection, or of the whole project + let ale = dir.join("log.ale").to_string_lossy().to_string(); + s.execute("project.select", json!({"items": []})).unwrap(); + let r = s.execute("file.exportAle", json!({"path": ale})).unwrap(); + let doc = filmcraft_interchange::ale::parse(&std::fs::read_to_string(&ale).unwrap()).unwrap(); + assert_eq!(r["clips"].as_u64().unwrap() as usize, doc.rows.len()); + assert!(doc.rows.len() >= 9, "{}", doc.rows.len()); + assert_eq!(doc.heading("FPS"), Some("23.976")); + assert!(doc.rows.iter().any(|r| r[0] == "Ambient_Score.wav" && r[1] == "A1A2")); +} + +#[test] +fn media_properties_settings_and_scratch_disks() { + let dir = tmp_dir("props"); + let path = dir.join("clip.mp4"); + make_movie(&path, DemoScene::Forest, 64, 36, 12); + let (mut s, items, _) = session_with(&[&path]); + s.execute("project.select", json!({"items": [items[0].0]})).unwrap(); + let r = s.execute("file.mediaProperties", json!({})).unwrap(); + let v = &r[0]; + assert_eq!(v["video"]["width"], 64); + assert_eq!(v["video"]["codec"].as_str().map(|c| c.to_ascii_lowercase().contains("264") || c.contains("avc")), Some(true), "{v}"); + assert_eq!(v["audio"]["sampleRate"], 48000); + assert_eq!(v["path"], path.to_string_lossy().as_ref()); + let f = s.execute("file.mediaPropertiesFile", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(f["video"]["height"], 36); + assert!(s.execute("file.mediaPropertiesFile", json!({"path": dir.join("missing.mp4").to_string_lossy()})).is_err()); + // Project Settings ▸ General + let g = s.execute("file.projectSettings.general", json!({})).unwrap(); + assert_eq!(g["titleSafe"], json!([20.0, 20.0])); + let n = s.history.undo.len(); + s.execute( + "file.projectSettings.general", + json!({"renderer": "software", "videoDisplay": "frames", "audioDisplay": "milliseconds", "titleSafe": [15, 12], "captureFormat": "HDV"}), + ) + .unwrap(); + let st = &s.project.settings; + assert_eq!(st.renderer, crate::project_tools::RENDERER_SOFTWARE); + assert_eq!(st.video_display, filmcraft_time::TimeDisplay::Frames); + assert!(!st.audio_display_samples); + assert_eq!((st.title_safe, st.capture_format.as_str()), ((15.0, 12.0), "HDV")); + assert_eq!(s.history.undo.len(), n + 1); + assert!(s.execute("file.projectSettings.general", json!({"renderer": "quantum"})).is_err()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.settings.renderer, crate::project_tools::RENDERER_GPU); + // Scratch Disks: previews follow the Video Previews folder once the project is saved + let proj = dir.join("Show.fcproj").to_string_lossy().to_string(); + s.execute("file.saveAs", json!({"path": proj})).unwrap(); + assert_eq!(crate::project_tools::previews_dir(&s).unwrap(), dir.join("FilmCraft Previews").join("Show")); + let scratch = dir.join("scratch").to_string_lossy().to_string(); + let r = s.execute("file.projectSettings.scratchDisks", json!({"videoPreviews": scratch, "autoSave": scratch})).unwrap(); + assert_eq!(crate::project_tools::previews_dir(&s).unwrap(), dir.join("scratch").join("Show")); + assert_eq!(r["videoPreviews"]["path"], dir.join("scratch").join("Show").to_string_lossy().as_ref()); + assert_eq!(r["autoSave"]["path"], scratch); + assert_eq!(s.previews.dir(), Some(dir.join("scratch").join("Show"))); + s.execute("file.projectSettings.scratchDisks", json!({"videoPreviews": null})).unwrap(); + assert!(s.project.settings.scratch.video_previews.is_none()); + assert!(s.execute("file.projectSettings.scratchDisks", json!({"captured": 5})).is_err()); +} + +#[test] +fn edit_original_update_metadata_and_clip_items() { + let dir = tmp_dir("orig"); + let path = dir.join("take.mp4"); + make_movie(&path, DemoScene::Aurora, 64, 36, 8); + let (mut s, items, _) = session_with(&[&path]); + s.execute("project.select", json!({"items": [items[0].0]})).unwrap(); + let r = s.execute("edit.editOriginal", json!({})).unwrap(); + assert_eq!(r["paths"], json!([path.to_string_lossy()])); + // generator media has no original file + let mut d = demo(); + let ocean = item_named(&d, "Ocean_Sunset.mp4"); + d.execute("project.select", json!({"items": [ocean.0]})).unwrap(); + assert!(!d.is_enabled("edit.editOriginal")); + assert!(d.is_enabled("clip.sourceSettings")); + let ss = d.execute("clip.sourceSettings", json!({})).unwrap(); + assert!(ss["message"].as_str().unwrap().contains("no source settings")); + assert!(!d.is_enabled("clip.restoreCaptionsFromSource")); + let w = d.execute("clip.generateAudioWaveform", json!({})).unwrap(); + assert_eq!(w["items"], json!([ocean.0])); + // Update Metadata writes an XMP sidecar with the log fields + let mut p = (*s.project).clone(); + let it = p.item_mut(items[0]).unwrap(); + it.metadata.insert("Scene".into(), "12A".into()); + it.metadata.insert("Log Note".into(), "best ".into()); + s.project = std::sync::Arc::new(p); + let r = s.execute("clip.updateMetadata", json!({})).unwrap(); + let side = dir.join("take.xmp"); + assert_eq!(r["written"][0]["path"], side.to_string_lossy().as_ref()); + let x = std::fs::read_to_string(&side).unwrap(); + assert!(x.contains("12A") && x.contains("best <take>"), "{x}"); + assert!(x.contains("take.mp4")); + // a sidecar from another application is left alone + std::fs::write(&side, "someone else's").unwrap(); + let r = s.execute("clip.updateMetadata", json!({})).unwrap(); + assert!(r["written"].as_array().unwrap().is_empty() && r["skipped"].as_array().unwrap().len() == 1); + assert!(std::fs::read_to_string(&side).unwrap().contains("someone else")); +} + +#[test] +fn edit_offline_sets_the_log_fields() { + let mut s = demo(); + let r = s.execute("file.newOfflineFile", json!({"name": "Missing Take", "seconds": 4.0})).unwrap(); + let id = ItemId(r["item"].as_u64().unwrap()); + let ocean = item_named(&s, "Ocean_Sunset.mp4"); + s.execute("project.select", json!({"items": [ocean.0]})).unwrap(); + assert!(!s.is_enabled("clip.editOffline"), "online clip"); + s.execute("project.select", json!({"items": [id.0]})).unwrap(); + assert!(s.is_enabled("clip.editOffline")); + s.execute("clip.editOffline", json!({"mediaName": "Take 7", "tapeName": "A007", "scene": "4", "shot": "2", "logNote": "reshoot", "description": "wide"})) + .unwrap(); + let it = s.project.item(id).unwrap(); + assert_eq!(it.name, "Take 7"); + assert_eq!(it.metadata.get("Tape Name").map(String::as_str), Some("A007")); + assert_eq!(it.metadata.get("Log Note").map(String::as_str), Some("reshoot")); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.item(id).unwrap().name, "Missing Take"); +} + +#[test] +fn automate_to_sequence_places_clips_with_overlap_and_transitions() { + let mut s = demo(); + s.execute("file.newSequence", json!({"name": "Auto", "fps": 23.976})).unwrap(); + let rate = s.sequence_rate(); + let names = ["Misty_Forest.mp4", "Ocean_Sunset.mp4", "Aurora_Timelapse.mp4"]; + let ids: Vec = names.iter().map(|n| item_named(&s, n)).collect(); + // short In/Out ranges: 3 s each + for id in &ids { + s.execute("project.setMarks", json!({"item": id.0, "in": 0, "out": rate.tick_of(71).0})).unwrap(); + } + s.execute("project.select", json!({"items": ids.iter().map(|i| i.0).collect::>()})).unwrap(); + assert!(s.is_enabled("clip.automateToSequence")); + let n_undo = s.history.undo.len(); + let r = s.execute("clip.automateToSequence", json!({"ordering": "selection", "method": "overwrite", "overlapFrames": 12})).unwrap(); + assert_eq!(s.history.undo.len(), n_undo + 1, "one undo step"); + assert_eq!(r["placed"], 3); + let q = s.active_sequence().unwrap(); + let v1 = &q.video_tracks[0].items; + assert_eq!(v1.len(), 3); + // selection order; each clip starts 12 frames before the previous one ends + assert_eq!(v1.iter().map(|i| i.item).collect::>(), ids); + assert_eq!(v1[0].start, Tick::ZERO); + assert_eq!(v1[1].start, rate.tick_of(72 - 12)); + assert_eq!(v1[2].start, rate.tick_of(2 * (72 - 12))); + assert_eq!(r["transitions"], 4, "a video and an audio transition at each of the two cuts: {r}"); + assert_eq!(q.video_tracks[0].transitions.len(), 2); + assert!(q.video_tracks[0].transitions.iter().all(|t| t.duration == rate.tick_of(12))); + // sort order (bin order): Ocean, Aurora (bin order of the demo), Forest + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_sequence().unwrap().video_tracks[0].items.is_empty()); + s.execute( + "clip.automateToSequence", + json!({"ordering": "sort", "overlapFrames": 0, "videoTransition": false, "audioTransition": false, "ignoreAudio": true}), + ) + .unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!(q.video_tracks[0].items.iter().map(|i| i.item).collect::>(), vec![ids[1], ids[2], ids[0]]); + assert!(q.audio_tracks.iter().all(|t| t.items.is_empty())); + assert!(q.video_tracks[0].transitions.is_empty()); + // insert at the playhead pushes what is there + let end = q.video_tracks[0].items[2].end(); + s.set_playhead(Tick::ZERO); + s.execute("project.select", json!({"items": [ids[0].0]})).unwrap(); + s.execute("clip.automateToSequence", json!({"method": "insert", "ignoreAudio": true})).unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!(q.video_tracks[0].items.len(), 4); + assert_eq!(q.video_tracks[0].items[0].item, ids[0]); + assert_eq!(q.video_tracks[0].items.last().unwrap().end(), end + rate.tick_of(72)); + // at unnumbered markers + s.execute("file.newSequence", json!({"name": "Markers", "fps": 23.976})).unwrap(); + s.execute("markers.add", json!({"seconds": 10.0})).unwrap(); + s.execute("markers.add", json!({"seconds": 20.0})).unwrap(); + s.execute("project.select", json!({"items": ids.iter().map(|i| i.0).collect::>()})).unwrap(); + let r = s.execute("clip.automateToSequence", json!({"placement": "unnumberedMarkers", "method": "overwrite", "ordering": "selection"})).unwrap(); + assert_eq!(r["placed"], 2, "two markers, two clips"); + let q = s.active_sequence().unwrap(); + let starts: Vec = q.video_tracks[0].items.iter().map(|i| i.start).collect(); + assert_eq!(starts, vec![rate.snap(Tick::from_seconds_f64(10.0)), rate.snap(Tick::from_seconds_f64(20.0))]); + // disabled without a Project panel selection + s.execute("project.select", json!({"items": []})).unwrap(); + assert!(!s.is_enabled("clip.automateToSequence")); +} + +#[test] +fn system_report_lists_the_build() { + let s = &mut Session::default(); + let r = s.execute("help.systemReport", json!({})).unwrap(); + assert_eq!(r["os"], std::env::consts::OS); + assert!(r["cpuThreads"].as_u64().unwrap() >= 1); + assert!(r["decoders"].as_array().unwrap().iter().any(|d| d.as_str().unwrap().contains("H.264"))); + assert_eq!(r["exportFormats"].as_array().unwrap().len(), filmcraft_export::Format::ALL.len()); +} diff --git a/crates/engine/src/proxies.rs b/crates/engine/src/proxies.rs index beff7d65..5ed151d0 100644 --- a/crates/engine/src/proxies.rs +++ b/crates/engine/src/proxies.rs @@ -343,7 +343,10 @@ pub fn proxy_dir(media: &str, dest: Option<&str>) -> PathBuf { pub fn create(s: &mut Session, p: &Value) -> Result { let pr = preset(str_p(p, "preset").unwrap_or(DEFAULT_PROXY_PRESET)).ok_or_else(|| bad("media.createProxies", "unknown preset (see media.proxyPresets)"))?; let items = items_param(s, p); - let dest = str_p(p, "destination").map(str::to_string); + // default: Project Settings ▸ Scratch Disks ▸ Captured and Generated, else next to the media + let dest = str_p(p, "destination") + .map(str::to_string) + .or_else(|| s.project.settings.scratch.captured.clone().filter(|d| !d.is_empty()).map(|d| format!("{d}/Proxies"))); let mut work = Vec::new(); let mut taken = Vec::new(); let mut skipped = Vec::new(); diff --git a/crates/engine/src/scene_detect.rs b/crates/engine/src/scene_detect.rs new file mode 100644 index 00000000..ae8c63fa --- /dev/null +++ b/crates/engine/src/scene_detect.rs @@ -0,0 +1,318 @@ +//! Clip ▸ Scene Edit Detection… (M3.11). +//! +//! `clip.sceneEditDetection {clips?, sensitivity=50, minShotFrames=6, applyCuts=true, +//! createSubclips=false, generateMarkers=false, wait=false}` analyses the selected timeline video +//! clips in a background job (`jobs.list` shows the progress, `jobs.cancel` stops it without +//! changing anything). Every sequence frame of each clip is decoded at reduced size and compared +//! with the previous one ([`filmcraft_render::scene`]). When the job finishes, the results are +//! applied in one undo step ("Scene Edit Detection"): +//! +//! - **Apply a cut at each detected cut point**: the clip (and its linked audio) is cut at the +//! first frame of each new shot; +//! - **Create a subclip for each cut**: one subclip per shot, in a new bin " Scenes"; +//! - **Generate clip markers**: a marker on the clip's master clip (media time) at each cut. + +use std::sync::{Arc, Mutex}; + +use filmcraft_project::{ClipId, ItemId, ItemKind, Label, Marker, MarkerId, MarkerKind, TrackKind}; +use filmcraft_render::scene::{SceneDetector, SceneOptions}; +use filmcraft_time::{Tick, TimeRange}; +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, bad, bool_p, clips_p, f64_p, has_seq, u64_p}; +use crate::{EngineError, Result, Session}; + +/// Longest side of the frames analysed (speed; the measures don't need detail). +const ANALYSIS_WIDTH: f64 = 256.0; + +pub(crate) fn commands() -> Vec { + vec![CommandSpec { + id: "clip.sceneEditDetection", + label: "Scene Edit Detection…", + menu: &["Clip"], + shortcut: None, + params: r#"{"clips":[id]?,"sensitivity":0..100=50,"minShotFrames":n=6,"applyCuts":bool=true,"createSubclips":bool=false,"generateMarkers":bool=false,"wait":bool=false}"#, + enabled: has_video_selection, + run: detect, + journal: true, + }] +} + +fn has_video_selection(s: &Session) -> std::result::Result<(), String> { + has_seq(s)?; + let q = s.active_sequence().ok_or("no sequence is open")?; + let any = s.state.selection.iter().any(|c| q.find_item(*c).is_some_and(|(t, _)| q.track(t).is_some_and(|t| t.kind == TrackKind::Video))); + if any { Ok(()) } else { Err("select a video clip in the timeline".into()) } +} + +/// What to do with the cuts found. +#[derive(Clone, Copy, Debug, Default)] +pub struct SceneApply { + pub cuts: bool, + pub subclips: bool, + pub markers: bool, +} + +/// One analysed clip. +#[derive(Clone, Debug)] +pub struct ClipScenes { + pub clip: ClipId, + pub name: String, + /// The clip's media item (a subclip's parent). + pub media_item: ItemId, + /// Media range the clip shows. + pub media_range: TimeRange, + /// (timeline time, media time) of the first frame of each new shot. + pub cuts: Vec<(Tick, Tick)>, +} + +/// A running Scene Edit Detection job; applied by [`poll`] when it finishes. +pub struct PendingScene { + pub job: u64, + pub seq: ItemId, + pub apply: SceneApply, + pub results: Arc>>>, +} + +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// The media item a clip shows (a subclip resolves to its parent: subclip clips use the parent's +/// media time). +fn media_root(p: &filmcraft_project::Project, item: ItemId) -> Option { + match &p.item(item)?.kind { + ItemKind::Media(_) => Some(item), + ItemKind::Subclip { parent, .. } => media_root(p, *parent), + _ => None, + } +} + +struct Work { + clip: ClipId, + name: String, + media_item: ItemId, + src: filmcraft_media::SharedSource, + scale: f32, + /// (timeline time, media time) of every sequence frame of the clip. + frames: Vec<(Tick, Tick)>, +} + +fn detect(s: &mut Session, p: &Value) -> Result { + let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?; + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let fd = q.settings.frame_rate.frame_duration(); + let opts = SceneOptions { + sensitivity: f64_p(p, "sensitivity").unwrap_or(50.0).clamp(0.0, 100.0) as f32, + min_shot_frames: u64_p(p, "minShotFrames").unwrap_or(6).clamp(1, 10_000) as usize, + }; + let apply = SceneApply { + cuts: bool_p(p, "applyCuts").unwrap_or(true), + subclips: bool_p(p, "createSubclips").unwrap_or(false), + markers: bool_p(p, "generateMarkers").unwrap_or(false), + }; + if !(apply.cuts || apply.subclips || apply.markers) { + return Err(bad("clip.sceneEditDetection", "choose at least one of applyCuts, createSubclips, generateMarkers")); + } + if s.scene_jobs.iter().any(|j| j.seq == seq_id) { + return Err(EngineError::Other("Scene Edit Detection is already running on this sequence".into())); + } + let mut work = Vec::new(); + for c in clips_p(s, p) { + let Some((tid, it)) = q.find_item(c) else { continue }; + if q.track(tid).is_none_or(|t| t.kind != TrackKind::Video) { + continue; + } + let Some(media_item) = media_root(&s.project, it.item) else { continue }; + let Some(src) = s.source(it.item) else { continue }; + let Some(v) = src.info().video.clone() else { continue }; + let n = (it.duration.0 / fd.0.max(1)).max(1); + let frames: Vec<(Tick, Tick)> = (0..n) + .map(|j| { + let t = it.start + Tick(fd.0 * j); + (t, it.source_time_at(t)) + }) + .collect(); + work.push(Work { clip: c, name: it.name.clone(), media_item, src, scale: (ANALYSIS_WIDTH / v.width.max(1) as f64).min(1.0) as f32, frames }); + } + if work.is_empty() { + return Err(EngineError::Other("none of the selected clips is a video clip with media to analyse".into())); + } + let total: usize = work.iter().map(|w| w.frames.len()).sum(); + let id = s.jobs.iter().map(|j| j.id).max().unwrap_or(0) + 1; + let label = format!("Scene Edit Detection ({} clip{})", work.len(), if work.len() == 1 { "" } else { "s" }); + let job = crate::Job { id, label, progress: Default::default(), result: Default::default() }; + job.progress.total.store(total as u64, std::sync::atomic::Ordering::Relaxed); + let results: Arc>>> = Arc::default(); + let (prog, res, out) = (job.progress.clone(), job.result.clone(), results.clone()); + let run = move || { + use std::sync::atomic::Ordering; + let t0 = web_time::Instant::now(); + let mut done = 0u64; + let mut found = Vec::new(); + let mut err = None; + 'clips: for w in &work { + let mut det = SceneDetector::new(); + for (k, (_, mt)) in w.frames.iter().enumerate() { + if prog.cancel.load(Ordering::Relaxed) { + err = Some("stopped".to_string()); + break 'clips; + } + match w.src.video_frame(filmcraft_media::FrameRequest { time: *mt, scale: w.scale }) { + Ok(f) => { + det.push_rgba(&f.to_rgba8(), f.width as usize, f.height as usize); + } + Err(e) => { + err = Some(format!("{}: can't decode frame {k}: {e}", w.name)); + break 'clips; + } + } + done += 1; + prog.done.store(done, Ordering::Relaxed); + *lock(&prog.status) = format!("{}: frame {} of {}", w.name, k + 1, w.frames.len()); + } + let cuts: Vec<(Tick, Tick)> = det.cuts(&opts).into_iter().map(|i| w.frames[i]).collect(); + let (a, b) = (w.frames.first().map(|f| f.1).unwrap_or_default(), w.frames.last().map(|f| f.1).unwrap_or_default()); + let fdm = w.frames.get(1).map(|f| (f.1 - w.frames[0].1).abs()).unwrap_or(Tick(1)).max(Tick(1)); + found.push(ClipScenes { + clip: w.clip, + name: w.name.clone(), + media_item: w.media_item, + media_range: TimeRange::from_bounds(a.min(b), a.max(b) + fdm), + cuts, + }); + } + let secs = t0.elapsed().as_secs_f64(); + let n_cuts: usize = found.iter().map(|c| c.cuts.len()).sum(); + *lock(&prog.status) = match &err { + Some(e) if e == "stopped" => "Stopped: nothing was changed".into(), + Some(e) => e.clone(), + None => format!("Found {n_cuts} cut point(s) in {done} frame(s) ({secs:.1}s)"), + }; + let r = match err { + Some(e) => Err(e), + None => { + *lock(&out) = Some(found); + Ok(filmcraft_export::Report { path: String::new(), frames: done, seconds: secs, bytes: 0, render_fps: done as f64 / secs.max(1e-6) }) + } + }; + *lock(&res) = Some(r); + prog.finished.store(true, Ordering::Relaxed); + }; + s.jobs.push(job); + s.scene_jobs.push(PendingScene { job: id, seq: seq_id, apply, results: results.clone() }); + let wait = bool_p(p, "wait").unwrap_or(false); + let mut out = json!({"job": id, "frames": total}); + if wait || cfg!(target_arch = "wasm32") { + run(); + if let Some(r) = lock(&results).as_ref() { + out["clips"] = json!( + r.iter() + .map(|c| json!({"clip": c.clip.0, "cuts": c.cuts.iter().map(|x| x.0.0).collect::>(), "mediaCuts": c.cuts.iter().map(|x| x.1.0).collect::>()})) + .collect::>() + ); + } + poll(s); + } else { + std::thread::Builder::new().name("filmcraft-scene-detect".into()).spawn(run).map_err(|e| EngineError::Other(e.to_string()))?; + } + if let Some(Err(e)) = s.jobs.iter().find(|j| j.id == id).and_then(|j| lock(&j.result).clone()) { + return Err(EngineError::Other(e)); + } + Ok(out) +} + +/// Apply finished jobs (one undo step each) and drop finished / cancelled ones. Called once per UI +/// frame from [`Session::poll_persistence`] and after synchronous runs. +pub fn poll(s: &mut Session) { + use std::sync::atomic::Ordering; + let mut i = 0; + while i < s.scene_jobs.len() { + let job = s.jobs.iter().find(|j| j.id == s.scene_jobs[i].job); + let finished = job.is_none_or(|j| j.progress.finished.load(Ordering::Relaxed)); + // cancelled before the results were applied: nothing changes + let cancelled = job.is_some_and(|j| j.progress.cancel.load(Ordering::Relaxed)); + if !finished { + i += 1; + continue; + } + let pj = s.scene_jobs.remove(i); + let Some(results) = lock(&pj.results).take().filter(|_| !cancelled) else { continue }; + if let Err(e) = apply_results(s, pj.seq, pj.apply, results) { + s.events.push(crate::Event::Toast { message: format!("Scene Edit Detection: {e}"), error: true }); + } + } +} + +fn apply_results(s: &mut Session, seq_id: ItemId, apply: SceneApply, results: Vec) -> Result { + let n_cuts: usize = results.iter().map(|r| r.cuts.len()).sum(); + if n_cuts == 0 { + s.toast("Scene Edit Detection found no cut points"); + return Ok(json!({"cuts": 0})); + } + let media = s.media.clone(); + s.edit("Scene Edit Detection", move |pr, _| { + let snapshot = pr.clone(); + let durations = move |id: ItemId| crate::media_duration(&snapshot, &media, id); + for r in &results { + if apply.cuts { + let min = pr.sequence(seq_id).map(|q| q.settings.frame_rate.frame_duration()).unwrap_or(Tick(1)); + let mut next = pr.next_id; + let q = pr.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?; + let mut ctx = filmcraft_edit::EditCtx { next_id: &mut next, media_duration: &durations, min_duration: min }; + // latest cut first: the left piece keeps the clip's id for the earlier cuts + for (t, _) in r.cuts.iter().rev() { + let link = q.find_item(r.clip).and_then(|(_, it)| it.link); + let mut items = vec![r.clip]; + if let Some(l) = link { + items.extend(q.all_tracks().flat_map(|tr| tr.items.iter()).filter(|it| it.link == Some(l) && it.id != r.clip).map(|it| it.id)); + } + filmcraft_edit::razor_items(q, &items, *t, &mut ctx); + } + pr.next_id = next; + pr.sequence(seq_id).ok_or(EngineError::NoSequence)?.check().map_err(EngineError::Other)?; + } + if apply.markers { + let ids: Vec = r.cuts.iter().map(|_| pr.alloc_id()).collect(); + if let Some(m) = pr.item_mut(r.media_item).and_then(|i| i.as_media_mut()) { + for ((_, mt), id) in r.cuts.iter().zip(ids) { + if m.markers.iter().any(|x| x.start == *mt) { + continue; + } + m.markers.push(Marker { + id: MarkerId(id), + start: *mt, + duration: Tick::ZERO, + name: "Scene Edit".into(), + comment: String::new(), + kind: MarkerKind::Comment, + color: Label::Green, + }); + } + m.markers.sort_by_key(|x| x.start); + } + } + if apply.subclips { + let base = pr.item(r.media_item).map(|i| i.name.clone()).unwrap_or_else(|| r.name.clone()); + let label = pr.item(r.media_item).map(|i| i.label).unwrap_or(Label::Iris); + let bin = pr.add_bin(&format!("{base} Scenes"), None); + let mut bounds: Vec = vec![r.media_range.start]; + bounds.extend(r.cuts.iter().map(|c| c.1).filter(|t| *t > r.media_range.start && *t < r.media_range.end())); + bounds.push(r.media_range.end()); + bounds.sort(); + bounds.dedup(); + for (k, w) in bounds.windows(2).enumerate() { + let kind = ItemKind::Subclip { parent: r.media_item, range: TimeRange::from_bounds(w[0], w[1]), restrict_trims: false }; + pr.add_item(&format!("{base} Scene {:02}", k + 1), label, kind, Some(bin)); + } + } + } + Ok(()) + })?; + s.toast(format!("Scene Edit Detection: {n_cuts} cut point(s)")); + Ok(json!({"cuts": n_cuts})) +} + +#[cfg(test)] +#[path = "scene_detect_tests.rs"] +mod tests; diff --git a/crates/engine/src/scene_detect_tests.rs b/crates/engine/src/scene_detect_tests.rs new file mode 100644 index 00000000..f281fda2 --- /dev/null +++ b/crates/engine/src/scene_detect_tests.rs @@ -0,0 +1,207 @@ +//! Scene Edit Detection on generated footage with known cut points: a movie made with our own +//! ProRes encoder from two demo scenes (no ffmpeg needed), and an H.264 file that ffmpeg (fixture +//! generator only) builds from three lavfi sources. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use filmcraft_media::generators::GeneratorSource; +use filmcraft_media::{DemoScene, Generator, MediaSource}; +use filmcraft_project::{ItemId, ItemKind, Label, MediaClip, MediaRef, Project, SequenceSettings, TrackKind}; +use filmcraft_time::{FrameRate, Tick, TimeRange}; +use serde_json::json; + +use crate::Session; +use crate::media_test_util::{session_with, tmp_dir}; + +/// Write a 24 fps ProRes movie of demo scenes played one after the other: `shots` = (scene, +/// frames). The cuts are at the running sums of the frame counts. +fn make_cut_movie(path: &Path, shots: &[(DemoScene, i64)], w: u32, h: u32) { + let rate = FrameRate::FPS_24; + let mut p = Project::new("fixture"); + let seq = p.new_sequence("s", SequenceSettings { width: w, height: h, frame_rate: rate, ..Default::default() }, 1, 1, None); + let mut sources: Vec<(ItemId, filmcraft_media::SharedSource)> = Vec::new(); + let mut at = Tick::ZERO; + for (k, (scene, frames)) in shots.iter().enumerate() { + let dur = rate.tick_of(*frames); + // each shot starts at a different point of its scene + let src = GeneratorSource::new(Generator::Demo(*scene), w, h, rate, rate.tick_of(200)); + let clip = MediaClip { + media: MediaRef::Generator(Generator::Demo(*scene)), + info: src.info().clone(), + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }; + let item = p.add_item(&format!("shot{k}"), Label::Iris, ItemKind::Media(clip), None); + let mut v = p.make_track_item(item, TrackKind::Video, at, TimeRange::new(rate.tick_of(10 * k as i64), dur), rate).unwrap(); + for e in &mut v.effects { + filmcraft_project::resolve_auto_points(e, (w, h), (w, h)); + } + p.sequence_mut(seq).unwrap().video_tracks[0].items.push(v); + sources.push((item, Arc::new(src))); + at += dur; + } + let settings = + filmcraft_export::ExportSettings { format: filmcraft_export::Format::ProRes, path: path.to_string_lossy().into_owned(), ..Default::default() }; + let provider = move |id: ItemId| sources.iter().find(|(i, _)| *i == id).map(|(_, s)| s.clone()); + filmcraft_export::export(&Arc::new(p), seq, &settings, &provider, &Default::default()).unwrap(); +} + +fn select_v1(s: &mut Session) -> filmcraft_project::ClipId { + let c = s.active_sequence().unwrap().video_tracks[0].items[0].id; + s.execute("timeline.select", json!({"clips": [c.0]})).unwrap(); + c +} + +#[test] +fn detects_cuts_in_our_own_render_and_applies_cuts_markers_and_subclips() { + let dir = tmp_dir("scene-own"); + let path = dir.join("cuts.mov"); + // Ocean Sunset 20 frames, City Night 16, Forest 14: cuts at frames 20 and 36 + make_cut_movie(&path, &[(DemoScene::OceanSunset, 20), (DemoScene::CityNight, 16), (DemoScene::Forest, 14)], 160, 90); + let (mut s, items, _) = session_with(&[&path]); + let rate = s.sequence_rate(); + assert!(!s.is_enabled("clip.sceneEditDetection"), "nothing selected"); + select_v1(&mut s); + assert!(s.is_enabled("clip.sceneEditDetection")); + let n_undo = s.history.undo.len(); + let r = s.execute("clip.sceneEditDetection", json!({"wait": true, "applyCuts": true, "generateMarkers": true, "createSubclips": true})).unwrap(); + assert_eq!(r["clips"][0]["cuts"], json!([rate.tick_of(20).0, rate.tick_of(36).0]), "{r}"); + assert_eq!(s.history.undo.len(), n_undo + 1, "one undo step"); + assert_eq!(s.history.undo.last().unwrap().0, "Scene Edit Detection"); + // cuts: V1 and the linked A1 are in three pieces + let q = s.active_sequence().unwrap(); + let starts: Vec = q.video_tracks[0].items.iter().map(|i| i.start).collect(); + assert_eq!(starts, vec![Tick::ZERO, rate.tick_of(20), rate.tick_of(36)]); + q.check().unwrap(); + // markers on the master clip, in media time + let m = s.project.item(items[0]).unwrap().as_media().unwrap(); + assert_eq!(m.markers.iter().map(|x| x.start).collect::>(), vec![rate.tick_of(20), rate.tick_of(36)]); + // one subclip per shot in a " Scenes" bin + let subs: Vec = s + .project + .items + .values() + .filter_map(|i| match &i.kind { + ItemKind::Subclip { parent, range, .. } if *parent == items[0] => Some(*range), + _ => None, + }) + .collect(); + assert_eq!( + subs, + vec![ + TimeRange::new(Tick::ZERO, rate.tick_of(20)), + TimeRange::new(rate.tick_of(20), rate.tick_of(16)), + TimeRange::new(rate.tick_of(36), rate.tick_of(14)) + ] + ); + assert!(s.project.root.children.iter().any(|c| matches!(c, filmcraft_project::BinEntry::Bin(b) if b.name == "cuts.mov Scenes" && b.children.len() == 3))); + // undo restores everything + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active_sequence().unwrap().video_tracks[0].items.len(), 1); + assert!(s.project.item(items[0]).unwrap().as_media().unwrap().markers.is_empty()); + assert!(!s.project.items.values().any(|i| matches!(i.kind, ItemKind::Subclip { .. }))); + // sensitivity 0 still finds these hard cuts; nothing chosen to do is an error + select_v1(&mut s); + let r = s.execute("clip.sceneEditDetection", json!({"wait": true, "applyCuts": false, "generateMarkers": true, "sensitivity": 0})).unwrap(); + assert_eq!(r["clips"][0]["cuts"].as_array().unwrap().len(), 2); + assert!(s.execute("clip.sceneEditDetection", json!({"applyCuts": false})).is_err()); +} + +#[test] +fn background_job_reports_progress_and_cancel_changes_nothing() { + let dir = tmp_dir("scene-job"); + let path = dir.join("job.mov"); + make_cut_movie(&path, &[(DemoScene::Dunes, 18), (DemoScene::Plasma, 18)], 128, 72); + let (mut s, _, _) = session_with(&[&path]); + select_v1(&mut s); + let r = s.execute("clip.sceneEditDetection", json!({})).unwrap(); + let job = r["job"].as_u64().unwrap(); + let t0 = std::time::Instant::now(); + loop { + s.poll_persistence(); + let j = s.execute("jobs.list", json!({})).unwrap(); + let j = j.as_array().unwrap().iter().find(|x| x["id"] == job).unwrap().clone(); + if j["finished"] == true && s.scene_jobs.is_empty() { + assert_eq!(j["total"], 36); + assert_eq!(j["done"], 36); + break; + } + assert!(t0.elapsed().as_secs() < 120, "job never finished"); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert_eq!(s.active_sequence().unwrap().video_tracks[0].items.len(), 2, "cut applied when the job finished"); + // a cancelled job leaves the project alone + s.execute("edit.undo", json!({})).unwrap(); + select_v1(&mut s); + let rev = s.revision; + let r = s.execute("clip.sceneEditDetection", json!({})).unwrap(); + s.execute("jobs.cancel", json!({"job": r["job"]})).unwrap(); + let t0 = std::time::Instant::now(); + while !s.scene_jobs.is_empty() { + s.poll_persistence(); + assert!(t0.elapsed().as_secs() < 120); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert_eq!(s.revision, rev); + assert_eq!(s.active_sequence().unwrap().video_tracks[0].items.len(), 1); +} + +fn ffmpeg_fixture() -> Option { + let ffmpeg = filmcraft_testkit::oracle::ffmpeg_or_skip("scene_detect")?; + let out = filmcraft_testkit::fixtures_dir("engine/scene").join("three_shots.mp4"); + filmcraft_testkit::fixtures::generate(&out, |tmp| { + std::process::Command::new(&ffmpeg) + .args(["-y", "-loglevel", "error"]) + .args(["-f", "lavfi", "-i", "testsrc2=size=192x108:rate=24:duration=1"]) + .args(["-f", "lavfi", "-i", "smptebars=size=192x108:rate=24:duration=1.5"]) + .args(["-t", "1", "-f", "lavfi", "-i", "mandelbrot=size=192x108:rate=24"]) + .args(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=3.5"]) + .args(["-filter_complex", "[0:v][1:v][2:v]concat=n=3:v=1:a=0,format=yuv420p[v]", "-map", "[v]", "-map", "3:a"]) + .args(["-c:v", "libx264", "-preset", "fast", "-g", "30", "-c:a", "aac", "-shortest"]) + .arg(tmp) + .status() + .is_ok_and(|s| s.success()) + }) +} + +/// ffmpeg-made H.264 + AAC: testsrc2 (24 frames), SMPTE bars (36), Mandelbrot zoom (24). The cuts +/// are at frames 24 and 60; the moving test pattern and the zoom don't trigger. +#[test] +fn detects_the_cuts_of_an_ffmpeg_concat_and_cuts_linked_audio() { + let Some(src) = ffmpeg_fixture() else { return }; + let dir = tmp_dir("scene-ff"); + let media = dir.join("three_shots.mp4"); + std::fs::copy(&src, &media).unwrap(); + let (mut s, _, _) = session_with(&[&media]); + let rate = s.sequence_rate(); + assert_eq!(rate, FrameRate::FPS_24); + // link the V1 / A1 pair, as an edit from the Source monitor would + let mut p = (*s.project).clone(); + let seq = s.state.active_sequence.unwrap(); + let q = p.sequence_mut(seq).unwrap(); + q.video_tracks[0].items[0].link = Some(7777); + q.audio_tracks[0].items[0].link = Some(7777); + s.project = Arc::new(p); + select_v1(&mut s); + for sensitivity in [20.0, 50.0, 80.0] { + let r = s.execute("clip.sceneEditDetection", json!({"wait": true, "applyCuts": false, "generateMarkers": true, "sensitivity": sensitivity})).unwrap(); + assert_eq!(r["clips"][0]["cuts"], json!([rate.tick_of(24).0, rate.tick_of(60).0]), "sensitivity {sensitivity}: {r}"); + s.execute("edit.undo", json!({})).unwrap(); + } + s.execute("clip.sceneEditDetection", json!({"wait": true})).unwrap(); + let q = s.active_sequence().unwrap(); + let v: Vec = q.video_tracks[0].items.iter().map(|i| i.start).collect(); + let a: Vec = q.audio_tracks[0].items.iter().map(|i| i.start).collect(); + assert_eq!(v, vec![Tick::ZERO, rate.tick_of(24), rate.tick_of(60)]); + assert_eq!(a, v, "linked audio is cut too"); + // each piece keeps a link to its partner + for (x, y) in q.video_tracks[0].items.iter().zip(&q.audio_tracks[0].items) { + assert!(x.link.is_some() && x.link == y.link); + } +} diff --git a/crates/engine/src/sequence_extras.rs b/crates/engine/src/sequence_extras.rs new file mode 100644 index 00000000..f861405f --- /dev/null +++ b/crates/engine/src/sequence_extras.rs @@ -0,0 +1,496 @@ +//! Sequence / Markers menu long tail (M3.11). +//! +//! | Id | Menu | Notes | +//! |---|---|---| +//! | `sequence.applyDefaultTransitionsToSelection` | Sequence ▸ Apply Default Transitions to Selection (⇧D) | selected clips' edges; selected edit points | +//! | `sequence.normalizeMixTrack` | Sequence ▸ Normalize Mix Track… | Mix fader so the mix peaks at the target | +//! | `sequence.transcribe` | Sequence ▸ Transcribe Sequence… | `transcript.generate` on the sequence's audio clips | +//! | `sequence.simplify` | Sequence ▸ Simplify Sequence… | a simplified copy of the sequence | +//! | `captions.showActiveOnly` | Sequence ▸ Captions ▸ Show Active Caption Tracks Only | | +//! | `markers.addFlashCue` | Markers ▸ Add Flash Cue Marker… | `MarkerKind::FlashCue` | +//! +//! **Simplify Sequence** (Premiere 26): makes a copy named " (Simplified)" and opens it; +//! the original is untouched. Options: remove disabled clips, remove empty tracks, close gaps (spans +//! where no track has a clip), move video clips down to the lowest free track, remove video / audio +//! effects (standard effects; Motion, Opacity, Volume… are kept), remove text (graphic clips and +//! caption tracks), keep only video or only audio. + +use filmcraft_edit as edit; +use filmcraft_project::{ClipId, ItemKind, Label, Marker, MarkerId, MarkerKind, Sequence, TrackId, TrackKind}; +use filmcraft_time::{Tick, TimeRange}; +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, bad, bool_p, f64_p, has_seq, str_p, time_p, u64_p}; +use crate::{EngineError, Result, Session}; + +type Run = fn(&mut Session, &Value) -> Result; +type Enabled = fn(&Session) -> std::result::Result<(), String>; + +fn spec( + id: &'static str, + label: &'static str, + menu: &'static [&'static str], + shortcut: Option<&'static str>, + params: &'static str, + enabled: Enabled, + run: Run, +) -> CommandSpec { + CommandSpec { id, label, menu, shortcut, params, enabled, run, journal: true } +} + +pub(crate) fn commands() -> Vec { + vec![ + spec( + "sequence.applyDefaultTransitionsToSelection", + "Apply Default Transitions to Selection", + &["Sequence"], + // ⇧D comes from the Premiere shortcut audit (`shortcut_presets::PREMIERE`) + None, + r#"{"clips":[id]?}"#, + has_clips_or_edit_points, + apply_default_transitions, + ), + spec("sequence.normalizeMixTrack", "Normalize Mix Track…", &["Sequence"], None, r#"{"db":f64=0}"#, has_audio_clips, normalize_mix), + spec( + "sequence.transcribe", + "Transcribe Sequence…", + &["Sequence"], + None, + r#"{"track":"mix"|"A1"|id?,"language":"en|auto"?,"diarize":bool?,"maxSpeakers":n?,"model":str?}"#, + has_audio_clips, + transcribe, + ), + spec( + "sequence.simplify", + "Simplify Sequence…", + &["Sequence"], + None, + r#"{"name":str?,"removeDisabled":bool=true,"removeEmptyTracks":bool=true,"closeGaps":bool=false,"moveClipsDown":bool=false,"removeVideoEffects":bool=false,"removeAudioEffects":bool=false,"removeText":bool=false,"keep":"both|video|audio"}"#, + has_seq, + simplify, + ), + spec( + "captions.showActiveOnly", + "Show Active Caption Tracks Only", + &["Sequence", "Captions"], + None, + r#"{"track":id|"C1"?}"#, + has_caption_track, + show_active_only, + ), + spec( + "markers.addFlashCue", + "Add Flash Cue Marker…", + &["Markers"], + None, + r#"{"time":ticks?,"name":str?,"comment":str?,"color":label?}"#, + has_seq, + add_flash_cue, + ), + ] +} + +// --------------------------------------------------------------------------------------------- +// enablement +// --------------------------------------------------------------------------------------------- + +fn has_clips_or_edit_points(s: &Session) -> std::result::Result<(), String> { + has_seq(s)?; + if s.state.selection.is_empty() && s.state.edit_points.is_empty() { Err("select clips or edit points".into()) } else { Ok(()) } +} + +fn has_audio_clips(s: &Session) -> std::result::Result<(), String> { + has_seq(s)?; + let q = s.active_sequence().ok_or("no sequence is open")?; + if q.audio_tracks.iter().any(|t| !t.items.is_empty()) { Ok(()) } else { Err("the sequence has no audio clips".into()) } +} + +fn has_caption_track(s: &Session) -> std::result::Result<(), String> { + has_seq(s)?; + if s.active_sequence().is_some_and(|q| !q.caption_tracks.is_empty()) { Ok(()) } else { Err("the sequence has no caption track".into()) } +} + +// --------------------------------------------------------------------------------------------- +// Apply Default Transitions to Selection +// --------------------------------------------------------------------------------------------- + +/// Premiere applies the default video / audio transition at each edge of the selected clips +/// (centred on cuts between two clips, starting / ending at a clip's free edge). Edges that +/// already have a transition keep it. With edit points selected (trim mode) and no clips, the +/// transitions go at the edit points. +fn apply_default_transitions(s: &mut Session, p: &Value) -> Result { + let clips: Vec = match p.get("clips").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect(), + None => s.state.selection.clone(), + }; + if clips.is_empty() { + return crate::trim::apply_default_transitions(s); + } + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + // (track, clip, edge) per distinct cut + let mut edges: Vec<(TrackKind, ClipId, &'static str, Tick, TrackId)> = Vec::new(); + for c in &clips { + let Some((tid, it)) = q.find_item(*c) else { continue }; + let Some(tr) = q.track(tid) else { continue }; + for (edge, at) in [("in", it.start), ("out", it.end())] { + if edges.iter().any(|e| e.4 == tid && e.3 == at) { + continue; + } + // an existing transition at this cut stays + if tr.transitions.iter().any(|x| x.start <= at && x.end() >= at && (x.from == Some(*c) || x.to == Some(*c))) { + continue; + } + edges.push((tr.kind, *c, edge, at, tid)); + } + } + let n0 = s.history.undo.len(); + let mut ids = Vec::new(); + let mut errors = Vec::new(); + for (kind, clip, edge, _, _) in edges { + let cmd = if kind == TrackKind::Video { "sequence.applyVideoTransition" } else { "sequence.applyAudioTransition" }; + match s.execute(cmd, json!({"clip": clip.0, "edge": edge})) { + Ok(r) => ids.push(r["transition"].clone()), + Err(e) => errors.push(e.to_string()), + } + } + crate::clip_ops::collapse_history(s, n0, "Apply Default Transitions"); + if ids.is_empty() { + return Err(EngineError::Other(errors.into_iter().next().unwrap_or_else(|| "no transition could be applied to the selection".into()))); + } + Ok(json!({"transitions": ids})) +} + +// --------------------------------------------------------------------------------------------- +// Normalize Mix Track +// --------------------------------------------------------------------------------------------- + +/// Peak (dBFS) of the active sequence's mix over its whole duration, rendered through the mixer +/// graph exactly like playback and export. +pub fn mix_peak_db(s: &Session) -> Result { + let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?; + let q = s.project.sequence(seq_id).ok_or(EngineError::NoSequence)?; + let sr = q.settings.sample_rate.max(1); + let total = q.duration().to_units_floor(sr as i64); + let provider = s.media.full_res_provider(s.project.clone(), s.services.clone()); + let mut peak = 0f32; + let mut pos = 0i64; + while pos < total { + let n = (total - pos).min(sr as i64) as usize; + let b = filmcraft_render::audio::mix_sequence(&s.project, q, pos, n, &provider); + peak = b.peaks().into_iter().fold(peak, f32::max); + pos += n as i64; + } + Ok(if peak > 0.0 { 20.0 * (peak as f64).log10() } else { f64::NEG_INFINITY }) +} + +/// Sequence ▸ Normalize Mix Track…: set the Mix fader so the mix peaks at `db` (default 0 dBFS). +/// The mix is measured, the fader moved by the difference, and measured again (post-fader +/// effects such as a limiter are not linear), up to three times. One undo step. +fn normalize_mix(s: &mut Session, p: &Value) -> Result { + let target = f64_p(p, "db").unwrap_or(0.0); + if !(-96.0..=24.0).contains(&target) { + return Err(bad("sequence.normalizeMixTrack", "the target must be between -96 and +24 dB")); + } + let before = s.active_sequence().map(|q| q.master_volume_db).unwrap_or(0.0); + let first = mix_peak_db(s)?; + if !first.is_finite() { + return Err(EngineError::Other("the mix is silent: nothing to normalize".into())); + } + let n0 = s.history.undo.len(); + let mut peak = first; + for _ in 0..3 { + let delta = target - peak; + if delta.abs() < 0.01 { + break; + } + s.edit_sequence("Normalize Mix Track", |q, _, _| { + q.master_volume_db = (q.master_volume_db + delta).clamp(-96.0, 48.0); + Ok(()) + })?; + peak = mix_peak_db(s)?; + } + crate::clip_ops::collapse_history(s, n0, "Normalize Mix Track"); + let after = s.active_sequence().map(|q| q.master_volume_db).unwrap_or(0.0); + Ok(json!({"peakBeforeDb": first, "peakDb": peak, "gainDb": after - before, "masterVolumeDb": after})) +} + +// --------------------------------------------------------------------------------------------- +// Transcribe Sequence +// --------------------------------------------------------------------------------------------- + +/// Sequence ▸ Transcribe Sequence…: transcribe the media of the sequence's audio clips (all audio +/// tracks — "Mix" — or one track) with `transcript.generate`. +fn transcribe(s: &mut Session, p: &Value) -> Result { + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let track = match p.get("track") { + None => None, + Some(Value::String(t)) if t.eq_ignore_ascii_case("mix") => None, + Some(_) => Some(crate::commands::track_p(s, p, "track").ok_or_else(|| bad("sequence.transcribe", "unknown `track`"))?), + }; + let mut items = Vec::new(); + for t in q.audio_tracks.iter().filter(|t| track.is_none_or(|x| x == t.id)) { + for it in t.items.iter().filter(|i| i.enabled) { + if !items.contains(&it.item.0) { + items.push(it.item.0); + } + } + } + if items.is_empty() { + return Err(EngineError::Other("there are no audio clips to transcribe".into())); + } + let mut params = json!({"items": items}); + for k in ["language", "diarize", "maxSpeakers", "model"] { + if let Some(v) = p.get(k) { + params[k] = v.clone(); + } + } + s.execute("transcript.generate", params) +} + +// --------------------------------------------------------------------------------------------- +// Simplify Sequence +// --------------------------------------------------------------------------------------------- + +/// What Simplify Sequence does to the copy. +#[derive(Clone, Debug)] +pub struct SimplifyOptions { + pub remove_disabled: bool, + pub remove_empty_tracks: bool, + pub close_gaps: bool, + pub move_clips_down: bool, + pub remove_video_effects: bool, + pub remove_audio_effects: bool, + pub remove_text: bool, + /// `both`, `video` or `audio`. + pub keep: String, +} + +impl SimplifyOptions { + pub fn from_params(p: &Value) -> Result { + let keep = str_p(p, "keep").unwrap_or("both").to_ascii_lowercase(); + if !matches!(keep.as_str(), "both" | "video" | "audio") { + return Err(bad("sequence.simplify", "`keep` is both, video or audio")); + } + Ok(SimplifyOptions { + remove_disabled: bool_p(p, "removeDisabled").unwrap_or(true), + remove_empty_tracks: bool_p(p, "removeEmptyTracks").unwrap_or(true), + close_gaps: bool_p(p, "closeGaps").unwrap_or(false), + move_clips_down: bool_p(p, "moveClipsDown").unwrap_or(false), + remove_video_effects: bool_p(p, "removeVideoEffects").unwrap_or(false), + remove_audio_effects: bool_p(p, "removeAudioEffects").unwrap_or(false), + remove_text: bool_p(p, "removeText").unwrap_or(false), + keep, + }) + } +} + +fn is_text(p: &filmcraft_project::Project, it: &filmcraft_project::TrackItem) -> bool { + p.item(it.item).is_some_and(|x| matches!(x.kind, ItemKind::Graphic { .. })) +} + +/// Apply Simplify Sequence to `q` (a copy). Returns what changed. +pub fn simplify_sequence(proj: &filmcraft_project::Project, q: &mut Sequence, o: &SimplifyOptions) -> Value { + let mut removed_clips = 0usize; + // keep only video / audio + if o.keep == "video" { + q.audio_tracks.clear(); + } else if o.keep == "audio" { + q.video_tracks.clear(); + } + let mut drop: Vec = Vec::new(); + for t in q.video_tracks.iter().chain(q.audio_tracks.iter()) { + for it in &t.items { + if (o.remove_disabled && !it.enabled) || (o.remove_text && is_text(proj, it)) { + drop.push(it.id); + } + } + } + removed_clips += edit::delete_items(q, &drop); + // linked partners removed with "keep" lose their link + let mut links: std::collections::BTreeMap = Default::default(); + for t in q.all_tracks() { + for it in &t.items { + if let Some(l) = it.link { + *links.entry(l).or_default() += 1; + } + } + } + for t in q.video_tracks.iter_mut().chain(q.audio_tracks.iter_mut()) { + for it in &mut t.items { + if it.link.is_some_and(|l| links.get(&l).copied().unwrap_or(0) < 2) { + it.link = None; + } + } + edit::remove_orphan_transitions(t); + } + if o.remove_text { + q.caption_tracks.clear(); + } + // effects (standard ones; intrinsic Motion / Opacity / Volume… and Essential Sound stay) + let mut removed_effects = 0usize; + for (video, tracks) in [(true, &mut q.video_tracks), (false, &mut q.audio_tracks)] { + if (video && !o.remove_video_effects) || (!video && !o.remove_audio_effects) { + continue; + } + for t in tracks.iter_mut() { + for it in &mut t.items { + let n = it.effects.len(); + it.effects.retain(|e| e.def().is_none_or(|d| d.intrinsic || filmcraft_project::graphic::is_layer_id(d.id)) || e.essential); + removed_effects += n - it.effects.len(); + } + if !video { + let n = t.effects.len(); + t.effects.clear(); + removed_effects += n; + } + } + } + // move video clips down to the lowest track where they fit (clips with transitions stay) + let mut moved = 0usize; + if o.move_clips_down { + for ti in 1..q.video_tracks.len() { + let ids: Vec = q.video_tracks[ti].items.iter().map(|i| i.id).collect(); + for id in ids { + let Some(pos) = q.video_tracks[ti].items.iter().position(|i| i.id == id) else { continue }; + let it = q.video_tracks[ti].items[pos].clone(); + if q.video_tracks[ti].transitions.iter().any(|x| x.from == Some(id) || x.to == Some(id)) { + continue; + } + let Some(dest) = (0..ti).find(|&d| !q.video_tracks[d].locked && edit::track_range_empty(&q.video_tracks[d], it.range())) else { continue }; + q.video_tracks[ti].items.remove(pos); + q.video_tracks[dest].items.push(it); + q.video_tracks[dest].sort(); + moved += 1; + } + } + } + // close gaps: spans where no track has anything + let mut closed = Tick::ZERO; + if o.close_gaps { + let mut spans: Vec = q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.range())).collect(); + spans.extend(q.caption_tracks.iter().flat_map(|t| t.captions.iter().map(|c| c.range()))); + spans.sort_by_key(|r| r.start); + let mut gaps: Vec = Vec::new(); + let mut cursor = Tick::ZERO; + for r in &spans { + if r.start > cursor { + gaps.push(TimeRange::from_bounds(cursor, r.start)); + } + cursor = cursor.max(r.end()); + } + for g in gaps.iter().rev() { + for t in q.video_tracks.iter_mut().chain(q.audio_tracks.iter_mut()) { + edit::shift_track_from(t, g.end(), Tick::ZERO - g.duration); + } + for t in &mut q.caption_tracks { + for c in t.captions.iter_mut().filter(|c| c.start >= g.end()) { + c.start -= g.duration; + } + } + crate::sequence_tools::ripple_markers(&mut q.markers, g.end(), Tick::ZERO - g.duration); + closed += g.duration; + } + } + // empty tracks (one video and one audio track always stay unless "keep" dropped the kind) + let mut removed_tracks = 0usize; + if o.remove_empty_tracks { + for tracks in [&mut q.video_tracks, &mut q.audio_tracks] { + // all empty: the first track stays so the sequence remains editable + let all_empty = tracks.iter().all(|t| t.items.is_empty()); + let n = tracks.len(); + let mut first = true; + tracks.retain(|t| { + let keep = !t.items.is_empty() || (all_empty && first); + first = false; + keep + }); + removed_tracks += n - tracks.len(); + } + } + json!({"removedClips": removed_clips, "removedEffects": removed_effects, "movedClips": moved, "closedGaps": closed.0, "removedTracks": removed_tracks}) +} + +fn simplify(s: &mut Session, p: &Value) -> Result { + let o = SimplifyOptions::from_params(p)?; + let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?; + let src = s.project.item(seq_id).cloned().ok_or(EngineError::NoSequence)?; + let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("{} (Simplified)", src.name)); + let (id, report) = s.edit("Simplify Sequence", |pr, st| { + let ItemKind::Sequence(q) = &src.kind else { return Err(EngineError::NoSequence) }; + let mut q = (**q).clone(); + let report = simplify_sequence(pr, &mut q, &o); + q.check().map_err(EngineError::Other)?; + let bin = pr.root.parent_of(seq_id).filter(|b| b.0 != 0); + let id = pr.add_item(&name, src.label, ItemKind::Sequence(Box::new(q)), bin); + st.active_sequence = Some(id); + if !st.open_sequences.contains(&id) { + st.open_sequences.push(id); + } + st.selection.clear(); + st.project_selection = vec![id]; + Ok((id, report)) + })?; + s.events.push(crate::Event::OpenSequence(id)); + let mut out = report; + out["sequence"] = json!(id.0); + out["name"] = json!(name); + Ok(out) +} + +// --------------------------------------------------------------------------------------------- +// Captions ▸ Show Active Caption Tracks Only +// --------------------------------------------------------------------------------------------- + +/// The active caption track: the one named by `track`, else the track of the selected caption, +/// else a targeted caption track, else the top one. +fn active_caption_track(s: &Session, p: &Value) -> Option { + let q = s.active_sequence()?; + if let Some(v) = p.get("track") { + if let Some(id) = v.as_u64() { + return q.caption_track(TrackId(id)).map(|t| t.id); + } + let n: usize = v.as_str()?.trim_start_matches(['C', 'c']).parse().ok()?; + return q.caption_tracks.get(n.checked_sub(1)?).map(|t| t.id); + } + if let Some(t) = s.state.caption_selection.iter().find_map(|c| q.find_caption(*c).map(|x| x.0)) { + return Some(t); + } + let tg = s.targeting().targeted; + q.caption_tracks.iter().find(|t| tg.contains(&t.id)).or(q.caption_tracks.first()).map(|t| t.id) +} + +fn show_active_only(s: &mut Session, p: &Value) -> Result { + let active = active_caption_track(s, p).ok_or_else(|| bad("captions.showActiveOnly", "no such caption track"))?; + s.edit("Show Active Caption Tracks Only", |pr, st| { + let seq = st.active_sequence.ok_or(EngineError::NoSequence)?; + for t in &mut pr.sequence_mut(seq).ok_or(EngineError::NoSequence)?.caption_tracks { + t.enabled = t.id == active; + } + Ok(()) + })?; + Ok(json!({"track": active.0})) +} + +// --------------------------------------------------------------------------------------------- +// Markers ▸ Add Flash Cue Marker… +// --------------------------------------------------------------------------------------------- + +fn add_flash_cue(s: &mut Session, p: &Value) -> Result { + let t = s.sequence_rate().snap(time_p(s, p, "").unwrap_or(s.playhead())); + let n = s.active_sequence().map(|q| q.markers.iter().filter(|m| m.kind == MarkerKind::FlashCue).count()).unwrap_or(0) + 1; + let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Cue Point {n}")); + let comment = str_p(p, "comment").unwrap_or("").to_string(); + let color = str_p(p, "color").and_then(Label::from_name).unwrap_or(Label::Green); + let dur = u64_p(p, "durationFrames").map(|f| s.sequence_rate().tick_of(f as i64)).unwrap_or(Tick::ZERO); + let id = s.edit_sequence("Add Flash Cue Marker", |q, ctx, _| { + let id = MarkerId(ctx.alloc()); + q.markers.push(Marker { id, start: t, duration: dur, name, comment, kind: MarkerKind::FlashCue, color }); + q.markers.sort_by_key(|m| m.start); + Ok(id) + })?; + Ok(json!({"marker": id.0})) +} + +#[cfg(test)] +#[path = "sequence_extras_tests.rs"] +mod tests; diff --git a/crates/engine/src/sequence_extras_tests.rs b/crates/engine/src/sequence_extras_tests.rs new file mode 100644 index 00000000..0f575387 --- /dev/null +++ b/crates/engine/src/sequence_extras_tests.rs @@ -0,0 +1,237 @@ +//! Tests of the M3.11 Sequence / Markers commands (`sequence_extras`). + +use std::sync::Arc; + +use filmcraft_project::{ItemKind, MarkerKind, Transcript, Word}; +use filmcraft_speech::FixedTranscriber; +use filmcraft_time::Tick; +use serde_json::json; + +use crate::Session; + +fn demo() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s +} + +fn menu_ids(path: &[&str]) -> Vec<&'static str> { + crate::commands::command_specs().iter().filter(|c| c.menu == path).map(|c| c.id).collect() +} + +fn pos(ids: &[&str], id: &str) -> usize { + ids.iter().position(|x| *x == id).unwrap_or_else(|| panic!("{id} not in {ids:?}")) +} + +#[test] +fn sequence_and_markers_menu_order_matches_premiere() { + let seq = menu_ids(&["Sequence"]); + let order = [ + "sequence.settings", + "sequence.deleteRenderFilesInToOut", + "sequence.matchFrame", + "sequence.addEdit", + "trim.edit", + "sequence.applyVideoTransition", + "sequence.applyAudioTransition", + "trim.applyDefaultTransition", + "sequence.lift", + "sequence.showThroughEdits", + "sequence.normalizeMixTrack", + "sequence.makeSubsequence", + "sequence.transcribe", + "sequence.simplify", + "sequence.addTracks", + ]; + for w in order.windows(2) { + assert!(pos(&seq, w[0]) < pos(&seq, w[1]), "{} before {}: {seq:?}", w[0], w[1]); + } + assert_eq!(pos(&seq, "trim.applyDefaultTransition"), pos(&seq, "sequence.applyAudioTransition") + 1); + let spec = crate::commands::find("trim.applyDefaultTransition").unwrap(); + assert_eq!(spec.label, "Apply Default Transitions to Selection"); + assert_eq!(Session::default().shortcuts.primary("trim.applyDefaultTransition").as_deref(), Some("Shift+D")); + assert_eq!(crate::commands::find("sequence.normalizeMixTrack").unwrap().label, "Normalize Mix Track…"); + let caps = menu_ids(&["Sequence", "Captions"]); + assert_eq!(pos(&caps, "captions.showActiveOnly"), pos(&caps, "captions.showAll") + 1); + let markers = menu_ids(&["Markers"]); + assert_eq!(pos(&markers, "markers.addFlashCue"), pos(&markers, "markers.addChapter") + 1); +} + +#[test] +fn apply_default_transitions_to_selected_clips() { + let mut s = demo(); + let q = s.active_sequence().unwrap(); + // shot 3 (City Night): its in edge has no transition yet, its out edge has the dip to black + let c = q.video_tracks[0].items[2].clone(); + let before_v = q.video_tracks[0].transitions.len(); + let before_a = q.audio_tracks[0].transitions.len(); + assert!(s.execute("trim.applyDefaultTransition", json!({})).is_err(), "nothing selected"); + s.execute("timeline.select", json!({"clips": [c.id.0]})).unwrap(); + assert!(s.is_enabled("trim.applyDefaultTransition")); + let n_undo = s.history.undo.len(); + let r = s.execute("trim.applyDefaultTransition", json!({})).unwrap(); + assert_eq!(s.history.undo.len(), n_undo + 1, "one undo step"); + let q = s.active_sequence().unwrap(); + let vt = &q.video_tracks[0].transitions; + assert_eq!(vt.len(), before_v + 1, "only the free in edge of the video clip gets one: {r}"); + let cut = vt.iter().find(|t| t.to == Some(c.id)).expect("transition at the in point"); + assert_eq!(cut.effect.effect, "cross_dissolve"); + assert_eq!(cut.start + Tick(cut.duration.0 / 2), c.start, "centred on the cut"); + // the linked audio clip (selected with it) gets audio transitions at its free edges + assert!(q.audio_tracks[0].transitions.len() > before_a); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active_sequence().unwrap().video_tracks[0].transitions.len(), before_v); +} + +#[test] +fn normalize_mix_track_hits_the_target_peak() { + let mut s = demo(); + let before = crate::sequence_extras::mix_peak_db(&s).unwrap(); + assert!(before.is_finite()); + let r = s.execute("sequence.normalizeMixTrack", json!({"db": -3.0})).unwrap(); + let after = crate::sequence_extras::mix_peak_db(&s).unwrap(); + assert!((after + 3.0).abs() < 0.05, "peak {after} dB ({r})"); + assert!((r["peakBeforeDb"].as_f64().unwrap() - before).abs() < 1e-9); + assert!((s.active_sequence().unwrap().master_volume_db - r["masterVolumeDb"].as_f64().unwrap()).abs() < 1e-9); + assert_eq!(s.history.undo.last().unwrap().0, "Normalize Mix Track"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active_sequence().unwrap().master_volume_db, 0.0); + assert!(s.execute("sequence.normalizeMixTrack", json!({"db": 40.0})).is_err()); + // no audio: disabled + s.execute("file.newSequence", json!({"name": "Empty"})).unwrap(); + assert!(!s.is_enabled("sequence.normalizeMixTrack")); +} + +#[test] +fn transcribe_sequence_runs_transcript_generate_on_its_audio() { + let mut s = demo(); + let mut t = Transcript { language: "en".into(), ..Default::default() }; + t.words.push(Word::new("Hello", Tick::from_seconds_f64(0.1), Tick::from_seconds_f64(0.4))); + s.transcriber = Some(Arc::new(FixedTranscriber { transcript: t, id: "fixed".into() })); + // a project selection must not change what Transcribe Sequence transcribes + let music = s.project.items.values().find(|i| i.name == "Ambient_Score.wav").unwrap().id; + s.execute("project.select", json!({"items": [music.0]})).unwrap(); + let r = s.execute("sequence.transcribe", json!({"track": "A1"})).unwrap(); + let q = s.active_sequence().unwrap(); + let expected: std::collections::BTreeSet<_> = q.audio_tracks[0].items.iter().map(|i| i.item).collect(); + assert_eq!(r["items"].as_array().unwrap().len(), expected.len(), "{r}"); + for i in &expected { + assert!(s.project.transcripts.contains_key(i)); + } + assert!(!s.project.transcripts.contains_key(&music)); + assert!(s.execute("sequence.transcribe", json!({"track": "A9"})).is_err()); +} + +#[test] +fn simplify_sequence_makes_a_clean_copy() { + let mut s = demo(); + let orig_id = s.state.active_sequence.unwrap(); + let orig = s.active_sequence().unwrap().clone(); + // remove the V2 overlay and the A2 score, disable shot 2 (video + linked audio), blur shot 1 + let overlay = orig.video_tracks[1].items[0].id; + let score = orig.audio_tracks[1].items[0].id; + s.execute("timeline.select", json!({"clips": [overlay.0, score.0]})).unwrap(); + s.execute("edit.clear", json!({"clips": [overlay.0, score.0]})).unwrap(); + let c2 = orig.video_tracks[0].items[1].id; + s.execute("timeline.select", json!({"clips": [c2.0]})).unwrap(); + s.execute("clip.enable", json!({})).unwrap(); + let blur = orig.video_tracks[0].items[0].id; + s.execute("effects.apply", json!({"clips": [blur.0], "effect": "gaussian_blur"})).unwrap(); + let n_items = s.project.items.len(); + let r = s.execute("sequence.simplify", json!({"removeDisabled": true, "removeEmptyTracks": true, "closeGaps": true, "removeVideoEffects": true})).unwrap(); + assert_eq!(s.project.items.len(), n_items + 1); + let new_id = filmcraft_project::ItemId(r["sequence"].as_u64().unwrap()); + assert_eq!(s.state.active_sequence, Some(new_id), "the copy opens"); + assert_eq!(s.project.item(new_id).unwrap().name, "Main Edit (Simplified)"); + let q = s.project.sequence(new_id).unwrap(); + q.check().unwrap(); + assert!(q.find_item(c2).is_none()); + assert!(q.all_tracks().all(|t| t.items.iter().all(|i| i.enabled))); + assert_eq!(r["removedClips"], 2, "{r}"); + // V2 / V3 / A2 / A3 were empty + assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (1, 1), "{r}"); + // the gap left by shot 2 is closed: V1 is continuous from 0 + let v1 = &q.video_tracks[0].items; + assert_eq!(v1[0].start, Tick::ZERO); + for w in v1.windows(2) { + assert_eq!(w[0].end(), w[1].start); + } + assert_eq!(r["closedGaps"].as_i64().unwrap(), orig.video_tracks[0].items[1].duration.0); + // effects: Gaussian Blur and the demo's Lumetri gone, Motion & Opacity kept + assert!(q.video_tracks.iter().flat_map(|t| &t.items).all(|i| i.effects.iter().all(|e| e.def().is_some_and(|d| d.intrinsic)))); + assert!(q.video_tracks[0].items[0].effect("motion").is_some()); + // the original is untouched + let o = s.project.sequence(orig_id).unwrap(); + assert!(o.find_item(c2).is_some() && o.video_tracks.len() == 3); + // keep audio only + s.execute("sequence.open", json!({"item": orig_id.0})).unwrap(); + let r2 = s.execute("sequence.simplify", json!({"keep": "audio", "name": "Audio Only"})).unwrap(); + let q2 = s.project.sequence(filmcraft_project::ItemId(r2["sequence"].as_u64().unwrap())).unwrap(); + assert!(q2.video_tracks.is_empty() && !q2.audio_tracks.is_empty()); + assert!(q2.all_tracks().flat_map(|t| &t.items).all(|i| i.link.is_none()), "links to removed video are dropped"); + assert!(s.execute("sequence.simplify", json!({"keep": "nope"})).is_err()); + // undo removes the copy + let n = s.project.items.len(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.items.len(), n - 1); + assert!(matches!(s.project.item(orig_id).unwrap().kind, ItemKind::Sequence(_))); +} + +#[test] +fn simplify_moves_clips_down_where_they_fit() { + let s = demo(); + let mut q = s.active_sequence().unwrap().clone(); + // the V2 overlay sits over V1 shots: it can't move; delete V1 under it and it can + let ov = q.video_tracks[1].items[0].clone(); + let o = crate::sequence_extras::SimplifyOptions::from_params(&json!({"moveClipsDown": true, "removeEmptyTracks": false})).unwrap(); + let mut a = q.clone(); + crate::sequence_extras::simplify_sequence(&s.project, &mut a, &o); + assert!(a.video_tracks[1].item(ov.id).is_some()); + let under: Vec<_> = q.video_tracks[0].items.iter().filter(|i| i.range().overlaps(&ov.range())).map(|i| i.id).collect(); + filmcraft_edit::delete_items(&mut q, &under); + for t in &mut q.video_tracks { + t.transitions.clear(); + } + let r = crate::sequence_extras::simplify_sequence(&s.project, &mut q, &o); + assert_eq!(r["movedClips"], 1); + assert!(q.video_tracks[0].item(ov.id).is_some() && q.video_tracks[1].items.is_empty()); + q.check().unwrap(); +} + +#[test] +fn show_active_caption_track_only() { + let mut s = demo(); + assert!(!s.is_enabled("captions.showActiveOnly"), "no caption tracks"); + let a = s.execute("captions.newTrack", json!({"name": "English"})).unwrap(); + let b = s.execute("captions.newTrack", json!({"name": "French"})).unwrap(); + let id_of = |v: &serde_json::Value| v["track"].as_u64().or_else(|| v.as_u64()).unwrap(); + let (ta, tb) = (id_of(&a), id_of(&b)); + s.execute("captions.showActiveOnly", json!({"track": tb})).unwrap(); + let on: Vec<(u64, bool)> = s.active_sequence().unwrap().caption_tracks.iter().map(|t| (t.id.0, t.enabled)).collect(); + assert!(on.contains(&(tb, true)) && on.contains(&(ta, false)), "{on:?}"); + s.execute("captions.showActiveOnly", json!({"track": "C1"})).unwrap(); + let first = s.active_sequence().unwrap().caption_tracks[0].id.0; + assert!(s.active_sequence().unwrap().caption_tracks.iter().all(|t| t.enabled == (t.id.0 == first))); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_sequence().unwrap().caption_tracks.iter().any(|t| t.id.0 == tb && t.enabled)); + assert!(s.execute("captions.showActiveOnly", json!({"track": "C9"})).is_err()); +} + +#[test] +fn flash_cue_markers_round_trip() { + let mut s = demo(); + let r = s.execute("markers.addFlashCue", json!({"seconds": 2.0, "comment": "cue"})).unwrap(); + let id = r["marker"].as_u64().unwrap(); + let q = s.active_sequence().unwrap(); + let m = q.markers.iter().find(|m| m.id.0 == id).unwrap(); + assert_eq!((m.kind, m.name.as_str(), m.comment.as_str()), (MarkerKind::FlashCue, "Cue Point 1", "cue")); + assert_eq!(m.start, s.sequence_rate().snap(Tick::from_seconds_f64(2.0))); + let back = filmcraft_format::decode(&filmcraft_format::encode(&s.project, false)).unwrap().project; + assert_eq!(back, *s.project); + let r2 = s.execute("markers.addFlashCue", json!({})).unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!(q.markers.iter().find(|m| m.id.0 == r2["marker"].as_u64().unwrap()).unwrap().name, "Cue Point 2"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_sequence().unwrap().markers.iter().all(|m| m.kind != MarkerKind::FlashCue)); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 42e86f5f..d28d93f7 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -48,7 +48,7 @@ pub type Migration = fn(Value) -> Result; /// `MIGRATIONS[i]` upgrades schema `i + 1` to `i + 2`. Append one function per schema bump; never /// edit a shipped one. -pub const MIGRATIONS: &[Migration] = &[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9, v9_to_v10]; +pub const MIGRATIONS: &[Migration] = &[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9, v9_to_v10, v10_to_v11]; /// The schema version this build writes (and the newest it reads). pub const SCHEMA_VERSION: u32 = MIGRATIONS.len() as u32 + 1; @@ -264,13 +264,19 @@ fn v9_to_v10(doc: Value) -> Result { Ok(doc) } +/// v10 → v11: search bins (`project.search_bins`), Flash Cue markers, Project Settings safe areas, +/// capture format and scratch disks. Existing data needs no change. +fn v10_to_v11(doc: Value) -> Result { + Ok(doc) +} + #[cfg(test)] mod tests { use super::*; #[test] fn schema_version_matches_table() { - assert_eq!(SCHEMA_VERSION, 10); + assert_eq!(SCHEMA_VERSION, 11); } #[test] diff --git a/crates/format/tests/fixtures.rs b/crates/format/tests/fixtures.rs index 5505f0fd..a25909ac 100644 --- a/crates/format/tests/fixtures.rs +++ b/crates/format/tests/fixtures.rs @@ -191,3 +191,22 @@ fn v8_loads_without_transcripts_and_v9_roundtrips_them() { assert_eq!(again.project, p); assert_eq!(again.project.transcripts[&ItemId(7)].speakers[0].name, "Speaker 1"); } + +/// Schema 10 (before M3.11): no search bins, no safe areas / capture format / scratch disks in +/// the project settings. +#[test] +fn v10_minimal_loads_with_m3_11_defaults() { + let l = decode(&fixture("v10-minimal.fcproj")).unwrap(); + assert_eq!(l.schema_version, 10); + assert!(l.migrated()); + let p = &l.project; + assert_eq!(p.name, "Before Search Bins"); + assert!(p.search_bins.is_empty()); + assert_eq!((p.settings.title_safe, p.settings.action_safe), ((20.0, 20.0), (10.0, 10.0))); + assert_eq!(p.settings.capture_format, "DV"); + assert_eq!(p.settings.scratch, Default::default()); + assert!(p.root.find_bin(filmcraft_project::BinId(1)).is_some()); + let again = decode(&encode(p, true)).unwrap(); + assert_eq!(again.schema_version, SCHEMA_VERSION); + assert_eq!(&again.project, p); +} diff --git a/crates/format/tests/fixtures/v10-minimal.fcproj b/crates/format/tests/fixtures/v10-minimal.fcproj new file mode 100644 index 00000000..d1d2be74 --- /dev/null +++ b/crates/format/tests/fixtures/v10-minimal.fcproj @@ -0,0 +1 @@ +{"schema_version":10,"generator":"FilmCraft 0.1.0","project":{"name":"Before Search Bins","settings":{"renderer":"FilmCraft GPU Acceleration (wgpu)","video_display":"Timecode","audio_display_samples":true,"default_still_duration":1270080000000,"default_transition_duration_frames":24,"default_audio_transition_duration":254016000000,"ingest":{"enabled":false,"action":"createProxies","destination":null,"preset":""}},"root":{"id":0,"name":"Before Search Bins","children":[{"Bin":{"id":1,"name":"Selects","children":[]}}]},"items":{},"next_id":2}} diff --git a/crates/interchange/src/ale.rs b/crates/interchange/src/ale.rs new file mode 100644 index 00000000..faa20d8c --- /dev/null +++ b/crates/interchange/src/ale.rs @@ -0,0 +1,294 @@ +//! Avid Log Exchange (`.ale`): a tab-delimited clip log (File ▸ Export ▸ Avid Log Exchange…). +//! +//! Written from Avid's published description of the format: three sections introduced by a line +//! holding only `Heading`, `Column` or `Data`, separated by blank lines. +//! +//! ```text +//! Heading +//! FIELD_DELIM TABS +//! VIDEO_FORMAT 1080 +//! AUDIO_FORMAT 48khz +//! FPS 23.976 +//! +//! Column +//! (fields are separated by tab characters) +//! Name Tracks Start End Tape Source File Description … +//! +//! Data +//! Beach VA1A2 01:00:00:00 01:00:10:00 … +//! ``` +//! +//! `Tracks` lists the clip's tracks (`V`, `A1`…`A16`); `Start` and `End` are SMPTE timecode at the +//! heading's `FPS`, `End` exclusive. Values never contain tabs or line breaks (they are replaced by +//! spaces). Lines end with CR LF (Avid's own files); the reader accepts LF and CR too. + +use filmcraft_project::{ItemId, ItemKind, MediaRef, Project}; +use filmcraft_time::{FrameRate, Tick, format_timecode_frames, parse_timecode}; + +/// A parsed or to-be-written ALE file. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct AleDoc { + /// Heading fields in order (`FIELD_DELIM`, `VIDEO_FORMAT`, `AUDIO_FORMAT`, `FPS`, …). + pub heading: Vec<(String, String)>, + pub columns: Vec, + pub rows: Vec>, +} + +impl AleDoc { + pub fn heading(&self, key: &str) -> Option<&str> { + self.heading.iter().find(|(k, _)| k.eq_ignore_ascii_case(key)).map(|(_, v)| v.as_str()) + } + /// The value of `column` in row `row`. + pub fn get(&self, row: usize, column: &str) -> Option<&str> { + let c = self.columns.iter().position(|x| x.eq_ignore_ascii_case(column))?; + self.rows.get(row)?.get(c).map(String::as_str) + } + /// The heading's frame rate (`FPS`). + pub fn rate(&self) -> Option { + self.heading("FPS").and_then(|f| f.trim().parse::().ok()).map(FrameRate::from_f64) + } +} + +/// Metadata columns written after the standard ones (from the items' metadata fields). +const EXTRA: [&str; 6] = ["Description", "Scene", "Take", "Shot", "Log Note", "Comment"]; + +fn clean(s: &str) -> String { + s.replace(['\t', '\r', '\n'], " ") +} + +/// `FPS` heading text for a rate. +pub fn fps_text(r: FrameRate) -> String { + let f = r.as_f64(); + if (f - f.round()).abs() < 1e-6 { format!("{}", f.round() as i64) } else { format!("{:.3}", f).trim_end_matches('0').to_string() } +} + +/// `VIDEO_FORMAT` heading text for a frame height. +fn video_format(height: u32) -> &'static str { + match height { + 1080 => "1080", + 720 => "720", + 480 | 486 => "NTSC", + 576 => "PAL", + _ => "CUSTOM", + } +} + +/// Build the log of `items` (media clips and subclips; other items are skipped). The heading's +/// frame rate is the first video item's (24 fps when there is none). +pub fn export_items(p: &Project, items: &[ItemId]) -> AleDoc { + let media_of = |id: ItemId| -> Option<(&filmcraft_project::MediaClip, Tick, Tick)> { + let it = p.item(id)?; + match &it.kind { + ItemKind::Media(m) => Some((m, Tick::ZERO, m.duration())), + ItemKind::Subclip { parent, range, .. } => p.item(*parent)?.as_media().map(|m| (m, range.start, range.duration)), + _ => None, + } + }; + let rows_src: Vec = items.iter().copied().filter(|i| media_of(*i).is_some()).collect(); + let first_video = rows_src.iter().find_map(|i| media_of(*i).and_then(|(m, ..)| m.info.video.as_ref().map(|v| (v.frame_rate, v.height)))); + let (rate, height) = first_video.unwrap_or((FrameRate::FPS_24, 1080)); + let sr = rows_src.iter().find_map(|i| media_of(*i).and_then(|(m, ..)| m.info.audio.as_ref().map(|a| a.sample_rate))).unwrap_or(48_000); + let heading = vec![ + ("FIELD_DELIM".to_string(), "TABS".to_string()), + ("VIDEO_FORMAT".to_string(), video_format(height).to_string()), + ("AUDIO_FORMAT".to_string(), if sr == 44_100 { "44khz" } else { "48khz" }.to_string()), + ("FPS".to_string(), fps_text(rate)), + ]; + let mut columns: Vec = ["Name", "Tracks", "Start", "End", "Tape", "Source File"].iter().map(|s| s.to_string()).collect(); + columns.extend(EXTRA.iter().map(|s| s.to_string())); + let mut rows = Vec::new(); + for id in rows_src { + let (Some(it), Some((m, start, dur))) = (p.item(id), media_of(id)) else { continue }; + let mut tracks = String::new(); + if m.info.video.is_some() { + tracks.push('V'); + } + for c in 1..=m.info.audio.as_ref().map_or(0, |a| a.channels.min(16)) { + tracks.push_str(&format!("A{c}")); + } + // media start timecode (frames at the media's own rate) + the subclip offset + let tc0 = m.info.start_timecode.map(|f| m.frame_rate().tick_of(f)).unwrap_or(Tick::ZERO); + let a = rate.frame_at(tc0 + start); + let b = rate.frame_at(tc0 + start + dur); + let path = match &m.media { + MediaRef::File { path } => path.clone(), + MediaRef::Generator(_) => String::new(), + }; + let meta = |k: &str| it.metadata.iter().find(|(x, _)| x.eq_ignore_ascii_case(k)).map(|(_, v)| clean(v)).unwrap_or_default(); + let mut row = vec![ + clean(&it.name), + tracks, + format_timecode_frames(a, rate, false), + format_timecode_frames(b.max(a + 1), rate, false), + meta("Tape Name"), + clean(&path), + ]; + row.extend(EXTRA.iter().map(|k| meta(k))); + rows.push(row); + } + AleDoc { heading, columns, rows } +} + +/// Serialise (CR LF line ends). +pub fn write(doc: &AleDoc) -> String { + let mut out = String::from("Heading\r\n"); + for (k, v) in &doc.heading { + out.push_str(&format!("{}\t{}\r\n", clean(k), clean(v))); + } + out.push_str("\r\nColumn\r\n"); + out.push_str(&doc.columns.iter().map(|c| clean(c)).collect::>().join("\t")); + out.push_str("\r\n\r\nData\r\n"); + for r in &doc.rows { + out.push_str(&r.iter().map(|c| clean(c)).collect::>().join("\t")); + out.push_str("\r\n"); + } + out +} + +/// Read an ALE file. +pub fn parse(text: &str) -> Result { + #[derive(PartialEq)] + enum Sec { + None, + Heading, + Column, + Data, + } + let mut doc = AleDoc::default(); + let mut sec = Sec::None; + let text = text.replace("\r\n", "\n").replace('\r', "\n"); + for line in text.lines() { + match line.trim() { + "Heading" => { + sec = Sec::Heading; + continue; + } + "Column" => { + sec = Sec::Column; + continue; + } + "Data" => { + sec = Sec::Data; + continue; + } + "" => continue, + _ => {} + } + match sec { + Sec::None => return Err("not an ALE file (no Heading section)".into()), + Sec::Heading => { + let mut it = line.splitn(2, '\t'); + let k = it.next().unwrap_or_default().trim().to_string(); + doc.heading.push((k, it.next().unwrap_or_default().trim().to_string())); + } + Sec::Column => doc.columns.extend(line.split('\t').map(|c| c.trim().to_string())), + Sec::Data => doc.rows.push(line.split('\t').map(str::to_string).collect()), + } + } + if doc.columns.is_empty() { + return Err("the ALE file has no Column section".into()); + } + Ok(doc) +} + +/// Start and end (exclusive) of a data row, in ticks at the heading's rate. +pub fn row_range(doc: &AleDoc, row: usize) -> Option<(Tick, Tick)> { + let rate = doc.rate()?; + let a = parse_timecode(doc.get(row, "Start")?, rate, false, 0).ok()?; + let b = parse_timecode(doc.get(row, "End")?, rate, false, 0).ok()?; + Some((rate.tick_of(a), rate.tick_of(b))) +} + +#[cfg(test)] +mod tests { + use super::*; + use filmcraft_media::{AudioStreamInfo, MediaInfo, MediaKind, VideoStreamInfo}; + use filmcraft_project::{Label, MediaClip}; + use filmcraft_time::TimeRange; + + fn media(p: &mut Project, name: &str, rate: FrameRate, seconds: i64, tc: Option, audio: u32) -> ItemId { + let info = MediaInfo { + name: name.into(), + kind: MediaKind::Movie, + duration: rate.tick_of(seconds * 24), + video: Some(VideoStreamInfo { + width: 1920, + height: 1080, + frame_rate: rate, + par: (1, 1), + codec: "h264".into(), + pixel_format: "yuv420p".into(), + color: Default::default(), + has_alpha: false, + bitrate: None, + }), + audio: (audio > 0).then(|| AudioStreamInfo { sample_rate: 48_000, channels: audio, codec: "aac".into(), bits_per_sample: None }), + container: "mp4".into(), + start_timecode: tc, + file_size: None, + }; + let clip = MediaClip { + media: MediaRef::File { path: format!("/media/{name}") }, + info, + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }; + p.add_item(name, Label::Iris, ItemKind::Media(clip), None) + } + + #[test] + fn writes_the_three_sections_and_round_trips() { + let mut p = Project::new("ALE"); + let r = FrameRate::FPS_23_976; + let a = media(&mut p, "Beach\tWide.mp4", r, 10, Some(86_400), 2); + p.item_mut(a).unwrap().metadata.insert("Scene".into(), "4".into()); + p.item_mut(a).unwrap().metadata.insert("Tape Name".into(), "A001".into()); + let sub = p.add_item( + "Beach Sub", + Label::Iris, + ItemKind::Subclip { parent: a, range: TimeRange::new(r.tick_of(24), r.tick_of(48)), restrict_trims: false }, + None, + ); + let seq = p.new_sequence("Seq", Default::default(), 1, 1, None); + let doc = export_items(&p, &[a, sub, seq]); + let text = write(&doc); + assert!( + text.starts_with( + "Heading\r\nFIELD_DELIM\tTABS\r\nVIDEO_FORMAT\t1080\r\nAUDIO_FORMAT\t48khz\r\nFPS\t23.976\r\n\r\nColumn\r\nName\tTracks\tStart\tEnd\tTape" + ), + "{text}" + ); + let back = parse(&text).unwrap(); + assert_eq!(back, doc); + assert_eq!(back.rows.len(), 2, "sequences are not logged"); + assert_eq!(back.get(0, "Name"), Some("Beach Wide.mp4")); + assert_eq!(back.get(0, "Tracks"), Some("VA1A2")); + assert_eq!(back.get(0, "Start"), Some("01:00:00:00")); + assert_eq!(back.get(0, "End"), Some("01:00:10:00")); + assert_eq!(back.get(0, "Tape"), Some("A001")); + assert_eq!(back.get(0, "Source File"), Some("/media/Beach Wide.mp4")); + assert_eq!(back.get(0, "Scene"), Some("4")); + assert_eq!(back.get(1, "Start"), Some("01:00:01:00")); + assert_eq!(back.get(1, "End"), Some("01:00:03:00")); + let (s, e) = row_range(&back, 1).unwrap(); + assert_eq!(e - s, r.tick_of(48)); + assert_eq!(back.rate(), Some(r)); + // LF-only files read the same + assert_eq!(parse(&text.replace("\r\n", "\n")).unwrap(), doc); + assert!(parse("hello").is_err()); + } + + #[test] + fn fps_and_formats() { + assert_eq!(fps_text(FrameRate::FPS_29_97), "29.97"); + assert_eq!(fps_text(FrameRate::FPS_25), "25"); + assert_eq!(fps_text(FrameRate::FPS_59_94), "59.94"); + assert_eq!(video_format(576), "PAL"); + assert_eq!(video_format(2160), "CUSTOM"); + } +} diff --git a/crates/interchange/src/lib.rs b/crates/interchange/src/lib.rs index b29f5723..d78d0638 100644 --- a/crates/interchange/src/lib.rs +++ b/crates/interchange/src/lib.rs @@ -18,6 +18,7 @@ //! This crate is L2: it does no file I/O. Media references are strings (absolute paths, or paths //! resolved against the `base_dir` passed to the importer); the engine probes/relinks them. +pub mod ale; pub mod edl; pub mod fcp7; pub mod fcpxml; diff --git a/crates/project/src/find.rs b/crates/project/src/find.rs new file mode 100644 index 00000000..e9584685 --- /dev/null +++ b/crates/project/src/find.rs @@ -0,0 +1,252 @@ +//! Project panel queries: Edit ▸ Find… (column / operator / text rows, match all or any) and +//! search bins (File ▸ New ▸ Search Bin), which are saved queries whose contents update live. +//! +//! A query is evaluated against the text of a column of a project item ([`column_text`]): the +//! name, label, media type, frame rate, duration, video / audio info, file path, or any metadata +//! field (Description, Scene, Shot, Log Note, Tape Name…). `All` searches every column. + +use serde::{Deserialize, Serialize}; + +use crate::{BinId, ItemId, ItemKind, MediaRef, Project, ProjectItem}; + +/// How a row compares the column text with the search text. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FindOp { + #[default] + Contains, + /// The whole column text equals the search text. + Matches, + BeginsWith, + EndsWith, + DoesNotContain, +} + +impl FindOp { + pub const ALL: [FindOp; 5] = [FindOp::Contains, FindOp::Matches, FindOp::BeginsWith, FindOp::EndsWith, FindOp::DoesNotContain]; + + pub fn label(self) -> &'static str { + match self { + FindOp::Contains => "Contains", + FindOp::Matches => "Matches", + FindOp::BeginsWith => "Begins With", + FindOp::EndsWith => "Ends With", + FindOp::DoesNotContain => "Does Not Contain", + } + } + pub fn name(self) -> &'static str { + match self { + FindOp::Contains => "contains", + FindOp::Matches => "matches", + FindOp::BeginsWith => "beginsWith", + FindOp::EndsWith => "endsWith", + FindOp::DoesNotContain => "doesNotContain", + } + } + pub fn from_name(s: &str) -> Option { + let k = s.to_ascii_lowercase().replace([' ', '_', '-'], ""); + FindOp::ALL.into_iter().find(|o| o.name().to_ascii_lowercase() == k || o.label().to_ascii_lowercase().replace(' ', "") == k) + } + fn test(self, hay: &str, needle: &str) -> bool { + match self { + FindOp::Contains => hay.contains(needle), + FindOp::Matches => hay == needle, + FindOp::BeginsWith => hay.starts_with(needle), + FindOp::EndsWith => hay.ends_with(needle), + FindOp::DoesNotContain => !hay.contains(needle), + } + } +} + +/// One row of the Find dialog: ` `. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct FindRow { + /// A column name from [`COLUMNS`] or any metadata field; `All` searches every column. + pub column: String, + pub op: FindOp, + pub text: String, +} + +/// A Find / search-bin query. Empty rows (no text) are ignored; a query without rows matches +/// nothing. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct FindQuery { + pub rows: Vec, + /// Match: All (every row) or Any (one row is enough). + pub match_all: bool, + pub case_sensitive: bool, +} + +/// A saved query shown as a bin in the Project panel (File ▸ New ▸ Search Bin). Its contents are +/// the items matching the query, computed when shown, so they follow every change. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SearchBin { + pub id: BinId, + pub name: String, + pub query: FindQuery, +} + +/// The columns offered by the Find dialog, in its order (metadata fields after the built-ins). +pub const COLUMNS: [&str; 15] = [ + "All", + "Name", + "Label", + "Media Type", + "Frame Rate", + "Media Duration", + "Video Info", + "Audio Info", + "File Path", + "Tape Name", + "Description", + "Comment", + "Log Note", + "Scene", + "Shot", +]; + +/// The text of one column of an item (empty when the item has no such value). +pub fn column_text(p: &Project, it: &ProjectItem, column: &str) -> String { + let media = match &it.kind { + ItemKind::Media(m) => Some(m), + ItemKind::Subclip { parent, .. } => p.item(*parent).and_then(|x| x.as_media()), + _ => None, + }; + match column.to_ascii_lowercase().as_str() { + "name" => it.name.clone(), + "label" => it.label.name().to_string(), + "media type" | "type" => it.type_label().to_string(), + "frame rate" => { + if it.has_video() { + format!("{} fps", it.frame_rate().label()) + } else { + String::new() + } + } + "media duration" | "duration" => { + let rate = it.frame_rate(); + filmcraft_time::format_timecode_frames(rate.frame_at(it.duration()), rate, false) + } + "video info" => media + .and_then(|m| m.info.video.as_ref()) + .map(|v| format!("{} x {} ({:.4}) {}", v.width, v.height, v.par.0 as f64 / v.par.1.max(1) as f64, v.codec)) + .unwrap_or_default(), + "audio info" => media.and_then(|m| m.info.audio.as_ref()).map(|a| format!("{} Hz - {} ch {}", a.sample_rate, a.channels, a.codec)).unwrap_or_default(), + "file path" | "path" => match media.map(|m| &m.media) { + Some(MediaRef::File { path }) => path.clone(), + _ => String::new(), + }, + _ => it.metadata.iter().find(|(k, _)| k.eq_ignore_ascii_case(column)).map(|(_, v)| v.clone()).unwrap_or_default(), + } +} + +impl FindQuery { + /// A one-row query. + pub fn simple(column: &str, op: FindOp, text: &str) -> Self { + FindQuery { rows: vec![FindRow { column: column.into(), op, text: text.into() }], match_all: true, case_sensitive: false } + } + + fn active_rows(&self) -> impl Iterator { + self.rows.iter().filter(|r| !r.text.is_empty()) + } + + pub fn is_empty(&self) -> bool { + self.active_rows().next().is_none() + } + + /// Does `s` satisfy the row's operator? + fn row_matches_text(&self, r: &FindRow, s: &str) -> bool { + if self.case_sensitive { r.op.test(s, &r.text) } else { r.op.test(&s.to_lowercase(), &r.text.to_lowercase()) } + } + + fn row_matches(&self, p: &Project, it: &ProjectItem, r: &FindRow) -> bool { + if r.column.is_empty() || r.column.eq_ignore_ascii_case("all") { + let mut texts: Vec = COLUMNS[1..].iter().map(|c| column_text(p, it, c)).collect(); + texts.extend(it.metadata.values().cloned()); + // "does not contain": no column contains the text; otherwise one column is enough + return if r.op == FindOp::DoesNotContain { + texts.iter().all(|t| self.row_matches_text(r, t)) + } else { + texts.iter().any(|t| self.row_matches_text(r, t)) + }; + } + self.row_matches_text(r, &column_text(p, it, &r.column)) + } + + /// Does the item match? Graphic sources (not shown in the Project panel) never match. + pub fn matches(&self, p: &Project, it: &ProjectItem) -> bool { + if matches!(it.kind, ItemKind::Graphic { .. }) || self.is_empty() { + return false; + } + let mut rows = self.active_rows(); + if self.match_all { rows.all(|r| self.row_matches(p, it, r)) } else { rows.any(|r| self.row_matches(p, it, r)) } + } + + /// Matching items in Project panel order (bins depth-first, then items not in any bin). + pub fn find(&self, p: &Project) -> Vec { + let mut order = Vec::new(); + p.root.all_items(&mut order); + for id in p.items.keys() { + if !order.contains(id) { + order.push(*id); + } + } + order.into_iter().filter(|id| p.item(*id).is_some_and(|it| self.matches(p, it))).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Label, Project}; + + fn project() -> (Project, ItemId, ItemId) { + let mut p = Project::new("Find"); + let st = crate::SequenceSettings::default(); + let a = p.new_sequence("Interview A", st.clone(), 1, 1, None); + let b = p.new_sequence("B-Roll Beach", st, 1, 1, None); + p.item_mut(a).unwrap().metadata.insert("Scene".into(), "12".into()); + p.item_mut(b).unwrap().metadata.insert("Log Note".into(), "sunset, waves".into()); + p.item_mut(b).unwrap().label = Label::Mango; + (p, a, b) + } + + #[test] + fn operators_columns_and_match_modes() { + let (p, a, b) = project(); + assert_eq!(FindQuery::simple("Name", FindOp::Contains, "beach").find(&p), vec![b]); + assert_eq!(FindQuery::simple("Name", FindOp::BeginsWith, "inter").find(&p), vec![a]); + assert_eq!(FindQuery::simple("Name", FindOp::EndsWith, " a").find(&p), vec![a]); + assert_eq!(FindQuery::simple("Name", FindOp::Matches, "b-roll beach").find(&p), vec![b]); + assert!(FindQuery::simple("Name", FindOp::Matches, "b-roll").find(&p).is_empty()); + assert_eq!(FindQuery::simple("Name", FindOp::DoesNotContain, "beach").find(&p), vec![a]); + assert_eq!(FindQuery::simple("Scene", FindOp::Matches, "12").find(&p), vec![a]); + assert_eq!(FindQuery::simple("Label", FindOp::Matches, "mango").find(&p), vec![b]); + assert_eq!(FindQuery::simple("All", FindOp::Contains, "waves").find(&p), vec![b]); + assert_eq!(FindQuery::simple("Media Type", FindOp::Matches, "sequence").find(&p), vec![a, b]); + // case sensitivity + let mut q = FindQuery::simple("Name", FindOp::Contains, "beach"); + q.case_sensitive = true; + assert!(q.find(&p).is_empty()); + // all vs any + let mut q = FindQuery { + rows: vec![ + FindRow { column: "Name".into(), op: FindOp::Contains, text: "a".into() }, + FindRow { column: "Scene".into(), op: FindOp::Matches, text: "12".into() }, + ], + match_all: true, + case_sensitive: false, + }; + assert_eq!(q.find(&p), vec![a]); + q.match_all = false; + assert_eq!(q.find(&p), vec![a, b]); + // empty rows are ignored; no rows match nothing + q.rows[1].text.clear(); + assert_eq!(q.find(&p), vec![a, b]); + assert!(FindQuery::default().find(&p).is_empty()); + assert_eq!(FindOp::from_name("Begins With"), Some(FindOp::BeginsWith)); + assert_eq!(FindOp::from_name("doesNotContain"), Some(FindOp::DoesNotContain)); + } +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 93f4b807..564e6890 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -10,6 +10,7 @@ pub mod caption; pub mod effect; pub mod essential; +pub mod find; pub mod graphic; pub mod keyframe; pub mod mask; @@ -27,6 +28,7 @@ use serde::{Deserialize, Serialize}; pub use caption::{Caption, CaptionAlign, CaptionAnchor, CaptionFormat, CaptionStyle, CaptionTrack, plain_text}; pub use effect::{EffectDef, EffectInstance, EffectKind, ParamDef, ParamKind, effect_defs, find_effect}; pub use essential::{AudioType, EssentialSound}; +pub use find::{FindOp, FindQuery, FindRow, SearchBin}; pub use keyframe::{Interpolation, Keyframe, Param, ParamValue}; pub use mask::{Mask, MaskMode, MaskPath, MaskVertex, TrackMethod}; pub use mixer::{AutomationMode, InputMap, MixerStrip, TrackSend}; @@ -443,6 +445,9 @@ pub enum MarkerKind { Chapter, Segmentation, WebLink, + /// Markers ▸ Add Flash Cue Marker… (a cue point for interactive / Flash-style playback; kept + /// for interchange). Schema v11. + FlashCue, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -974,6 +979,42 @@ pub struct ProjectSettings { /// Project Settings ▸ Ingest Settings: what happens to media on import. #[serde(default)] pub ingest: IngestSettings, + /// Project Settings ▸ General ▸ Action and Title Safe Areas: (horizontal, vertical) percent. + #[serde(default = "title_safe_default")] + pub title_safe: (f64, f64), + #[serde(default = "action_safe_default")] + pub action_safe: (f64, f64), + /// Project Settings ▸ General ▸ Capture Format (`DV` or `HDV`; informational, FilmCraft has no + /// tape capture). Schema v11. + #[serde(default = "capture_format_default")] + pub capture_format: String, + /// Project Settings ▸ Scratch Disks. Schema v11. + #[serde(default)] + pub scratch: ScratchDisks, +} + +fn title_safe_default() -> (f64, f64) { + (20.0, 20.0) +} +fn action_safe_default() -> (f64, f64) { + (10.0, 10.0) +} +fn capture_format_default() -> String { + "DV".into() +} + +/// Project Settings ▸ Scratch Disks: where generated files go. `None` = Same as Project (next to +/// the project file; the data directory for an unsaved project). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ScratchDisks { + /// Captured and Generated media: proxies (when no ingest destination is set), Extract Audio. + pub captured: Option, + /// Video and audio render previews (`/`). + pub video_previews: Option, + pub audio_previews: Option, + /// Project Auto Save versions. + pub auto_save: Option, } /// Ingest on import (Project Settings ▸ Ingest Settings). @@ -1013,6 +1054,10 @@ impl Default for ProjectSettings { default_transition_duration_frames: 24, default_audio_transition_duration: Tick(TICKS_PER_SECOND), ingest: IngestSettings::default(), + title_safe: title_safe_default(), + action_safe: action_safe_default(), + capture_format: capture_format_default(), + scratch: ScratchDisks::default(), } } } @@ -1033,6 +1078,10 @@ pub struct Project { /// are media time. Shared (`Arc`) so undo snapshots don't copy them. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub transcripts: BTreeMap>, + /// Search bins (File ▸ New ▸ Search Bin): saved Find queries listed in the Project panel. + /// Schema v11. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub search_bins: Vec, } /// A LUT imported into the project (`lut.import`). Lumetri refers to it as `lib:`. @@ -1065,6 +1114,7 @@ impl Project { next_id: 1, luts: Vec::new(), transcripts: BTreeMap::new(), + search_bins: Vec::new(), } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f44e422e..66736451 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -23,6 +23,7 @@ pub mod multicam; pub mod offline; pub mod plan; pub mod preview; +pub mod scene; pub mod track; pub mod transitions; diff --git a/crates/render/src/scene.rs b/crates/render/src/scene.rs new file mode 100644 index 00000000..19d32eeb --- /dev/null +++ b/crates/render/src/scene.rs @@ -0,0 +1,246 @@ +//! Scene Edit Detection: find the shot changes (hard cuts) in a clip. +//! +//! Each frame is reduced to a [`Signature`]: a 32×18 grid of mean luma and a 16-bin histogram per +//! RGB channel. The difference of two consecutive frames ([`difference`]) mixes the histogram +//! distance (half the L1 distance of the normalised histograms, 0…1) with the mean absolute luma +//! difference of the grids (0…1). A hard cut changes both; camera or subject motion moves the grid +//! but keeps the histogram, and a lighting change shifts the histogram smoothly over many frames. +//! +//! [`detect_cuts`] marks frame *i* as the first frame of a new shot when its difference is above an +//! absolute threshold set by the sensitivity **and** clearly above the local activity (the median of +//! the differences around it), so busy footage needs a bigger jump than static footage. Shots +//! shorter than `min_shot_frames` are merged into the previous one (flashes, single bad frames). + +/// Grid size of a signature. +const GW: usize = 32; +const GH: usize = 18; +const BINS: usize = 16; + +/// The reduced picture of one frame. +#[derive(Clone, Debug, PartialEq)] +pub struct Signature { + grid: Vec, + hist: [f32; 3 * BINS], +} + +/// Reduce an RGBA8 frame (`w`×`h`, row-major, 4 bytes per pixel) to its signature. +pub fn signature(rgba: &[u8], w: usize, h: usize) -> Signature { + let mut grid = vec![0f32; GW * GH]; + let mut count = vec![0u32; GW * GH]; + let mut hist = [0f32; 3 * BINS]; + if w == 0 || h == 0 || rgba.len() < w * h * 4 { + return Signature { grid, hist }; + } + // sample at most ~256×144 pixels: plenty for both measures and cheap for 4K frames + let step_x = (w / 256).max(1); + let step_y = (h / 144).max(1); + let mut n = 0u32; + for y in (0..h).step_by(step_y) { + let gy = y * GH / h; + for x in (0..w).step_by(step_x) { + let i = (y * w + x) * 4; + let (r, g, b) = (rgba[i] as f32, rgba[i + 1] as f32, rgba[i + 2] as f32); + let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; + let gi = gy * GW + x * GW / w; + grid[gi] += luma; + count[gi] += 1; + hist[rgba[i] as usize * BINS / 256] += 1.0; + hist[BINS + rgba[i + 1] as usize * BINS / 256] += 1.0; + hist[2 * BINS + rgba[i + 2] as usize * BINS / 256] += 1.0; + n += 1; + } + } + for (g, c) in grid.iter_mut().zip(&count) { + *g = if *c > 0 { *g / *c as f32 / 255.0 } else { 0.0 }; + } + let inv = 1.0 / n.max(1) as f32; + hist.iter_mut().for_each(|v| *v *= inv); + Signature { grid, hist } +} + +/// How different two frames look, 0 (identical) … 1. +pub fn difference(a: &Signature, b: &Signature) -> f32 { + // per channel: half the L1 distance of two distributions is in 0..1 + let h: f32 = a.hist.iter().zip(&b.hist).map(|(x, y)| (x - y).abs()).sum::() / 6.0; + let g: f32 = a.grid.iter().zip(&b.grid).map(|(x, y)| (x - y).abs()).sum::() / (GW * GH) as f32; + // the grid term rarely exceeds ~0.5 even between unrelated shots: weigh it up + (0.5 * h + 0.5 * (2.0 * g).min(1.0)).clamp(0.0, 1.0) +} + +/// Detection settings. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SceneOptions { + /// 0 (only the most obvious cuts) … 100 (also subtle ones). Default 50. + pub sensitivity: f32, + /// Shortest shot kept, in frames (a cut closer than this to the previous one is ignored). + pub min_shot_frames: usize, +} + +impl Default for SceneOptions { + fn default() -> Self { + SceneOptions { sensitivity: 50.0, min_shot_frames: 6 } + } +} + +impl SceneOptions { + /// The absolute difference a cut must exceed. + pub fn threshold(&self) -> f32 { + let s = self.sensitivity.clamp(0.0, 100.0) / 100.0; + 0.45 - 0.33 * s + } +} + +/// Frames that start a new shot. `diffs[i]` is the difference between frame `i - 1` and frame +/// `i` (`diffs[0]` is ignored). +pub fn detect_cuts(diffs: &[f32], opt: &SceneOptions) -> Vec { + const WINDOW: usize = 5; + let thr = opt.threshold(); + let mut cuts: Vec = Vec::new(); + for i in 1..diffs.len() { + let d = diffs[i]; + if d < thr { + continue; + } + // local activity: median of the neighbours (the candidate itself excluded) + let lo = i.saturating_sub(WINDOW).max(1); + let hi = (i + WINDOW + 1).min(diffs.len()); + let mut around: Vec = (lo..hi).filter(|&j| j != i).map(|j| diffs[j]).collect(); + let med = if around.is_empty() { + 0.0 + } else { + around.sort_by(f32::total_cmp); + around[around.len() / 2] + }; + if d < 2.5 * med + 0.05 { + continue; + } + // a local maximum (two-frame dissolves report their larger step once) + if diffs.get(i + 1).is_some_and(|n| *n > d) || (i > 1 && diffs[i - 1] > d && cuts.last() != Some(&(i - 1))) { + continue; + } + let since = cuts.last().copied().unwrap_or(0); + if i - since < opt.min_shot_frames.max(1) { + continue; + } + cuts.push(i); + } + cuts +} + +/// Incremental detector: push frames in order, read the cuts at the end. +#[derive(Clone, Debug, Default)] +pub struct SceneDetector { + prev: Option, + diffs: Vec, +} + +impl SceneDetector { + pub fn new() -> Self { + Self::default() + } + /// Add the next frame; returns its difference to the previous one. + pub fn push_rgba(&mut self, rgba: &[u8], w: usize, h: usize) -> f32 { + self.push(signature(rgba, w, h)) + } + pub fn push(&mut self, sig: Signature) -> f32 { + let d = self.prev.as_ref().map(|p| difference(p, &sig)).unwrap_or(0.0); + self.diffs.push(d); + self.prev = Some(sig); + d + } + pub fn diffs(&self) -> &[f32] { + &self.diffs + } + pub fn cuts(&self, opt: &SceneOptions) -> Vec { + detect_cuts(&self.diffs, opt) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A synthetic shot: a pattern chosen by `shot`, moving with time `t` (pan + noise). + fn frame(shot: u32, t: u32, w: usize, h: usize) -> Vec { + let mut v = vec![0u8; w * h * 4]; + let mut seed = shot.wrapping_mul(2_654_435_761) ^ t.wrapping_mul(40_503); + for y in 0..h { + for x in 0..w { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345); + let noise = ((seed >> 16) % 9) as i32 - 4; + let xx = (x as u32 + 2 * t) as f32; + let (r, g, b) = match shot % 4 { + 0 => (40.0 + 150.0 * (xx * 0.05).sin().abs(), 90.0, 160.0), + 1 => (200.0, 180.0 - (y as f32) * 0.5, 40.0 + (xx * 0.1).cos().abs() * 60.0), + 2 => (if (xx as usize / 8 + y / 8).is_multiple_of(2) { 230.0 } else { 20.0 }, 120.0, 60.0), + _ => (30.0, 30.0 + (y as f32 * 1.5).min(200.0), 30.0 + (xx * 0.03).sin().abs() * 200.0), + }; + let i = (y * w + x) * 4; + v[i] = (r as i32 + noise).clamp(0, 255) as u8; + v[i + 1] = (g as i32 + noise).clamp(0, 255) as u8; + v[i + 2] = (b as i32 + noise).clamp(0, 255) as u8; + v[i + 3] = 255; + } + } + v + } + + fn run(shots: &[(u32, u32)], opt: &SceneOptions) -> Vec { + let mut d = SceneDetector::new(); + let mut t = 0; + for &(shot, len) in shots { + for _ in 0..len { + d.push_rgba(&frame(shot, t, 96, 54), 96, 54); + t += 1; + } + } + d.cuts(opt) + } + + #[test] + fn finds_hard_cuts_and_ignores_motion() { + let cuts = run(&[(0, 20), (1, 15), (2, 25), (3, 12), (0, 10)], &SceneOptions::default()); + assert_eq!(cuts, vec![20, 35, 60, 72]); + // one continuous moving shot: no cuts at any sensitivity + for s in [0.0, 50.0, 100.0] { + assert!(run(&[(2, 60)], &SceneOptions { sensitivity: s, ..Default::default() }).is_empty(), "sensitivity {s}"); + } + } + + #[test] + fn min_shot_length_merges_flashes() { + // a 2-frame flash of another shot inside shot 0: one cut into the flash is kept, the cut + // back out (2 frames later) is closer than the minimum shot length + let cuts = run(&[(0, 20), (1, 2), (0, 20)], &SceneOptions::default()); + assert_eq!(cuts, vec![20]); + let cuts = run(&[(0, 20), (1, 2), (0, 20)], &SceneOptions { min_shot_frames: 1, ..Default::default() }); + assert_eq!(cuts, vec![20, 22]); + } + + #[test] + fn sensitivity_controls_subtle_cuts() { + // a subtle cut: the same pattern with a small global brightness change + let mk = |k: i32| { + let mut f = frame(0, 0, 64, 36); + f.chunks_mut(4).for_each(|p| (0..3).for_each(|c| p[c] = (p[c] as i32 + k).clamp(0, 255) as u8)); + signature(&f, 64, 36) + }; + let mut d = SceneDetector::new(); + for i in 0..30 { + d.push(mk(if i < 15 { 0 } else { 12 })); + } + let diff = d.diffs()[15]; + assert!(diff > 0.1 && diff < 0.4, "subtle diff {diff}"); + assert!(d.cuts(&SceneOptions { sensitivity: 0.0, ..Default::default() }).is_empty()); + assert_eq!(d.cuts(&SceneOptions { sensitivity: 100.0, ..Default::default() }), vec![15]); + assert!(SceneOptions { sensitivity: 100.0, ..Default::default() }.threshold() < SceneOptions::default().threshold()); + } + + #[test] + fn degenerate_inputs() { + assert!(detect_cuts(&[], &SceneOptions::default()).is_empty()); + assert!(detect_cuts(&[0.0], &SceneOptions::default()).is_empty()); + let s = signature(&[], 0, 0); + assert_eq!(difference(&s, &s), 0.0); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9e39f018..6ec751d1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -180,6 +180,13 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) d.params[k.as_str()] = v.clone(); } } + // fields of the open M3.11 menu dialog (`panels::menu_dialogs`) + if let Some(m) = p.get("menuDialog").and_then(Value::as_object) { + let Some(d) = app.ui.extras.dialog.as_mut() else { return err("no menu dialog is open") }; + for (k, v) in m { + d.params[k.as_str()] = v.clone(); + } + } ok(Value::Null) } "ui.panel.show" | "ui.panel.close" => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 44e8e435..460beb8f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -62,6 +62,9 @@ pub struct HostHooks { /// (macOS: `orderFrontRegardless`). Without it the app only requests a repaint: it never /// activates itself for an agent, because the user's keystrokes would land here. pub raise_without_focus: Option>, + /// Open a file in its default application, or (`true`) reveal it in the file manager (Edit ▸ + /// Edit Original, Help ▸ Reveal Log Files). + pub open_path: Option Result<(), String>>>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb5096bd..9d65616a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -77,6 +77,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("view.guideTemplates.apply", "Apply Guide Template", [], None), uic!("view.guideTemplates.delete", "Delete Guide Template", [], None), uic!("view.compare.setReference", "Set Comparison Reference", [], None), + uic!("view.dynamicAudioWaveforms", "Dynamic Audio Waveforms", ["View"], None), uic!("multicam.toggleView", "Multi-Camera View", ["View"], Some("Shift+0")), uic!("multicam.recordToggle", "Multi-Camera Record On/Off Toggle", [], Some("0")), uic!("view.theme.dark", "Darkest", ["View", "Appearance"], None), @@ -111,6 +112,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("help.appPage", "FilmCraft on getartcraft.com", ["Help"], None), uic!("help.github", "FilmCraft on GitHub", ["Help"], None), uic!("help.reportIssue", "Report an Issue…", ["Help"], None), + uic!("help.revealLogFiles", "Reveal Log Files…", ["Help"], None), + uic!("help.systemCompatibilityReport", "System Compatibility Report…", ["Help"], None), uic!("app.about", "About FilmCraft", ["Help"], None), uic!("app.keyboardShortcuts", "Keyboard Shortcuts…", ["Edit"], Some("Cmd+Alt+K")), uic!("app.preferences.autoSave", "Auto Save…", ["Edit", "Preferences"], Some("Cmd+,")), @@ -154,6 +157,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val if let Some(r) = crate::panels::clip_dialogs::route(app, id, ¶ms) { return r; } + if let Some(r) = crate::panels::menu_dialogs::route(app, ctx, id, ¶ms) { + if let Err(e) = &r { + app.ui.status = e.clone(); + } + return r; + } if let Some(r) = crate::panels::media_dialogs::route(app, id, ¶ms) { if let Err(e) = &r { app.ui.status = e.clone(); @@ -327,7 +336,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { let mut v = menu_items_for(&app.session); for it in &mut v { if it.id.starts_with("view.") { - it.checked = crate::panels::monitor_view::checked(app, &it.id); + it.checked = crate::panels::monitor_view::checked(app, &it.id).or(crate::panels::menu_dialogs::checked(app, &it.id)); it.enabled &= crate::panels::monitor_view::enabled(app, &it.id); } } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 217c3353..35f54e02 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -7,6 +7,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { crate::panels::media_dialogs::show(app, ctx); crate::panels::color_dialogs::show(app, ctx); crate::panels::clip_dialogs::show(app, ctx); + crate::panels::menu_dialogs::show(app, ctx); crate::panels::presets::save_dialog(app, ctx); crate::panels::multicam::show_dialog(app, ctx); crate::panels::monitor_view::dialogs(app, ctx); diff --git a/crates/ui-egui/src/panels/menu_dialogs.rs b/crates/ui-egui/src/panels/menu_dialogs.rs new file mode 100644 index 00000000..d134e176 --- /dev/null +++ b/crates/ui-egui/src/panels/menu_dialogs.rs @@ -0,0 +1,896 @@ +//! Menu long tail (M3.11): the dialogs of Find, Create Search Bin, Project Settings (General / +//! Scratch Disks / Ingest Settings), Get Media File Properties, Edit Offline, Source Settings, +//! Update Metadata, Automate to Sequence, Scene Edit Detection, Normalize Mix Track, Simplify +//! Sequence, Transcribe Sequence, Save as Template, Add Flash Cue Marker and the System +//! Compatibility Report; plus the frontend side of Edit Original / Reveal Log Files (the OS +//! opener), Generate Audio Waveform (the timeline's peak cache), Import from Media Browser (the +//! Media Browser selection) and View ▸ Dynamic Audio Waveforms. +//! +//! The open dialog lives in `UiState::extras.dialog` (serde, like `clip_dialogs`): the engine +//! command it runs and the parameters being edited, so agents open it from the menu, change +//! `params` with `ui.set {"menuDialog": {...}}` and click OK. Menu items invoked without params +//! open the dialog; with params the engine command runs directly. +//! +//! Automation ids (`

` = the dialog prefix, `` = a parameter): `

.ok`, `

.cancel` and +//! `

.` for each control; radio buttons `

..`; Find rows +//! `find.row..column|operator|text`; Project Settings tabs `projectSettings.tab.`, +//! scratch rows `projectSettings.scratch.` / `.browse` / `.same`. + +use egui::{Align2, RichText}; +use filmcraft_project::{FindOp, find::COLUMNS}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::FilmcraftApp; +use crate::dock::PanelKind; +use crate::state::ClipDialogDraft; + +/// Frontend state of the M3.11 menu items (`UiState::extras`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Extras { + /// The open dialog (None = closed). + pub dialog: Option, + /// View ▸ Dynamic Audio Waveforms: logarithmic (dB) waveform display; off = linear amplitude. + pub dynamic_waveforms: bool, + /// Files selected in the Media Browser (File ▸ Import from Media Browser). + pub media_browser_selection: Vec, + /// Paths handed to the operating system's opener (Edit Original, Reveal Log Files), newest last. + pub opened: Vec, +} + +impl Default for Extras { + fn default() -> Self { + Extras { dialog: None, dynamic_waveforms: true, media_browser_selection: Vec::new(), opened: Vec::new() } + } +} + +type Elems = Vec<(String, egui::Rect, String)>; + +fn push(elems: &mut Elems, id: impl Into, r: &egui::Response, label: impl Into) { + elems.push((id.into(), r.rect, label.into())); +} + +/// Dialog title, automation prefix and OK button text of a command's dialog. +fn meta(command: &str) -> Option<(&'static str, &'static str, &'static str)> { + Some(match command { + "edit.find" => ("Find", "find", "Find"), + "file.newSearchBin" => ("Create Search Bin", "searchBin", "OK"), + "project.settings" => ("Project Settings", "projectSettings", "OK"), + "file.mediaProperties" => ("Properties", "properties", "Close"), + "clip.editOffline" => ("Edit Offline File", "editOffline", "OK"), + "clip.sourceSettings" => ("Source Settings", "sourceSettings", "OK"), + "clip.updateMetadata" => ("Update Metadata", "updateMetadata", "OK"), + "clip.automateToSequence" => ("Automate To Sequence", "automate", "OK"), + "clip.sceneEditDetection" => ("Scene Edit Detection", "sceneDetect", "Analyze"), + "sequence.normalizeMixTrack" => ("Normalize Mix Track", "normalizeMix", "OK"), + "sequence.simplify" => ("Simplify Sequence", "simplify", "Simplify"), + "sequence.transcribe" => ("Transcribe Sequence", "transcribe", "Transcribe"), + "file.saveAsTemplate" => ("Save as Template", "saveTemplate", "Save"), + "markers.addFlashCue" => ("Flash Cue Marker", "flashCue", "OK"), + "help.systemCompatibilityReport" => ("System Compatibility Report", "systemReport", "Close"), + _ => return None, + }) +} + +/// Commands whose menu item opens a dialog (the Project Settings items share one). +fn dialog_command(id: &str) -> Option<&'static str> { + Some(match id { + "file.projectSettings.general" | "file.projectSettings.scratchDisks" | "project.ingestSettings" => "project.settings", + "edit.find" => "edit.find", + "file.newSearchBin" => "file.newSearchBin", + "file.mediaProperties" => "file.mediaProperties", + "clip.editOffline" => "clip.editOffline", + "clip.sourceSettings" => "clip.sourceSettings", + "clip.updateMetadata" => "clip.updateMetadata", + "clip.automateToSequence" => "clip.automateToSequence", + "clip.sceneEditDetection" => "clip.sceneEditDetection", + "sequence.normalizeMixTrack" => "sequence.normalizeMixTrack", + "sequence.simplify" => "sequence.simplify", + "sequence.transcribe" => "sequence.transcribe", + "file.saveAsTemplate" => "file.saveAsTemplate", + "markers.addFlashCue" => "markers.addFlashCue", + _ => return None, + }) +} + +fn enabled(app: &FilmcraftApp, id: &str) -> Result<(), String> { + filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session)) +} + +/// Checkmark state of this module's toggles in the menus. +pub fn checked(app: &FilmcraftApp, id: &str) -> Option { + (id == "view.dynamicAudioWaveforms").then_some(app.ui.extras.dynamic_waveforms) +} + +/// Project Settings ▸ General ▸ Renderer is "Software Only": the Program monitor composites on +/// the CPU. +pub fn software_renderer(app: &FilmcraftApp) -> bool { + app.session.project.settings.renderer == filmcraft_engine::project_tools::RENDERER_SOFTWARE +} + +/// Hand a path to the operating system (open it in its default application, or reveal it in the +/// file manager). The host hook does it on the desktop; otherwise the path is opened as a +/// `file://` URL. +pub fn open_path(app: &mut FilmcraftApp, ctx: &egui::Context, path: &str, reveal: bool) -> Result<(), String> { + app.ui.extras.opened.push(path.to_string()); + if app.ui.extras.opened.len() > 50 { + app.ui.extras.opened.remove(0); + } + match app.hooks.open_path.as_mut() { + Some(f) => f(path, reveal), + None => { + let p = path.replace('\\', "/"); + ctx.open_url(egui::OpenUrl::new_tab(format!("file://{}{p}", if p.starts_with('/') { "" } else { "/" }))); + Ok(()) + } + } +} + +/// Where Reveal Log Files points: `/Logs` (the temp folder without a data directory). +pub fn logs_dir(app: &FilmcraftApp) -> std::path::PathBuf { + app.session.prefs_path.as_ref().and_then(|p| p.parent()).map(|d| d.join("Logs")).unwrap_or_else(|| std::env::temp_dir().join("FilmCraft Logs")) +} + +/// Write this session's log (system report and the command journal) into [`logs_dir`]. +fn write_session_log(app: &FilmcraftApp) -> Result { + let dir = logs_dir(app); + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let path = dir.join(format!("session-{}.log", std::process::id())); + let mut text = format!("{}\n", system_report(app)); + for (id, p) in app.session.journal.iter().rev().take(2000).collect::>().into_iter().rev() { + text.push_str(&format!("{}\n", json!({"command": id, "params": p}))); + } + std::fs::write(&path, text).map_err(|e| format!("{}: {e}", path.display()))?; + Ok(path) +} + +/// The engine's report plus the GPU adapter (wgpu) when the GPU compositor is on. +pub fn system_report(app: &FilmcraftApp) -> Value { + let mut r = filmcraft_engine::project_tools::system_report(); + let gpu = app.gpu.as_ref().map(|g| { + let i = g.render_state.adapter.get_info(); + json!({"name": i.name, "backend": format!("{:?}", i.backend), "type": format!("{:?}", i.device_type), "driver": i.driver, "driverInfo": i.driver_info}) + }); + let ok = gpu.is_some(); + r["gpu"] = gpu.unwrap_or(Value::Null); + let detail = if ok { r["gpu"]["name"].as_str().unwrap_or("wgpu adapter").to_string() } else { "no GPU adapter: the CPU compositor is used".to_string() }; + if let Some(c) = r["checks"].as_array_mut() { + c.push(json!({"name": "GPU acceleration", "ok": ok, "detail": detail})); + } + r +} + +/// Menu / shortcut entry points. Returns None for ids this module doesn't handle. +pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { + let empty = params.as_object().is_none_or(|m| m.is_empty()); + // UI commands + match id { + "view.dynamicAudioWaveforms" => { + app.ui.extras.dynamic_waveforms = params.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.extras.dynamic_waveforms); + return Some(Ok(json!({"dynamicAudioWaveforms": app.ui.extras.dynamic_waveforms}))); + } + "help.revealLogFiles" => { + let r = write_session_log(app).and_then(|p| { + let s = p.to_string_lossy().to_string(); + open_path(app, ctx, &s, true).map(|_| json!({"path": s})) + }); + return Some(r); + } + "help.systemCompatibilityReport" => { + let report = system_report(app); + app.ui.extras.dialog = Some(ClipDialogDraft { command: id.into(), params: json!({}), info: report.clone(), error: String::new() }); + return Some(Ok(json!({"dialog": "systemReport", "report": report}))); + } + "edit.editOriginal" => { + let r = app.session.execute(id, params.clone()).map_err(|e| e.to_string()); + return Some(r.and_then(|v| { + for p in v["paths"].as_array().cloned().unwrap_or_default() { + open_path(app, ctx, p.as_str().unwrap_or_default(), false)?; + } + Ok(v) + })); + } + "clip.generateAudioWaveform" => { + let r = app.session.execute(id, params.clone()).map_err(|e| e.to_string()); + if let Ok(v) = &r { + let mut peaks = app.tl.peaks.lock().unwrap_or_else(|e| e.into_inner()); + for i in v["items"].as_array().cloned().unwrap_or_default() { + peaks.remove(&filmcraft_engine::project::ItemId(i.as_u64().unwrap_or_default())); + } + } + return Some(r); + } + "file.importFromMediaBrowser" if params.get("paths").is_none() => { + let paths = app.ui.extras.media_browser_selection.clone(); + if paths.is_empty() { + return Some(Err("select files in the Media Browser".into())); + } + return Some(app.session.execute(id, json!({"paths": paths})).map_err(|e| e.to_string())); + } + "file.exportSelectionProject" | "file.exportAle" if params.get("path").is_none() => { + if let Err(e) = enabled(app, id) { + return Some(Err(e)); + } + let (filter, ext, name) = if id == "file.exportAle" { + ("Avid Log Exchange", "ale", format!("{}.ale", app.session.project.name)) + } else { + ("FilmCraft Project", "fcproj", format!("{} Selection.fcproj", app.session.project.name)) + }; + let Some(path) = app.hooks.pick_save_as.as_mut().and_then(|f| f(filter, &[ext], &name)) else { + return Some(Ok(Value::Null)); + }; + let mut p = params.clone(); + if !p.is_object() { + p = json!({}); + } + p["path"] = json!(path); + return Some(app.session.execute(id, p).map_err(|e| e.to_string())); + } + "file.mediaPropertiesFile" if params.get("path").is_none() => { + let exts: Vec<&str> = + filmcraft_media::VIDEO_EXTENSIONS.iter().chain(filmcraft_media::AUDIO_EXTENSIONS).chain(filmcraft_media::STILL_EXTENSIONS).copied().collect(); + let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f("Media", &exts)) else { return Some(Ok(Value::Null)) }; + let r = app.session.execute(id, json!({"path": path})).map_err(|e| e.to_string()); + if let Ok(v) = &r { + app.ui.extras.dialog = + Some(ClipDialogDraft { command: "file.mediaProperties".into(), params: json!({}), info: json!([v]), error: String::new() }); + } + return Some(r); + } + _ => {} + } + if !empty { + return None; + } + let cmd = dialog_command(id)?; + if let Err(e) = enabled(app, id) { + app.ui.status = e.clone(); + return Some(Err(e)); + } + let (params, info) = match defaults(app, cmd, id) { + Ok(x) => x, + Err(e) => return Some(Err(e)), + }; + app.ui.extras.dialog = Some(ClipDialogDraft { command: cmd.into(), params, info, error: String::new() }); + Some(Ok(json!({"dialog": meta(cmd).map(|m| m.1)}))) +} + +/// Initial parameters (and display info) of a dialog. +fn defaults(app: &mut FilmcraftApp, cmd: &str, id: &str) -> Result<(Value, Value), String> { + let s = &mut app.session; + Ok(match cmd { + "edit.find" => { + let timeline = app.ui.focused == PanelKind::Timeline && s.active_sequence().is_some(); + let last = s.state.find.clone(); + let rows = match &last { + Some(f) if f.scope == "project" => { + f.query.rows.iter().map(|r| json!({"column": r.column, "operator": r.op.name(), "text": r.text})).collect::>() + } + _ => vec![json!({"column": "Name", "operator": "contains", "text": ""})], + }; + let mut rows = rows; + while rows.len() < 2 { + rows.push(json!({"column": "Name", "operator": "contains", "text": ""})); + } + (json!({"scope": if timeline { "timeline" } else { "project" }, "rows": rows, "matchAll": true, "caseSensitive": false, "in": "all"}), json!({})) + } + "file.newSearchBin" => { + let text = app.ui.project_search.clone(); + (json!({"name": "", "column": "All", "operator": "contains", "text": text}), Value::Null) + } + "project.settings" => { + let tab = match id { + "file.projectSettings.scratchDisks" => "scratchDisks", + "project.ingestSettings" => "ingest", + _ => "general", + }; + let st = s.project.settings.clone(); + let general = s.execute("file.projectSettings.general", json!({})).map_err(|e| e.to_string())?; + let scratch = filmcraft_engine::project_tools::scratch_paths(s); + let ingest = serde_json::to_value(&st.ingest).unwrap_or_default(); + let sc = &st.scratch; + ( + json!({ + "tab": tab, + "renderer": general["renderer"], "videoDisplay": general["videoDisplay"], "audioDisplay": general["audioDisplay"], + "captureFormat": general["captureFormat"], "titleSafe": general["titleSafe"], "actionSafe": general["actionSafe"], + "captured": sc.captured, "videoPreviews": sc.video_previews, "audioPreviews": sc.audio_previews, "autoSave": sc.auto_save, + "ingest": ingest.clone(), + }), + json!({"scratch": scratch, "ingest": ingest, "project": s.project.name}), + ) + } + "file.mediaProperties" => { + let v = s.execute("file.mediaProperties", json!({})).map_err(|e| e.to_string())?; + (json!({}), v) + } + "clip.editOffline" => { + let item = s.state.project_selection.first().copied().and_then(|i| s.project.item(i)).cloned(); + let mut p = json!({}); + if let Some(it) = item { + p["item"] = json!(it.id.0); + p["mediaName"] = json!(it.name); + for (k, key) in filmcraft_engine::project_tools::OFFLINE_FIELDS { + p[k] = json!(it.metadata.get(key).cloned().unwrap_or_default()); + } + } + (p, Value::Null) + } + "clip.sourceSettings" => (json!({}), s.execute("clip.sourceSettings", json!({})).map_err(|e| e.to_string())?), + "clip.updateMetadata" => { + let n = s.state.project_selection.len().max(s.state.selection.len()); + (json!({}), json!({"count": n})) + } + "clip.automateToSequence" => ( + json!({"ordering": "sort", "placement": "sequentially", "method": "insert", "overlapFrames": 30, "stillFrames": 150, "videoTransition": true, "audioTransition": true, "ignoreAudio": false, "ignoreVideo": false}), + json!({"sequence": s.active_sequence().map(|_| s.state.active_sequence.and_then(|q| s.project.item(q)).map(|i| i.name.clone()).unwrap_or_default())}), + ), + "clip.sceneEditDetection" => (json!({"applyCuts": true, "createSubclips": false, "generateMarkers": false, "sensitivity": 50.0}), Value::Null), + "sequence.normalizeMixTrack" => (json!({"db": 0.0}), Value::Null), + "sequence.simplify" => { + let name = s.state.active_sequence.and_then(|q| s.project.item(q)).map(|i| format!("{} (Simplified)", i.name)).unwrap_or_default(); + ( + json!({"name": name, "removeDisabled": true, "removeEmptyTracks": true, "closeGaps": false, "moveClipsDown": false, "removeVideoEffects": false, "removeAudioEffects": false, "removeText": false, "keep": "both"}), + Value::Null, + ) + } + "sequence.transcribe" => { + let tracks: Vec = s.active_sequence().map(|q| (1..=q.audio_tracks.len()).map(|i| format!("A{i}")).collect()).unwrap_or_default(); + (json!({"language": "auto", "track": "mix", "diarize": true}), json!({"tracks": tracks})) + } + "file.saveAsTemplate" => (json!({"name": s.project.name}), Value::Null), + "markers.addFlashCue" => (json!({"name": "", "comment": ""}), Value::Null), + _ => (json!({}), Value::Null), + }) +} + +fn check(ui: &mut egui::Ui, elems: &mut Elems, pre: &str, p: &mut Value, key: &str, label: &str) { + let mut v = p.get(key).and_then(Value::as_bool).unwrap_or(false); + let r = ui.checkbox(&mut v, label); + push(elems, format!("{pre}.{key}"), &r, label); + if r.changed() { + p[key] = json!(v); + } +} + +fn text(ui: &mut egui::Ui, elems: &mut Elems, pre: &str, p: &mut Value, key: &str, label: &str, width: f32) { + ui.horizontal(|ui| { + ui.label(label); + let mut v = p.get(key).and_then(Value::as_str).unwrap_or_default().to_string(); + let r = ui.add(egui::TextEdit::singleline(&mut v).desired_width(width)); + push(elems, format!("{pre}.{key}"), &r, label); + if r.changed() { + p[key] = json!(v); + } + }); +} + +fn number(ui: &mut egui::Ui, elems: &mut Elems, pre: &str, p: &mut Value, key: &str, label: &str, range: std::ops::RangeInclusive, suffix: &str) { + ui.horizontal(|ui| { + ui.label(label); + let mut v = p.get(key).and_then(Value::as_f64).unwrap_or(0.0); + let r = ui.add(egui::DragValue::new(&mut v).range(range).suffix(suffix)); + push(elems, format!("{pre}.{key}"), &r, label); + if r.changed() { + p[key] = json!(v); + } + }); +} + +fn radios(ui: &mut egui::Ui, elems: &mut Elems, pre: &str, p: &mut Value, key: &str, label: &str, options: &[(&str, &str)]) { + ui.horizontal_wrapped(|ui| { + ui.label(label); + for (k, l) in options { + let r = ui.radio(p[key].as_str() == Some(*k), *l); + push(elems, format!("{pre}.{key}.{k}"), &r, *l); + if r.clicked() { + p[key] = json!(k); + } + } + }); +} + +fn combo(ui: &mut egui::Ui, elems: &mut Elems, id: &str, label: &str, value: &mut Value, options: &[(String, String)]) { + ui.horizontal(|ui| { + if !label.is_empty() { + ui.label(label); + } + let cur = value.as_str().unwrap_or_default().to_string(); + let shown = options.iter().find(|o| o.0 == cur).map(|o| o.1.clone()).unwrap_or(cur.clone()); + let r = egui::ComboBox::from_id_salt(id).selected_text(shown).show_ui(ui, |ui| { + for (k, l) in options { + if ui.selectable_label(cur == *k, l).clicked() { + *value = json!(k); + } + } + }); + push(elems, id, &r.response, label); + }); +} + +fn pairs(v: &[&str]) -> Vec<(String, String)> { + v.iter().map(|x| (x.to_string(), x.to_string())).collect() +} + +/// Draw the open dialog, if any. +pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { + let Some(mut d) = app.ui.extras.dialog.clone() else { return }; + let Some((title, pre, ok_text)) = meta(&d.command) else { + app.ui.extras.dialog = None; + return; + }; + let mut elems: Elems = Vec::new(); + let mut action: Option<&'static str> = None; + let mut browse: Option = None; + let info_only = matches!(d.command.as_str(), "file.mediaProperties" | "clip.sourceSettings" | "help.systemCompatibilityReport"); + egui::Window::new(title).collapsible(false).resizable(false).default_width(420.0).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { + let p = &mut d.params; + match d.command.as_str() { + "edit.find" => { + radios(ui, &mut elems, pre, p, "scope", "Find in:", &[("project", "Project"), ("timeline", "Timeline")]); + ui.separator(); + if p["scope"] == "timeline" { + let mut t = p["rows"][0]["text"].as_str().unwrap_or_default().to_string(); + ui.horizontal(|ui| { + ui.label("Find:"); + let r = ui.add(egui::TextEdit::singleline(&mut t).desired_width(240.0)); + push(&mut elems, "find.row.0.text", &r, "Find"); + }); + p["rows"][0]["text"] = json!(t); + radios(ui, &mut elems, pre, p, "in", "Search:", &[("all", "Clip Names and Markers"), ("clips", "Clip Names"), ("markers", "Markers")]); + } else { + let cols = pairs(&COLUMNS); + let ops: Vec<(String, String)> = FindOp::ALL.iter().map(|o| (o.name().to_string(), o.label().to_string())).collect(); + for n in 0..2 { + let mut row = p["rows"][n].clone(); + ui.horizontal(|ui| { + combo(ui, &mut elems, &format!("find.row.{n}.column"), "", &mut row["column"], &cols); + combo(ui, &mut elems, &format!("find.row.{n}.operator"), "", &mut row["operator"], &ops); + let mut t = row["text"].as_str().unwrap_or_default().to_string(); + let r = ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)); + push(&mut elems, format!("find.row.{n}.text"), &r, "Find What"); + row["text"] = json!(t); + }); + p["rows"][n] = row; + } + ui.horizontal(|ui| { + ui.label("Match:"); + for (v, l) in [(true, "All"), (false, "Any")] { + let r = ui.radio(p["matchAll"].as_bool() == Some(v), l); + push(&mut elems, format!("find.match.{}", l.to_ascii_lowercase()), &r, l); + if r.clicked() { + p["matchAll"] = json!(v); + } + } + check(ui, &mut elems, pre, p, "caseSensitive", "Case Sensitive"); + }); + } + if let Some(m) = d.info.get("result").and_then(Value::as_str) { + ui.label(RichText::new(m).weak()); + } + } + "file.newSearchBin" => { + text(ui, &mut elems, pre, p, "name", "Name:", 220.0); + combo(ui, &mut elems, "searchBin.column", "Search:", &mut p["column"], &pairs(&COLUMNS)); + let ops: Vec<(String, String)> = FindOp::ALL.iter().map(|o| (o.name().to_string(), o.label().to_string())).collect(); + combo(ui, &mut elems, "searchBin.operator", "", &mut p["operator"], &ops); + text(ui, &mut elems, pre, p, "text", "Find:", 220.0); + } + "project.settings" => { + ui.label(format!("Project: {}", d.info["project"].as_str().unwrap_or_default())); + ui.horizontal(|ui| { + for (k, l) in [("general", "General"), ("scratchDisks", "Scratch Disks"), ("ingest", "Ingest Settings")] { + let r = ui.selectable_label(p["tab"].as_str() == Some(k), l); + push(&mut elems, format!("projectSettings.tab.{k}"), &r, l); + if r.clicked() { + p["tab"] = json!(k); + } + } + }); + ui.separator(); + match p["tab"].as_str().unwrap_or("general") { + "scratchDisks" => { + for (k, l) in [ + ("captured", "Captured and Generated:"), + ("videoPreviews", "Video Previews:"), + ("audioPreviews", "Audio Previews:"), + ("autoSave", "Project Auto Save:"), + ] { + ui.label(RichText::new(l).strong()); + ui.horizontal(|ui| { + let mut v = p[k].as_str().unwrap_or_default().to_string(); + let r = ui.add(egui::TextEdit::singleline(&mut v).hint_text("Same as Project").desired_width(240.0)); + push(&mut elems, format!("projectSettings.scratch.{k}"), &r, l); + if r.changed() { + p[k] = if v.is_empty() { Value::Null } else { json!(v) }; + } + let r = ui.button("Browse…"); + push(&mut elems, format!("projectSettings.scratch.{k}.browse"), &r, "Browse"); + if r.clicked() { + browse = Some(k.to_string()); + } + let r = ui.button("Same as Project"); + push(&mut elems, format!("projectSettings.scratch.{k}.same"), &r, "Same as Project"); + if r.clicked() { + p[k] = Value::Null; + } + }); + let path = d.info["scratch"][k]["path"].as_str().unwrap_or_default(); + ui.label(RichText::new(format!("Path: {path}")).weak().small()); + } + } + "ingest" => { + let mut ing = p["ingest"].clone(); + let mut on = ing["enabled"].as_bool().unwrap_or(false); + let r = ui.checkbox(&mut on, "Ingest"); + push(&mut elems, "projectSettings.ingest.enabled", &r, "Ingest"); + ing["enabled"] = json!(on); + let actions = vec![ + ("copy".to_string(), "Copy".to_string()), + ("transcode".to_string(), "Transcode".to_string()), + ("createProxies".to_string(), "Create Proxies".to_string()), + ("copyAndCreateProxies".to_string(), "Copy and Create Proxies".to_string()), + ]; + combo(ui, &mut elems, "projectSettings.ingest.action", "Action:", &mut ing["action"], &actions); + ui.horizontal(|ui| { + ui.label("Destination:"); + let mut v = ing["destination"].as_str().unwrap_or_default().to_string(); + let r = ui.add(egui::TextEdit::singleline(&mut v).hint_text("Next to the media").desired_width(220.0)); + push(&mut elems, "projectSettings.ingest.destination", &r, "Destination"); + if r.changed() { + ing["destination"] = if v.is_empty() { Value::Null } else { json!(v) }; + } + }); + p["ingest"] = ing; + } + _ => { + ui.label(RichText::new("Video Rendering and Playback").strong()); + let rs = vec![ + (filmcraft_engine::project_tools::RENDERER_GPU.to_string(), filmcraft_engine::project_tools::RENDERER_GPU.to_string()), + (filmcraft_engine::project_tools::RENDERER_SOFTWARE.to_string(), filmcraft_engine::project_tools::RENDERER_SOFTWARE.to_string()), + ]; + combo(ui, &mut elems, "projectSettings.renderer", "Renderer:", &mut p["renderer"], &rs); + ui.label(RichText::new("Video").strong()); + let vd: Vec<(String, String)> = + filmcraft_time::TimeDisplay::ALL.iter().map(|t| (t.label().to_string(), t.label().to_string())).collect(); + combo(ui, &mut elems, "projectSettings.videoDisplay", "Display Format:", &mut p["videoDisplay"], &vd); + ui.label(RichText::new("Audio").strong()); + combo( + ui, + &mut elems, + "projectSettings.audioDisplay", + "Display Format:", + &mut p["audioDisplay"], + &pairs(&["Audio Samples", "Milliseconds"]), + ); + ui.label(RichText::new("Capture").strong()); + combo(ui, &mut elems, "projectSettings.captureFormat", "Capture Format:", &mut p["captureFormat"], &pairs(&["DV", "HDV"])); + ui.label(RichText::new("Action and Title Safe Areas").strong()); + for (k, l) in [("titleSafe", "Title Safe Area"), ("actionSafe", "Action Safe Area")] { + ui.horizontal(|ui| { + ui.label(l); + for (i, axis) in ["horizontal", "vertical"].iter().enumerate() { + let mut v = p[k][i].as_f64().unwrap_or(0.0); + let r = ui.add(egui::DragValue::new(&mut v).range(0.0..=50.0).suffix(" %")); + push(&mut elems, format!("projectSettings.{k}.{axis}"), &r, format!("{l} {axis}")); + p[k][i] = json!(v); + ui.label(*axis); + } + }); + } + } + } + } + "file.mediaProperties" => { + let list = d.info.as_array().cloned().unwrap_or_default(); + egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { + for v in list { + ui.label(RichText::new(v["name"].as_str().unwrap_or_default()).strong()); + let line = |ui: &mut egui::Ui, k: &str, val: String| { + if !val.is_empty() && val != "null" { + ui.label(format!("{k}: {val}")); + } + }; + let s = |x: &Value| x.as_str().map(str::to_string).unwrap_or_else(|| if x.is_null() { String::new() } else { x.to_string() }); + line(ui, "File Path", s(&v["path"])); + line(ui, "Type", format!("{} ({})", s(&v["type"]), s(&v["container"]))); + line(ui, "File Size", v["fileSize"].as_u64().map(|b| format!("{:.2} MB", b as f64 / 1e6)).unwrap_or_default()); + line(ui, "Total Duration", s(&v["duration"]["timecode"])); + line(ui, "Average Data Rate", v["dataRateKbps"].as_f64().map(|k| format!("{k} kbit/s")).unwrap_or_default()); + if v["video"].is_object() { + let x = &v["video"]; + line( + ui, + "Video", + format!( + "{} · {} x {} ({}) · {} fps · {}", + s(&x["codec"]), + x["width"], + x["height"], + s(&x["pixelAspectRatio"]), + s(&x["frameRateLabel"]), + s(&x["pixelFormat"]) + ), + ); + line(ui, "Colour", s(&x["color"])); + } + if v["audio"].is_object() { + let x = &v["audio"]; + line(ui, "Audio", format!("{} · {} Hz · {} channel(s)", s(&x["codec"]), x["sampleRate"], x["channels"])); + } + ui.separator(); + } + }); + } + "clip.editOffline" => { + text(ui, &mut elems, pre, p, "mediaName", "Media Name:", 220.0); + text(ui, &mut elems, pre, p, "tapeName", "Tape Name:", 220.0); + text(ui, &mut elems, pre, p, "description", "Description:", 220.0); + text(ui, &mut elems, pre, p, "scene", "Scene:", 220.0); + text(ui, &mut elems, pre, p, "shot", "Shot/Take:", 220.0); + text(ui, &mut elems, pre, p, "logNote", "Log Note:", 220.0); + } + "clip.sourceSettings" => { + ui.label(format!("Codec: {}", d.info["codec"].as_str().unwrap_or_default())); + ui.label(RichText::new(d.info["message"].as_str().unwrap_or_default()).weak()); + } + "clip.updateMetadata" => { + ui.label(format!("Write the metadata of the selected clip(s) ({}) to XMP files next to their media?", d.info["count"])); + ui.label(RichText::new("Existing XMP files written by other applications are left unchanged.").weak()); + } + "clip.automateToSequence" => { + ui.label(format!("To {}", d.info["sequence"].as_str().unwrap_or_default())); + radios(ui, &mut elems, pre, p, "ordering", "Ordering:", &[("sort", "Sort Order"), ("selection", "Selection Order")]); + radios(ui, &mut elems, pre, p, "placement", "Placement:", &[("sequentially", "Sequentially"), ("unnumberedMarkers", "At Unnumbered Markers")]); + radios(ui, &mut elems, pre, p, "method", "Method:", &[("insert", "Insert Edit"), ("overwrite", "Overwrite Edit")]); + number(ui, &mut elems, pre, p, "overlapFrames", "Clip Overlap:", 0.0..=600.0, " frames"); + number(ui, &mut elems, pre, p, "stillFrames", "Frames per Still:", 1.0..=100_000.0, " frames"); + ui.label(RichText::new("Transitions").strong()); + check(ui, &mut elems, pre, p, "audioTransition", "Apply Default Audio Transition"); + check(ui, &mut elems, pre, p, "videoTransition", "Apply Default Video Transition"); + ui.label(RichText::new("Ignore Options").strong()); + check(ui, &mut elems, pre, p, "ignoreAudio", "Ignore Audio"); + check(ui, &mut elems, pre, p, "ignoreVideo", "Ignore Video"); + } + "clip.sceneEditDetection" => { + check(ui, &mut elems, pre, p, "applyCuts", "Apply a cut at each detected cut point"); + check(ui, &mut elems, pre, p, "createSubclips", "Create a subclip for each detected cut point"); + check(ui, &mut elems, pre, p, "generateMarkers", "Generate clip markers at each detected cut point"); + ui.horizontal(|ui| { + ui.label("Sensitivity:"); + let mut v = p["sensitivity"].as_f64().unwrap_or(50.0); + let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0)); + push(&mut elems, "sceneDetect.sensitivity", &r, "Sensitivity"); + p["sensitivity"] = json!(v); + }); + } + "sequence.normalizeMixTrack" => number(ui, &mut elems, pre, p, "db", "Normalize Mix Track to:", -96.0..=24.0, " dB"), + "sequence.simplify" => { + text(ui, &mut elems, pre, p, "name", "Sequence Name:", 240.0); + ui.label(RichText::new("Remove").strong()); + check(ui, &mut elems, pre, p, "removeDisabled", "Disabled clips"); + check(ui, &mut elems, pre, p, "removeEmptyTracks", "Empty tracks"); + check(ui, &mut elems, pre, p, "closeGaps", "Gaps"); + check(ui, &mut elems, pre, p, "removeVideoEffects", "Video effects"); + check(ui, &mut elems, pre, p, "removeAudioEffects", "Audio effects"); + check(ui, &mut elems, pre, p, "removeText", "Text (graphics and captions)"); + ui.label(RichText::new("Clips").strong()); + check(ui, &mut elems, pre, p, "moveClipsDown", "Move clips to the lowest track possible"); + radios(ui, &mut elems, pre, p, "keep", "Keep:", &[("both", "Video and Audio"), ("video", "Video Only"), ("audio", "Audio Only")]); + } + "sequence.transcribe" => { + let langs = pairs(&["auto", "en", "es", "fr", "de", "it", "pt", "ja", "ko", "zh", "nl", "ru"]); + combo(ui, &mut elems, "transcribe.language", "Language:", &mut p["language"], &langs); + let mut tracks: Vec<(String, String)> = vec![("mix".into(), "Mix".into())]; + tracks.extend( + d.info["tracks"] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .filter_map(|t| t.as_str()) + .map(|t| (t.to_string(), format!("Audio on track {t}"))), + ); + combo(ui, &mut elems, "transcribe.track", "Audio analysis:", &mut p["track"], &tracks); + check(ui, &mut elems, pre, p, "diarize", "Recognize when different speakers are talking"); + } + "file.saveAsTemplate" => text(ui, &mut elems, pre, p, "name", "Template Name:", 240.0), + "markers.addFlashCue" => { + text(ui, &mut elems, pre, p, "name", "Name:", 220.0); + text(ui, &mut elems, pre, p, "comment", "Comments:", 220.0); + } + "help.systemCompatibilityReport" => { + let r = &d.info; + ui.label(RichText::new(r["app"].as_str().unwrap_or_default()).strong()); + for c in r["checks"].as_array().cloned().unwrap_or_default() { + let ok = c["ok"].as_bool().unwrap_or(false); + ui.label(format!( + "{} {}: {}", + if ok { "✔" } else { "⚠" }, + c["name"].as_str().unwrap_or_default(), + c["detail"].as_str().unwrap_or_default() + )); + } + ui.separator(); + let list = |ui: &mut egui::Ui, k: &str, v: &Value| { + let items: Vec = v + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .map(|x| { + x.as_str().map(str::to_string).unwrap_or_else(|| { + format!("{}{}", x["format"].as_str().unwrap_or_default(), if x["available"] == false { " (unavailable)" } else { "" }) + }) + }) + .collect(); + ui.label(format!("{k}: {}", items.join(", "))); + }; + list(ui, "Decoders", &r["decoders"]); + list(ui, "Containers", &r["containers"]); + list(ui, "Export formats", &r["exportFormats"]); + } + _ => {} + } + if !d.error.is_empty() { + ui.colored_label(egui::Color32::from_rgb(0xe0, 0x60, 0x60), &d.error); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + if !info_only { + let r = ui.button(if d.command == "edit.find" { "Done" } else { "Cancel" }); + push(&mut elems, format!("{pre}.cancel"), &r, "Cancel"); + if r.clicked() { + action = Some("cancel"); + } + } + let r = ui.button(ok_text); + push(&mut elems, format!("{pre}.ok"), &r, ok_text); + if r.clicked() { + action = Some(if info_only { "cancel" } else { "ok" }); + } + }); + }); + for (id, r, l) in elems { + app.auto.add(&id, r, &l); + } + if let Some(k) = browse + && let Some(dir) = app.hooks.pick_folder.as_mut().and_then(|f| f()) + { + d.params[k.as_str()] = json!(dir); + } + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + action = Some("cancel"); + } + match action { + Some("cancel") => { + app.ui.extras.dialog = None; + return; + } + Some("ok") => match run(app, &d) { + Ok(Some(info)) => d.info["result"] = json!(info), + Ok(None) => { + app.ui.extras.dialog = None; + return; + } + Err(e) => d.error = e, + }, + _ => {} + } + app.ui.extras.dialog = Some(d); +} + +/// Run the dialog's command(s). `Ok(Some(text))` keeps the dialog open with a message (Find). +fn run(app: &mut FilmcraftApp, d: &ClipDialogDraft) -> Result, String> { + let s = &mut app.session; + let p = &d.params; + match d.command.as_str() { + "edit.find" => { + let mut q = p.clone(); + if q["scope"] == "timeline" { + q["rows"] = json!([p["rows"][0]]); + } + let r = s.execute("edit.find", q).map_err(|e| e.to_string())?; + Ok(Some(format!("Match {} of {}", r["index"].as_u64().unwrap_or(0) + 1, r["matches"].as_u64().unwrap_or(0)))) + } + "project.settings" => { + s.execute( + "file.projectSettings.general", + json!({"renderer": p["renderer"], "videoDisplay": p["videoDisplay"], "audioDisplay": if p["audioDisplay"] == "Milliseconds" { "milliseconds" } else { "samples" }, + "captureFormat": p["captureFormat"], "titleSafe": p["titleSafe"], "actionSafe": p["actionSafe"]}), + ) + .map_err(|e| e.to_string())?; + s.execute( + "file.projectSettings.scratchDisks", + json!({"captured": p["captured"], "videoPreviews": p["videoPreviews"], "audioPreviews": p["audioPreviews"], "autoSave": p["autoSave"]}), + ) + .map_err(|e| e.to_string())?; + if p["ingest"] != d.info["ingest"] { + s.execute("project.ingestSettings", p["ingest"].clone()).map_err(|e| e.to_string())?; + } + Ok(None) + } + "file.saveAsTemplate" => { + let r = s.execute("file.saveAsTemplate", p.clone()).map_err(|e| e.to_string())?; + app.ui.status = format!("Saved template {}", r["path"].as_str().unwrap_or_default()); + Ok(None) + } + "clip.sceneEditDetection" => { + s.execute(&d.command, p.clone()).map_err(|e| e.to_string())?; + app.ui.status = "Scene Edit Detection: analysing in the background (see Progress)".into(); + Ok(None) + } + cmd => { + let mut q = p.clone(); + if cmd == "markers.addFlashCue" && q["name"].as_str().is_some_and(str::is_empty) { + q.as_object_mut().map(|m| m.remove("name")); + } + if cmd == "sequence.transcribe" && q["language"] == "auto" { + q.as_object_mut().map(|m| m.remove("language")); + } + s.execute(cmd, q).map_err(|e| e.to_string())?; + Ok(None) + } + } +} + +/// Search bins in the Project panel's list view: one row per search bin (`project.searchBin.`), +/// expanded to its live matches. Right-click: Delete. +pub fn search_bin_rows( + app: &mut FilmcraftApp, + ui: &mut egui::Ui, + row: &mut usize, + actions: &mut Vec<(String, Value)>, + draw_items: &mut dyn FnMut(&mut FilmcraftApp, &mut egui::Ui, &filmcraft_project::Bin, &mut usize, &mut Vec<(String, Value)>), +) { + let t = app.tokens; + for sb in app.session.project.search_bins.clone() { + let open = app.ui.expanded_bins.contains(&sb.id.0); + let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 22.0), egui::Sense::click()); + if *row % 2 == 1 { + ui.painter().rect_filled(r, 0.0, t.row_alt); + } + *row += 1; + let x = r.min.x + 6.0; + crate::icons::paint( + ui.painter(), + egui::Rect::from_center_size(egui::pos2(x + 5.0, r.center().y), egui::vec2(10.0, 10.0)), + if open { crate::icons::Icon::ChevronDown } else { crate::icons::Icon::ChevronRight }, + t.text_dim, + ); + crate::icons::paint( + ui.painter(), + egui::Rect::from_center_size(egui::pos2(x + 20.0, r.center().y), egui::vec2(13.0, 13.0)), + crate::icons::Icon::Search, + t.icon, + ); + let matches = sb.query.find(&app.session.project); + ui.painter().text( + egui::pos2(x + 32.0, r.center().y), + Align2::LEFT_CENTER, + format!("{} ({})", sb.name, matches.len()), + crate::theme::Tokens::ui(12.0), + t.text, + ); + app.auto.add(&format!("project.searchBin.{}", sb.id.0), r, &sb.name); + if resp.clicked() { + if open { + app.ui.expanded_bins.retain(|b| *b != sb.id.0); + } else { + app.ui.expanded_bins.push(sb.id.0); + } + } + resp.context_menu(|ui| { + if ui.button("Delete Search Bin").clicked() { + actions.push(("project.deleteSearchBin".into(), json!({"bin": sb.id.0}))); + ui.close(); + } + }); + if open { + let bin = + filmcraft_project::Bin { id: sb.id, name: sb.name.clone(), children: matches.into_iter().map(filmcraft_project::BinEntry::Item).collect() }; + draw_items(app, ui, &bin, row, actions); + } + } +} diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index cb8055a7..2564c29f 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -215,7 +215,22 @@ pub fn media_browser(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if !ok { continue; } - let resp = ui.selectable_label(false, format!("{} {name}", if *is_dir { "📁" } else { "🎞" })); + let sel = app.ui.extras.media_browser_selection.contains(&path); + let resp = ui.selectable_label(sel, format!("{} {name}", if *is_dir { "📁" } else { "🎞" })); + app.auto.add(&format!("mediaBrowser.entry.{name}"), resp.rect, name); + if resp.clicked() && !*is_dir { + let multi = ui.input(|i| i.modifiers.command); + let s = &mut app.ui.extras.media_browser_selection; + if multi { + if sel { + s.retain(|p| *p != path); + } else { + s.push(path.clone()); + } + } else { + *s = vec![path.clone()]; + } + } if resp.double_clicked() { if *is_dir { dir = path.clone(); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index f959783c..b3a92d7f 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -14,6 +14,7 @@ pub mod import_mode; pub mod lumetri; pub mod masks; pub mod media_dialogs; +pub mod menu_dialogs; pub mod meters; pub mod misc; pub mod mixer; diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index 2937992c..12e013e0 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -148,7 +148,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) }; let size_key = (scale * 1000.0) as u32; let channel = display.is_channel(); - let use_gpu = which == Which::Program && app.gpu.is_some() && !channel; + let use_gpu = which == Which::Program && app.gpu.is_some() && !channel && !crate::panels::menu_dialogs::software_renderer(app); let cpu_target = target; let target = match (use_gpu, target) { (true, Target::Sequence(s)) => Target::SequencePlan(s), diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 2fb5458f..c5899ccb 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -60,6 +60,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().line_segment([hr.left_bottom(), hr.right_bottom()], Stroke::new(1.0, t.separator)); let mut row = 0usize; list_bin(app, ui, &root, 0, &filter, &mut row, &mut actions, true); + // search bins: saved queries listing their live matches + let mut draw = |app: &mut FilmcraftApp, ui: &mut egui::Ui, bin: &Bin, row: &mut usize, actions: &mut Vec<(String, serde_json::Value)>| { + list_bin(app, ui, bin, 1, "", row, actions, false); + }; + crate::panels::menu_dialogs::search_bin_rows(app, ui, &mut row, &mut actions, &mut draw); } _ => icon_view(app, ui, &root, &filter, &mut actions), } @@ -75,6 +80,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { resp.context_menu(|ui| { for (label, cmd) in [ ("New Bin", "file.newBin"), + ("New Search Bin", "file.newSearchBin"), + ("Find…", "edit.find"), + ("Automate to Sequence…", "clip.automateToSequence"), ("New Sequence…", "file.newSequence"), ("Import…", "file.import"), ("Bars and Tone", "file.newBarsAndTone"), @@ -146,8 +154,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } }); - } else if resp.clicked() && cmd != "find" { - actions.push((cmd.into(), json!({}))); + } else if resp.clicked() { + actions.push((if cmd == "find" { "edit.find" } else { cmd }.into(), json!({}))); } rx -= 26.0; } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 317c79c7..9a03e1c0 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -553,6 +553,7 @@ fn draw_waveform(app: &mut FilmcraftApp, p: &egui::Painter, body: Rect, it: &Tra } let peak = peaks.iter().fold(0f32, |m, (a, b)| m.max(a.abs()).max(b.abs())); let gain = waveform_display_gain(peak, it.gain_db); + let dynamic = app.ui.extras.dynamic_waveforms; let mut mesh = egui::Mesh::default(); let dur_px = body.width().max(1.0); for x in (clip.min.x.floor() as i32)..(clip.max.x.ceil() as i32) { @@ -566,9 +567,14 @@ fn draw_waveform(app: &mut FilmcraftApp, p: &egui::Painter, body: Rect, it: &Tra for (a, b) in peaks.iter().skip(s0).take(s1 - s0) { m = m.max(a.abs()).max(b.abs()); } - // logarithmic display scale (Premiere's default): −48 dB → 0, 0 dB → full - let db = 20.0 * (m * gain).max(1e-5).log10(); - let h = ((db + 48.0) / 48.0).clamp(0.0, 1.0) * area.height(); + // View ▸ Dynamic Audio Waveforms (default): logarithmic scale, −48 dB → 0, 0 dB → full; + // off: linear amplitude + let h = if dynamic { + let db = 20.0 * (m * gain).max(1e-5).log10(); + ((db + 48.0) / 48.0).clamp(0.0, 1.0) * area.height() + } else { + (m * gain).clamp(0.0, 1.0) * area.height() + }; if h > 0.3 { mesh.add_colored_rect(Rect::from_min_max(pos2(x as f32, area.max.y - h), pos2(x as f32 + 1.0, area.max.y)), col); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ba71b96e..8251df5d 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -436,6 +436,10 @@ pub struct UiState { /// open when Some). See `panels::clip_dialogs`. #[serde(default)] pub clip_dialog: Option, + /// M3.11 menu items: their dialog, Dynamic Audio Waveforms, the Media Browser selection. See + /// `panels::menu_dialogs`. + #[serde(default)] + pub extras: crate::panels::menu_dialogs::Extras, } /// An open Edit / Clip / File menu dialog: the engine command it runs on OK and the parameters @@ -713,6 +717,7 @@ impl Default for UiState { multicam_record: true, guide_dialog: None, clip_dialog: None, + extras: Default::default(), } } } diff --git a/crates/ui-egui/tests/menus_ui.rs b/crates/ui-egui/tests/menus_ui.rs new file mode 100644 index 00000000..7a70d3f0 --- /dev/null +++ b/crates/ui-egui/tests/menus_ui.rs @@ -0,0 +1,242 @@ +//! Headless UI tests of the M3.11 menu long tail: Find, Create Search Bin (and its Project panel +//! rows), Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence, +//! Normalize Mix Track, Dynamic Audio Waveforms, Reveal Log Files and the System Compatibility +//! Report, all driven by automation id through the control channel. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_engine::project::ItemId; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + fn new() -> Self { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let (tx, rx) = channel(); + let mut app = FilmcraftApp::new(s).with_control(rx); + // never hand paths to the real OS opener from a test + app.hooks.open_path = Some(Box::new(|_, _| Ok(()))); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + fn call(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let v = self.call(method, params.clone()); + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + v["result"].clone() + } + + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + + fn menu(&mut self, id: &str) -> Value { + let r = self.ok("ui.menu.invoke", json!({"id": id})); + self.frames(3); + r + } + + fn click(&mut self, id: &str) { + self.ok("ui.click", json!({"id": id})); + self.frames(3); + } + + fn has(&mut self, id: &str) -> bool { + let v = self.ok("ui.elements", json!({"prefix": id})); + v.as_array().unwrap().iter().any(|e| e["id"] == json!(id)) + } + + fn app(&mut self) -> &mut FilmcraftApp { + self.harness.state_mut() + } + + fn item_named(&mut self, name: &str) -> u64 { + self.app().session.project.items.values().find(|i| i.name == name).unwrap().id.0 + } + + fn dialog_open(&mut self) -> bool { + self.app().ui.extras.dialog.is_some() + } +} + +#[test] +fn menu_items_toggles_and_help() { + let mut d = Driver::new(); + let menus = d.ok("ui.menu.list", json!({})); + let find = |id: &str| menus.as_array().unwrap().iter().find(|m| m["id"] == id).cloned().unwrap_or_else(|| panic!("{id} not in menus")); + assert_eq!(find("view.dynamicAudioWaveforms")["checked"], true); + assert_eq!(find("help.systemCompatibilityReport")["path"], json!(["Help"])); + assert_eq!(find("file.mediaProperties")["path"], json!(["File", "Get Media File Properties for"])); + assert_eq!(find("clip.sceneEditDetection")["label"], "Scene Edit Detection…"); + // Dynamic Audio Waveforms toggles + d.menu("view.dynamicAudioWaveforms"); + assert!(!d.app().ui.extras.dynamic_waveforms); + let menus = d.ok("ui.menu.list", json!({})); + assert_eq!(menus.as_array().unwrap().iter().find(|m| m["id"] == "view.dynamicAudioWaveforms").unwrap()["checked"], false); + // System Compatibility Report + assert_eq!(d.menu("help.systemCompatibilityReport")["dialog"], "systemReport"); + assert!(d.has("systemReport.ok")); + d.click("systemReport.ok"); + assert!(!d.dialog_open()); + // Reveal Log Files writes this session's log and reveals it + let r = d.menu("help.revealLogFiles"); + let path = r["path"].as_str().unwrap().to_string(); + assert!(std::fs::read_to_string(&path).unwrap().contains("\"os\"")); + assert_eq!(d.app().ui.extras.opened.last(), Some(&path)); + let _ = std::fs::remove_file(&path); + // Import from Media Browser needs a Media Browser selection + assert_eq!(d.call("ui.menu.invoke", json!({"id": "file.importFromMediaBrowser"}))["ok"], false); +} + +#[test] +fn find_dialog_and_search_bins() { + let mut d = Driver::new(); + d.ok("ui.set", json!({"focused": "Project"})); + assert_eq!(d.menu("edit.find")["dialog"], "find"); + for id in + ["find.scope.project", "find.row.0.column", "find.row.0.operator", "find.row.0.text", "find.row.1.text", "find.match.any", "find.ok", "find.cancel"] + { + assert!(d.has(id), "{id}"); + } + d.ok( + "ui.set", + json!({"menuDialog": {"rows": [{"column": "Name", "operator": "beginsWith", "text": "Neon"}, {"column": "Name", "operator": "contains", "text": ""}]}}), + ); + d.click("find.ok"); + let neon = d.item_named("Neon_Loop.mov"); + assert_eq!(d.app().session.state.project_selection, vec![ItemId(neon)]); + assert!(d.dialog_open(), "Find stays open until Done"); + d.click("find.cancel"); + assert!(!d.dialog_open()); + // timeline Find from the Timeline panel + d.ok("ui.set", json!({"focused": "Timeline"})); + d.menu("edit.find"); + assert_eq!(d.app().ui.extras.dialog.as_ref().unwrap().params["scope"], "timeline"); + d.ok("ui.set", json!({"menuDialog": {"rows": [{"column": "Name", "operator": "contains", "text": "forest"}]}})); + d.click("find.ok"); + assert_eq!(d.app().session.state.selection.len(), 1); + d.click("find.cancel"); + + // Create Search Bin, shown in the Project panel's list view with its live contents + d.app().ui.project_view = filmcraft_ui_egui::state::ProjectView::List; + d.ok("ui.panel.show", json!({"panel": "Project"})); + d.menu("file.newSearchBin"); + assert!(d.has("searchBin.text") && d.has("searchBin.column")); + d.ok("ui.set", json!({"menuDialog": {"text": "ocean", "name": "Ocean shots"}})); + d.click("searchBin.ok"); + let bin = d.app().session.project.search_bins[0].id.0; + d.frames(3); + assert!(d.has(&format!("project.searchBin.{bin}"))); + d.click(&format!("project.searchBin.{bin}")); + let ocean = d.item_named("Ocean_Sunset.mp4"); + assert!(d.app().ui.expanded_bins.contains(&bin)); + let items = d.ok("ui.elements", json!({"prefix": format!("project.item.{ocean}")})); + assert!(items.as_array().unwrap().len() >= 2, "listed in its bin and in the search bin: {items}"); +} + +#[test] +fn project_settings_dialog() { + let mut d = Driver::new(); + assert_eq!(d.menu("file.projectSettings.scratchDisks")["dialog"], "projectSettings"); + assert_eq!(d.app().ui.extras.dialog.as_ref().unwrap().params["tab"], "scratchDisks"); + for id in ["projectSettings.scratch.captured", "projectSettings.scratch.videoPreviews.browse", "projectSettings.scratch.autoSave.same"] { + assert!(d.has(id), "{id}"); + } + d.click("projectSettings.tab.general"); + for id in ["projectSettings.renderer", "projectSettings.videoDisplay", "projectSettings.titleSafe.horizontal", "projectSettings.captureFormat"] { + assert!(d.has(id), "{id}"); + } + d.ok( + "ui.set", + json!({"menuDialog": {"titleSafe": [15.0, 12.0], "renderer": filmcraft_engine::project_tools::RENDERER_SOFTWARE, "captured": "/tmp/fc-captured"}}), + ); + d.click("projectSettings.tab.ingest"); + assert!(d.has("projectSettings.ingest.enabled")); + d.click("projectSettings.ok"); + assert!(!d.dialog_open()); + let st = d.app().session.project.settings.clone(); + assert_eq!(st.title_safe, (15.0, 12.0)); + assert_eq!(st.renderer, filmcraft_engine::project_tools::RENDERER_SOFTWARE); + assert_eq!(st.scratch.captured.as_deref(), Some("/tmp/fc-captured")); + assert!(filmcraft_ui_egui::panels::menu_dialogs::software_renderer(d.app())); +} + +#[test] +fn clip_and_sequence_dialogs() { + let mut d = Driver::new(); + // Scene Edit Detection needs a selected video clip + assert_eq!(d.call("ui.menu.invoke", json!({"id": "clip.sceneEditDetection"}))["ok"], false); + let c = d.app().session.active_sequence().unwrap().video_tracks[0].items[0].id.0; + d.exec("timeline.select", json!({"clips": [c]})); + d.menu("clip.sceneEditDetection"); + for id in ["sceneDetect.applyCuts", "sceneDetect.createSubclips", "sceneDetect.generateMarkers", "sceneDetect.sensitivity", "sceneDetect.ok"] { + assert!(d.has(id), "{id}"); + } + d.click("sceneDetect.cancel"); + assert!(!d.dialog_open()); + // Normalize Mix Track + d.menu("sequence.normalizeMixTrack"); + d.ok("ui.set", json!({"menuDialog": {"db": -6.0}})); + d.click("normalizeMix.ok"); + assert!(!d.dialog_open()); + assert!(d.app().session.active_sequence().unwrap().master_volume_db != 0.0); + // Simplify Sequence + let n = d.app().session.project.items.len(); + d.menu("sequence.simplify"); + assert!(d.has("simplify.keep.audio") && d.has("simplify.closeGaps")); + d.click("simplify.closeGaps"); + d.click("simplify.ok"); + assert_eq!(d.app().session.project.items.len(), n + 1); + let active = d.app().session.state.active_sequence.unwrap(); + assert_eq!(d.app().session.project.item(active).unwrap().name, "Main Edit (Simplified)"); + // Automate to Sequence into a new sequence + d.exec("file.newSequence", json!({"name": "Auto"})); + let ids: Vec = ["Ocean_Sunset.mp4", "Misty_Forest.mp4"].iter().map(|n| d.item_named(n)).collect(); + d.exec("project.select", json!({"items": ids})); + assert_eq!(d.menu("clip.automateToSequence")["dialog"], "automate"); + d.click("automate.method.overwrite"); + d.click("automate.ordering.selection"); + d.click("automate.ok"); + assert!(!d.dialog_open()); + let q = d.app().session.active_sequence().unwrap().clone(); + assert_eq!(q.video_tracks[0].items.iter().map(|i| i.item.0).collect::>(), ids); + // Get Media File Properties for ▸ Selection shows the probe info + d.exec("project.select", json!({"items": [ids[0]]})); + assert_eq!(d.menu("file.mediaProperties")["dialog"], "properties"); + assert_eq!(d.app().ui.extras.dialog.as_ref().unwrap().info[0]["video"]["width"], 1920); + d.click("properties.ok"); + assert!(!d.dialog_open()); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b4ad1381..caec924a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -19,7 +19,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `engine.commands` / `ui.menu.list` | – | command registry / menu tree | | `ui.inspect` | – | UI state (tool, workspace, dock, timeline view, playback, fps…) | | `ui.elements` | `{prefix?}` | every on-screen interactive element: id, label, rect | -| `ui.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit}, program:{…}, source:{…}, clipDialog:{param: value}}` | `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …) | +| `ui.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit}, program:{…}, source:{…}, clipDialog:{param: value}, menuDialog:{param: value}}` | `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`) | | `ui.panel.show` / `ui.panel.close` | `{panel}` | | | `ui.click` / `ui.move` | `{id}` or `{x,y}`, `button`, `count`, `modifiers` | synthetic pointer input | | `ui.drag` | `{from, to, steps, modifiers}` | press–move–release | diff --git a/docs/project-files.md b/docs/project-files.md index 4d1424d4..a9a48c0f 100644 --- a/docs/project-files.md +++ b/docs/project-files.md @@ -34,6 +34,7 @@ writing them is `crates/format` (`filmcraft-format`); the engine's `file.*` comm | 8 | M12 | multi-camera source sequences and clips, merged clips; no-op step | | 9 | M10.5 | transcripts of media items (`project.transcripts`); no-op step | | 10 | M3.10 | clip time interpolation, Hold Filters, Field Options, audio source channels, Modify ▸ Audio Channels map, subclip Restrict Trims; no-op step | +| 11 | M3.11 | search bins (`project.search_bins`), Flash Cue markers, Project Settings safe areas, capture format and scratch disks; no-op step | ### Migrations @@ -61,8 +62,8 @@ on disk is unchanged until you save. The **first save over it** keeps the origin A file with a `schema_version` newer than the build supports is **refused**, not half-read: -> this project was saved by a newer version of FilmCraft (project schema v11); this build reads up to -> v10. Update FilmCraft to open it. +> this project was saved by a newer version of FilmCraft (project schema v12); this build reads up to +> v11. Update FilmCraft to open it. Reading it partially and saving it back would silently drop whatever the newer version added. diff --git a/docs/testing.md b/docs/testing.md index 3a6c49bf..fab0a366 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -22,6 +22,7 @@ FILMCRAFT_REQUIRE_ORACLES=1 cargo test --workspace # CI: missing ffmpeg fails | Engine / command | `crates/engine/src/tests.rs` | run commands on the demo project, assert the sequence, undo/redo, disabled cases | | Render | `crates/render/src/tests.rs` | compositing, opacity, Motion, cross dissolve midpoint, ½-res vs full, GPU plan vs reference, audio mix, audio-effect continuity | | Multi-camera / sync | `crates/audio-dsp/src/sync.rs`, `crates/engine/src/multicam_tests.rs`, `crates/ui-egui/tests/multicam_ui.rs` | audio offsets within one sample: Rust-generated multi-mic speech (gains −20…+10 dB, SNR 40/25/10/3 dB, zero-phase colouring, fractional delays, a 0 dB-SNR room with a −4 dB echo) and ffmpeg-made camera/recorder files (pink-noise bursts, independent noise; exact lags recovered); timecode (± hours) and marker sync within a frame; Merge Clips; timeline Synchronize keeps frame-aligned starts; multicam structure, angle render (PSNR vs the camera), Switch Audio mix = the angle's track, live switching from a simulated playback clock (cuts, healing, one undo step), Ctrl+N cuts, flatten (frames identical), save/load; `perf_grid_of_four_1080p_angles` (ignored) prints the grid cost | +| Menu long tail (M3.11) | `crates/render/src/scene.rs`, `crates/engine/src/{scene_detect,sequence_extras,project_tools}_tests.rs`, `crates/interchange/src/ale.rs`, `crates/project/src/find.rs`, `crates/ui-egui/tests/menus_ui.rs` | Scene Edit Detection on synthetic frames (hard cuts vs pans, flashes, sensitivity) and on generated movies with known cut points (our own ProRes render of demo scenes; an ffmpeg-made H.264 concat of testsrc2 / SMPTE bars / Mandelbrot): cuts with linked audio, clip markers, subclips, one undo step, background job and cancel; Normalize Mix Track to a target peak; Simplify Sequence; Find / Find Next and search bins; Automate to Sequence (overlap, transitions, insert, unnumbered markers); ALE round trip; Project Settings and scratch disks; menu order and shortcuts against Premiere's; the dialogs by automation id | | Mixer | `crates/render/src/mixer_tests.rs`, `crates/engine/src/mixer_tests.rs` | sample-exact fader gain and pan laws, automation at block boundaries (bit-identical however requests are cut), solo/mute/solo-safe, sends and submix routing, latency-compensation alignment, render-vs-playback identity, Touch ramp-back, recorder modes (Latch/Touch/Write), thinning, Audio Gain, transition curves; `perf_24_tracks_3_effects_realtime_factor` prints the realtime factor | | Oracle | `crates/*/tests/*oracle*.rs`, `conformance.rs` | compare with ffmpeg/ffprobe (§2) | | Robustness / fuzz | `*/tests/robustness.rs`, `*/tests/fuzz.rs` | seeded mutation and truncation of real and synthetic files; nothing may panic |