Merge remaining Premiere menu items (M3.11, schema v11) into integration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 11:47:51 -04:00
commit feae349814
38 changed files with 5035 additions and 21 deletions

View File

@ -56,7 +56,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) | — | — | | 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 | | 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 | — | — | | 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 | | 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 | | 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 | | 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 |

View File

@ -153,6 +153,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()) 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.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(|| { app.hooks.raise_without_focus = Some(Box::new(|| {
window_raise::raise_without_focus(); window_raise::raise_without_focus();
})); }));
@ -177,6 +178,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). /// Local UTC offset at a unix time (auto-save file names and recovery times use local time).
fn local_offset(unix: i64) -> i32 { fn local_offset(unix: i64) -> i32 {
chrono::Local.timestamp_opt(unix, 0).single().map(|d| d.offset().local_minus_utc()).unwrap_or(0) chrono::Local.timestamp_opt(unix, 0).single().map(|d| d.offset().local_minus_utc()).unwrap_or(0)

View File

@ -958,7 +958,16 @@ impl Worker {
fn auto_save(&mut self) -> Result<PathBuf, String> { fn auto_save(&mut self) -> Result<PathBuf, String> {
let Some(s) = &self.latest else { return Err("nothing to save".into()) }; 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 { 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) => { Some(p) => {
let p = Path::new(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())) (names::auto_save_dir(p), p.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| s.project.name.clone()))

View File

@ -440,7 +440,7 @@ fn items_p(s: &Session, p: &Value) -> Vec<ItemId> {
} }
/// Fold the undo steps pushed since the history had `n0` entries into one step named `label`. /// 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 { if s.history.undo.len() > n0 {
let first = s.history.undo[n0].1.clone(); let first = s.history.undo[n0].1.clone();
s.history.undo.truncate(n0); s.history.undo.truncate(n0);
@ -482,7 +482,7 @@ fn used_items(p: &Project) -> BTreeSet<ItemId> {
} }
/// Item ids mentioned in a JSON value (keys named `item`/`items`/`video`/`audio` holding ids). /// Item ids mentioned in a JSON value (keys named `item`/`items`/`video`/`audio` holding ids).
fn collect_ids(v: &Value, out: &mut BTreeSet<ItemId>, p: &Project) { pub(crate) fn collect_ids(v: &Value, out: &mut BTreeSet<ItemId>, p: &Project) {
match v { match v {
Value::Object(m) => { Value::Object(m) => {
for (k, x) in m { for (k, x) in m {
@ -591,7 +591,7 @@ fn sequence_from_clip(s: &mut Session, p: &Value) -> Result<Value> {
} }
/// The media range an item edits in with: its In/Out marks, a subclip's range, or the whole item. /// 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, still: Tick) -> Option<TimeRange> { pub(crate) fn item_range(p: &Project, id: ItemId, still: Tick) -> Option<TimeRange> {
let it = p.item(id)?; let it = p.item(id)?;
let rate = item_rate(p, id); let rate = item_rate(p, id);
Some(match &it.kind { Some(match &it.kind {

View File

@ -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!(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.newSequenceFromClip"), pos("file.newSequence") + 1);
assert_eq!(pos("file.newBinFromSelection"), pos("file.newBin") + 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!(pos("file.saveAll"), pos("file.saveCopy") + 1);
assert_eq!(menu_of("clip.timeInterpolation.opticalFlow"), ["Clip", "Video Options", "Time Interpolation"]); assert_eq!(menu_of("clip.timeInterpolation.opticalFlow"), ["Clip", "Video Options", "Time Interpolation"]);
assert_eq!(menu_of("clip.audioGain"), ["Clip", "Audio Options"]); assert_eq!(menu_of("clip.audioGain"), ["Clip", "Audio Options"]);

View File

@ -2094,6 +2094,8 @@ fn build() -> Vec<CommandSpec> {
// Edit ▸ Label ▸ <colour>, Paste Attributes, subclips, Video / Audio Options, Replace With Clip… // Edit ▸ Label ▸ <colour>, Paste Attributes, subclips, Video / Audio Options, Replace With Clip…
// and their menu order // and their menu order
crate::clip_ops::apply_layout(&mut v); 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.shrink_to_fit();
v v
} }
@ -2744,7 +2746,7 @@ fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Opti
if let Some(p) = &path if let Some(p) = &path
&& !cfg!(target_arch = "wasm32") && !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.path = path;
s.revision += 1; s.revision += 1;
@ -2793,6 +2795,9 @@ fn recover(s: &mut Session, id: Option<&str>) -> Result<Value> {
/// Where auto-saves of the current project go. /// Where auto-saves of the current project go.
fn auto_save_dir(s: &Session) -> std::path::PathBuf { 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) { match (&s.path, &s.persistence) {
(Some(p), _) => filmcraft_format::autosave::auto_save_dir(std::path::Path::new(p)), (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), (None, Some(per)) => per.data_dir.join(filmcraft_format::autosave::AUTO_SAVE_DIR),

View File

@ -24,8 +24,11 @@ pub mod multicam;
pub mod presets; pub mod presets;
pub mod previews; pub mod previews;
pub mod project_manager; pub mod project_manager;
pub mod project_tools;
pub mod proxies; pub mod proxies;
pub mod relink; pub mod relink;
pub mod scene_detect;
pub mod sequence_extras;
pub mod sequence_tools; pub mod sequence_tools;
pub mod settings; pub mod settings;
pub mod shortcut_presets; pub mod shortcut_presets;
@ -213,6 +216,9 @@ pub struct EditorState {
/// Sequence markers copied with the clipboard clips (start relative to the copied range). /// Sequence markers copied with the clipboard clips (start relative to the copied range).
#[serde(skip)] #[serde(skip)]
pub clipboard_markers: Vec<filmcraft_project::Marker>, pub clipboard_markers: Vec<filmcraft_project::Marker>,
/// The last Edit ▸ Find… (Find Next continues it).
#[serde(default)]
pub find: Option<project_tools::FindState>,
} }
/// Events for frontends (drained each frame). /// Events for frontends (drained each frame).
@ -262,6 +268,8 @@ pub struct Session {
pub media_jobs: Vec<proxies::PendingJob>, pub media_jobs: Vec<proxies::PendingJob>,
/// Mask tracking jobs whose keyframes are still being written. /// Mask tracking jobs whose keyframes are still being written.
pub mask_jobs: Vec<masks::PendingTrack>, pub mask_jobs: Vec<masks::PendingTrack>,
/// Scene Edit Detection jobs whose results are applied when they finish.
pub scene_jobs: Vec<scene_detect::PendingScene>,
/// Effect presets (built-in + the user's, persisted in the data directory). /// Effect presets (built-in + the user's, persisted in the data directory).
pub presets: presets::PresetLibrary, pub presets: presets::PresetLibrary,
/// Exports run a batch at a time by [`Session::pump_jobs`] (hosts without threads: web). /// Exports run a batch at a time by [`Session::pump_jobs`] (hosts without threads: web).
@ -346,6 +354,7 @@ impl Session {
offline: Default::default(), offline: Default::default(),
media_jobs: Vec::new(), media_jobs: Vec::new(),
mask_jobs: Vec::new(), mask_jobs: Vec::new(),
scene_jobs: Vec::new(),
presets: Default::default(), presets: Default::default(),
stepped: Vec::new(), stepped: Vec::new(),
transcriber: None, transcriber: None,
@ -431,6 +440,7 @@ impl Session {
pub fn poll_persistence(&mut self) { pub fn poll_persistence(&mut self) {
proxies::poll(self); proxies::poll(self);
masks::poll(self); masks::poll(self);
scene_detect::poll(self);
let Some(p) = self.persistence.as_mut() else { return }; let Some(p) = self.persistence.as_mut() else { return };
for ev in p.drain_events() { for ev in p.drain_events() {
match ev { match ev {
@ -471,7 +481,7 @@ impl Session {
if let Some(p) = &self.path if let Some(p) = &self.path
&& !cfg!(target_arch = "wasm32") && !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)));
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -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 <take>".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("<xmpDM:scene>12A</xmpDM:scene>") && x.contains("<xmpDM:logComment>best &lt;take&gt;</xmpDM:logComment>"), "{x}");
assert!(x.contains("<rdf:li xml:lang=\"x-default\">take.mp4</rdf:li>"));
// a sidecar from another application is left alone
std::fs::write(&side, "<x:xmpmeta>someone else's</x:xmpmeta>").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<ItemId> = 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::<Vec<_>>()})).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::<Vec<_>>(), 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<_>>(), 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::<Vec<_>>()})).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<Tick> = 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());
}

View File

@ -343,7 +343,10 @@ pub fn proxy_dir(media: &str, dest: Option<&str>) -> PathBuf {
pub fn create(s: &mut Session, p: &Value) -> Result<Value> { pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
let pr = preset(str_p(p, "preset").unwrap_or(DEFAULT_PROXY_PRESET)).ok_or_else(|| bad("media.createProxies", "unknown preset (see media.proxyPresets)"))?; 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 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 work = Vec::new();
let mut taken = Vec::new(); let mut taken = Vec::new();
let mut skipped = Vec::new(); let mut skipped = Vec::new();

View File

@ -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 "<clip> 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<CommandSpec> {
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<Mutex<Option<Vec<ClipScenes>>>>,
}
fn lock<T>(m: &Mutex<T>) -> 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<ItemId> {
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<Value> {
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<Mutex<Option<Vec<ClipScenes>>>> = 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::<Vec<_>>(), "mediaCuts": c.cuts.iter().map(|x| x.1.0).collect::<Vec<_>>()}))
.collect::<Vec<_>>()
);
}
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<ClipScenes>) -> Result<Value> {
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<u64> = 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<Tick> = 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;

View File

@ -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<Tick> = 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<_>>(), vec![rate.tick_of(20), rate.tick_of(36)]);
// one subclip per shot in a "<clip> Scenes" bin
let subs: Vec<TimeRange> = 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<PathBuf> {
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<Tick> = q.video_tracks[0].items.iter().map(|i| i.start).collect();
let a: Vec<Tick> = 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);
}
}

View File

@ -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 "<name> (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<Value>;
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<CommandSpec> {
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<Value> {
let clips: Vec<ClipId> = 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<f64> {
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<Value> {
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<Value> {
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<Self> {
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<ClipId> = 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<u64, usize> = 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<ClipId> = 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<TimeRange> = 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<TimeRange> = 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<Value> {
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<TrackId> {
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<Value> {
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<Value> {
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;

View File

@ -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));
}

View File

@ -48,7 +48,7 @@ pub type Migration = fn(Value) -> Result<Value, String>;
/// `MIGRATIONS[i]` upgrades schema `i + 1` to `i + 2`. Append one function per schema bump; never /// `MIGRATIONS[i]` upgrades schema `i + 1` to `i + 2`. Append one function per schema bump; never
/// edit a shipped one. /// 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). /// The schema version this build writes (and the newest it reads).
pub const SCHEMA_VERSION: u32 = MIGRATIONS.len() as u32 + 1; pub const SCHEMA_VERSION: u32 = MIGRATIONS.len() as u32 + 1;
@ -264,13 +264,19 @@ fn v9_to_v10(doc: Value) -> Result<Value, String> {
Ok(doc) 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<Value, String> {
Ok(doc)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn schema_version_matches_table() { fn schema_version_matches_table() {
assert_eq!(SCHEMA_VERSION, 10); assert_eq!(SCHEMA_VERSION, 11);
} }
#[test] #[test]

View File

@ -191,3 +191,22 @@ fn v8_loads_without_transcripts_and_v9_roundtrips_them() {
assert_eq!(again.project, p); assert_eq!(again.project, p);
assert_eq!(again.project.transcripts[&ItemId(7)].speakers[0].name, "Speaker 1"); 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);
}

View File

@ -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}}

View File

@ -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<String>,
pub rows: Vec<Vec<String>>,
}
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<FrameRate> {
self.heading("FPS").and_then(|f| f.trim().parse::<f64>().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<ItemId> = 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<String> = ["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::<Vec<_>>().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::<Vec<_>>().join("\t"));
out.push_str("\r\n");
}
out
}
/// Read an ALE file.
pub fn parse(text: &str) -> Result<AleDoc, String> {
#[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<i64>, 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");
}
}

View File

@ -18,6 +18,7 @@
//! This crate is L2: it does no file I/O. Media references are strings (absolute paths, or paths //! 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. //! resolved against the `base_dir` passed to the importer); the engine probes/relinks them.
pub mod ale;
pub mod edl; pub mod edl;
pub mod fcp7; pub mod fcp7;
pub mod fcpxml; pub mod fcpxml;

252
crates/project/src/find.rs Normal file
View File

@ -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<FindOp> {
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: `<column> <operator> <text>`.
#[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<FindRow>,
/// 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<Item = &FindRow> {
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<String> = 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<ItemId> {
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));
}
}

View File

@ -10,6 +10,7 @@
pub mod caption; pub mod caption;
pub mod effect; pub mod effect;
pub mod essential; pub mod essential;
pub mod find;
pub mod graphic; pub mod graphic;
pub mod keyframe; pub mod keyframe;
pub mod mask; pub mod mask;
@ -28,6 +29,7 @@ use serde::{Deserialize, Serialize};
pub use caption::{Caption, CaptionAlign, CaptionAnchor, CaptionFormat, CaptionStyle, CaptionTrack, plain_text}; 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 effect::{EffectDef, EffectInstance, EffectKind, ParamDef, ParamKind, effect_defs, find_effect};
pub use essential::{AudioType, EssentialSound}; pub use essential::{AudioType, EssentialSound};
pub use find::{FindOp, FindQuery, FindRow, SearchBin};
pub use keyframe::{Interpolation, Keyframe, Param, ParamValue}; pub use keyframe::{Interpolation, Keyframe, Param, ParamValue};
pub use mask::{Mask, MaskMode, MaskPath, MaskVertex, TrackMethod}; pub use mask::{Mask, MaskMode, MaskPath, MaskVertex, TrackMethod};
pub use mixer::{AutomationMode, InputMap, MixerStrip, TrackSend}; pub use mixer::{AutomationMode, InputMap, MixerStrip, TrackSend};
@ -444,6 +446,9 @@ pub enum MarkerKind {
Chapter, Chapter,
Segmentation, Segmentation,
WebLink, 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)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -977,6 +982,42 @@ pub struct ProjectSettings {
/// Project Settings ▸ Ingest Settings: what happens to media on import. /// Project Settings ▸ Ingest Settings: what happens to media on import.
#[serde(default)] #[serde(default)]
pub ingest: IngestSettings, 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<String>,
/// Video and audio render previews (`<dir>/<project name>`).
pub video_previews: Option<String>,
pub audio_previews: Option<String>,
/// Project Auto Save versions.
pub auto_save: Option<String>,
} }
/// Ingest on import (Project Settings ▸ Ingest Settings). /// Ingest on import (Project Settings ▸ Ingest Settings).
@ -1016,6 +1057,10 @@ impl Default for ProjectSettings {
default_transition_duration_frames: 24, default_transition_duration_frames: 24,
default_audio_transition_duration: Tick(TICKS_PER_SECOND), default_audio_transition_duration: Tick(TICKS_PER_SECOND),
ingest: IngestSettings::default(), ingest: IngestSettings::default(),
title_safe: title_safe_default(),
action_safe: action_safe_default(),
capture_format: capture_format_default(),
scratch: ScratchDisks::default(),
} }
} }
} }
@ -1036,6 +1081,10 @@ pub struct Project {
/// are media time. Shared (`Arc`) so undo snapshots don't copy them. /// are media time. Shared (`Arc`) so undo snapshots don't copy them.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")] #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub transcripts: BTreeMap<ItemId, std::sync::Arc<Transcript>>, pub transcripts: BTreeMap<ItemId, std::sync::Arc<Transcript>>,
/// 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<SearchBin>,
} }
/// A LUT imported into the project (`lut.import`). Lumetri refers to it as `lib:<id>`. /// A LUT imported into the project (`lut.import`). Lumetri refers to it as `lib:<id>`.
@ -1068,6 +1117,7 @@ impl Project {
next_id: 1, next_id: 1,
luts: Vec::new(), luts: Vec::new(),
transcripts: BTreeMap::new(), transcripts: BTreeMap::new(),
search_bins: Vec::new(),
} }
} }

View File

@ -23,6 +23,7 @@ pub mod multicam;
pub mod offline; pub mod offline;
pub mod plan; pub mod plan;
pub mod preview; pub mod preview;
pub mod scene;
pub mod track; pub mod track;
pub mod transitions; pub mod transitions;
pub mod vfx; pub mod vfx;

246
crates/render/src/scene.rs Normal file
View File

@ -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<f32>,
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::<f32>() / 6.0;
let g: f32 = a.grid.iter().zip(&b.grid).map(|(x, y)| (x - y).abs()).sum::<f32>() / (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<usize> {
const WINDOW: usize = 5;
let thr = opt.threshold();
let mut cuts: Vec<usize> = 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<f32> = (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<Signature>,
diffs: Vec<f32>,
}
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<usize> {
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<u8> {
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<usize> {
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);
}
}

View File

@ -189,6 +189,13 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
d.params[k.as_str()] = v.clone(); 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) ok(Value::Null)
} }
"ui.panel.show" | "ui.panel.close" => { "ui.panel.show" | "ui.panel.close" => {

View File

@ -81,6 +81,9 @@ pub struct HostHooks {
/// (macOS: `orderFrontRegardless`). Without it the app only requests a repaint: it never /// (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. /// activates itself for an agent, because the user's keystrokes would land here.
pub raise_without_focus: Option<Box<dyn FnMut()>>, pub raise_without_focus: Option<Box<dyn FnMut()>>,
/// 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<Box<dyn FnMut(&str, bool) -> Result<(), String>>>,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]

View File

@ -77,6 +77,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("view.guideTemplates.apply", "Apply Guide Template", [], None), uic!("view.guideTemplates.apply", "Apply Guide Template", [], None),
uic!("view.guideTemplates.delete", "Delete Guide Template", [], None), uic!("view.guideTemplates.delete", "Delete Guide Template", [], None),
uic!("view.compare.setReference", "Set Comparison Reference", [], 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.toggleView", "Multi-Camera View", ["View"], Some("Shift+0")),
uic!("multicam.recordToggle", "Multi-Camera Record On/Off Toggle", [], Some("0")), uic!("multicam.recordToggle", "Multi-Camera Record On/Off Toggle", [], Some("0")),
uic!("view.theme.dark", "Darkest", ["View", "Appearance"], None), 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.appPage", "FilmCraft on getartcraft.com", ["Help"], None),
uic!("help.github", "FilmCraft on GitHub", ["Help"], None), uic!("help.github", "FilmCraft on GitHub", ["Help"], None),
uic!("help.reportIssue", "Report an Issue…", ["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.about", "About FilmCraft", ["Help"], None),
uic!("app.keyboardShortcuts", "Keyboard Shortcuts…", ["Edit"], Some("Cmd+Alt+K")), uic!("app.keyboardShortcuts", "Keyboard Shortcuts…", ["Edit"], Some("Cmd+Alt+K")),
uic!("app.settings.general", "General…", ["Edit", "Preferences"], Some("Cmd+,")), uic!("app.settings.general", "General…", ["Edit", "Preferences"], Some("Cmd+,")),
@ -172,6 +175,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val
if let Some(r) = crate::panels::settings::route(app, id) { if let Some(r) = crate::panels::settings::route(app, id) {
return r; return r;
} }
if let Some(r) = crate::panels::menu_dialogs::route(app, ctx, id, &params) {
if let Err(e) = &r {
app.ui.status = e.clone();
}
return r;
}
if let Some(r) = crate::panels::media_dialogs::route(app, id, &params) { if let Some(r) = crate::panels::media_dialogs::route(app, id, &params) {
if let Err(e) = &r { if let Err(e) = &r {
app.ui.status = e.clone(); app.ui.status = e.clone();
@ -340,7 +349,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec<MenuItem> {
let mut v = menu_items_for(&app.session); let mut v = menu_items_for(&app.session);
for it in &mut v { for it in &mut v {
if it.id.starts_with("view.") { 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); it.enabled &= crate::panels::monitor_view::enabled(app, &it.id);
} }
} }

View File

@ -7,6 +7,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
crate::panels::media_dialogs::show(app, ctx); crate::panels::media_dialogs::show(app, ctx);
crate::panels::color_dialogs::show(app, ctx); crate::panels::color_dialogs::show(app, ctx);
crate::panels::clip_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::presets::save_dialog(app, ctx);
crate::panels::audio_fx_editor::show(app, ctx); crate::panels::audio_fx_editor::show(app, ctx);
crate::panels::multicam::show_dialog(app, ctx); crate::panels::multicam::show_dialog(app, ctx);

View File

@ -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 (`<p>` = the dialog prefix, `<key>` = a parameter): `<p>.ok`, `<p>.cancel` and
//! `<p>.<key>` for each control; radio buttons `<p>.<key>.<value>`; Find rows
//! `find.row.<n>.column|operator|text`; Project Settings tabs `projectSettings.tab.<general|scratchDisks|ingest>`,
//! scratch rows `projectSettings.scratch.<key>` / `.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<ClipDialogDraft>,
/// 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<String>,
/// Paths handed to the operating system's opener (Edit Original, Reveal Log Files), newest last.
pub opened: Vec<String>,
}
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<String>, r: &egui::Response, label: impl Into<String>) {
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<bool> {
(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: `<data dir>/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<std::path::PathBuf, String> {
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::<Vec<_>>().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<Result<Value, String>> {
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<_>>()
}
_ => 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<String> = 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<f64>, 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<String> = 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<String> = 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<Option<String>, 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.<id>`),
/// 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);
}
}
}

View File

@ -215,7 +215,22 @@ pub fn media_browser(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
if !ok { if !ok {
continue; 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 resp.double_clicked() {
if *is_dir { if *is_dir {
dir = path.clone(); dir = path.clone();

View File

@ -15,6 +15,7 @@ pub mod import_mode;
pub mod lumetri; pub mod lumetri;
pub mod masks; pub mod masks;
pub mod media_dialogs; pub mod media_dialogs;
pub mod menu_dialogs;
pub mod meters; pub mod meters;
pub mod misc; pub mod misc;
pub mod mixer; pub mod mixer;

View File

@ -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 size_key = (scale * 1000.0) as u32;
let channel = display.is_channel(); 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 cpu_target = target;
let target = match (use_gpu, target) { let target = match (use_gpu, target) {
(true, Target::Sequence(s)) => Target::SequencePlan(s), (true, Target::Sequence(s)) => Target::SequencePlan(s),

View File

@ -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)); ui.painter().line_segment([hr.left_bottom(), hr.right_bottom()], Stroke::new(1.0, t.separator));
let mut row = 0usize; let mut row = 0usize;
list_bin(app, ui, &root, 0, &filter, &mut row, &mut actions, true); 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), _ => 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| { resp.context_menu(|ui| {
for (label, cmd) in [ for (label, cmd) in [
("New Bin", "file.newBin"), ("New Bin", "file.newBin"),
("New Search Bin", "file.newSearchBin"),
("Find…", "edit.find"),
("Automate to Sequence…", "clip.automateToSequence"),
("New Sequence…", "file.newSequence"), ("New Sequence…", "file.newSequence"),
("Import…", "file.import"), ("Import…", "file.import"),
("Bars and Tone", "file.newBarsAndTone"), ("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" { } else if resp.clicked() {
actions.push((cmd.into(), json!({}))); actions.push((if cmd == "find" { "edit.find" } else { cmd }.into(), json!({})));
} }
rx -= 26.0; rx -= 26.0;
} }

View File

@ -561,6 +561,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 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 gain = waveform_display_gain(peak, it.gain_db);
let dynamic = app.ui.extras.dynamic_waveforms;
let mut mesh = egui::Mesh::default(); let mut mesh = egui::Mesh::default();
let dur_px = body.width().max(1.0); let dur_px = body.width().max(1.0);
for x in (clip.min.x.floor() as i32)..(clip.max.x.ceil() as i32) { for x in (clip.min.x.floor() as i32)..(clip.max.x.ceil() as i32) {
@ -574,9 +575,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) { for (a, b) in peaks.iter().skip(s0).take(s1 - s0) {
m = m.max(a.abs()).max(b.abs()); m = m.max(a.abs()).max(b.abs());
} }
// logarithmic display scale (Premiere's default): −48 dB → 0, 0 dB → full // View ▸ Dynamic Audio Waveforms (default): logarithmic scale, −48 dB → 0, 0 dB → full;
let db = 20.0 * (m * gain).max(1e-5).log10(); // off: linear amplitude
let h = ((db + 48.0) / 48.0).clamp(0.0, 1.0) * area.height(); 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 { 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); 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);
} }

View File

@ -442,6 +442,10 @@ pub struct UiState {
/// open when Some). See `panels::clip_dialogs`. /// open when Some). See `panels::clip_dialogs`.
#[serde(default)] #[serde(default)]
pub clip_dialog: Option<ClipDialogDraft>, pub clip_dialog: Option<ClipDialogDraft>,
/// 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 /// An open Edit / Clip / File menu dialog: the engine command it runs on OK and the parameters
@ -721,6 +725,7 @@ impl Default for UiState {
multicam_record: true, multicam_record: true,
guide_dialog: None, guide_dialog: None,
clip_dialog: None, clip_dialog: None,
extras: Default::default(),
} }
} }
} }

View File

@ -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<ControlRequest>,
}
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<u64> = ["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::<Vec<_>>(), 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());
}

View File

@ -19,7 +19,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`):
| `engine.commands` / `ui.menu.list` | – | command registry / menu tree | | `engine.commands` / `ui.menu.list` | – | command registry / menu tree |
| `ui.inspect` | – | UI state (tool, workspace, dock, timeline view, playback, fps…) | | `ui.inspect` | – | UI state (tool, workspace, dock, timeline view, playback, fps…) |
| `ui.elements` | `{prefix?}` | every on-screen interactive element: id, label, rect | | `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.panel.show` / `ui.panel.close` | `{panel}` | |
| `ui.click` / `ui.move` | `{id}` or `{x,y}`, `button`, `count`, `modifiers` | synthetic pointer input | | `ui.click` / `ui.move` | `{id}` or `{x,y}`, `button`, `count`, `modifiers` | synthetic pointer input |
| `ui.drag` | `{from, to, steps, modifiers}` | press–move–release | | `ui.drag` | `{from, to, steps, modifiers}` | press–move–release |

View File

@ -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 | | 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 | | 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 | | 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 ### 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: 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 > this project was saved by a newer version of FilmCraft (project schema v12); this build reads up to
> v10. Update FilmCraft to open it. > v11. Update FilmCraft to open it.
Reading it partially and saving it back would silently drop whatever the newer version added. Reading it partially and saving it back would silently drop whatever the newer version added.

View File

@ -23,6 +23,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 | | 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 | | 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 | | 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 | | 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) | | 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 | | Robustness / fuzz | `*/tests/robustness.rs`, `*/tests/fuzz.rs` | seeded mutation and truncation of real and synthetic files; nothing may panic |