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) | — | — |
| M1 | Media I/O | ✅ | MP4/MOV demux+mux, WAV, stills, MJPEG, symphonia audio (MP3/FLAC/ALAC/Vorbis), GOP seek + frame cache, import | Media Browser polish | 2 |
| M2 | H.264 decoder | ✅ | Own decoder, bit-exact on 37+ streams, 500–600 fps 1080p | — | — |
| M3 | Editing core | 🟡 | Edit algebra (insert/overwrite/razor/lift/extract/ripple/roll/slip/slide/rate-stretch/nest/paste), tools, markers, trim mode + Trim Monitor + dynamic J/K/L trimming, Keyboard Shortcuts editor with FilmCraft/Premiere/FCP/Avid presets; Edit/Clip/File menu commands (Label colours and Select Label Group, Paste/Remove Attributes, Select All Matching, Remove Unused, Consolidate Duplicates, Sequence From Clip, Bin From Selection, Offline File, Close Project, Make/Edit Subclip, Modify Audio Channels/Timecode, Frame Hold Options/Add Frame Hold/Insert Frame Hold Segment, Time Interpolation with frame blending, Fit/Fill frame, Breakout to Mono, Extract Audio, Replace With Clip); multicam (Create Multi-Camera Source Sequence, Multi-Camera view with live switching on 1–9, angle switching, Enable/Flatten, Edit Cameras) and sync (Synchronize, Merge Clips; In/Out/timecode/marker/audio — GCC-PHAT, sample-accurate) | Multicam paging >16 angles and grid thumbnails, optical flow (renders as frame blending), ~200 Premiere default shortcuts whose commands don't exist yet | 6–10 |
| M3 | Editing core | 🟡 | Edit algebra (insert/overwrite/razor/lift/extract/ripple/roll/slip/slide/rate-stretch/nest/paste), tools, markers, trim mode + Trim Monitor + dynamic J/K/L trimming, Keyboard Shortcuts editor with FilmCraft/Premiere/FCP/Avid presets; Edit/Clip/File menu commands (Label colours and Select Label Group, Paste/Remove Attributes, Select All Matching, Remove Unused, Consolidate Duplicates, Sequence From Clip, Bin From Selection, Offline File, Close Project, Make/Edit Subclip, Modify Audio Channels/Timecode, Frame Hold Options/Add Frame Hold/Insert Frame Hold Segment, Time Interpolation with frame blending, Fit/Fill frame, Breakout to Mono, Extract Audio, Replace With Clip; M3.11: Scene Edit Detection (pure-Rust cut detection, background job), Normalize Mix Track, Simplify Sequence, Transcribe Sequence, Find/Find Next and search bins, Automate to Sequence, Edit Original, Edit Offline, Source Settings, Update Metadata (XMP), Generate Audio Waveform, Project Settings General/Scratch Disks, Get Media File Properties, Save as Template, Selection as FilmCraft Project, Avid Log Exchange export, Flash Cue markers, Dynamic Audio Waveforms, Reveal Log Files, System Compatibility Report); multicam (Create Multi-Camera Source Sequence, Multi-Camera view with live switching on 1–9, angle switching, Enable/Flatten, Edit Cameras) and sync (Synchronize, Merge Clips; In/Out/timecode/marker/audio — GCC-PHAT, sample-accurate) | Multicam paging >16 angles and grid thumbnails, optical flow (renders as frame blending), ~200 Premiere default shortcuts whose commands don't exist yet | 6–10 |
| M4 | Playback | 🟡 | Audio-clock master, prefetch with cancellation, J/K/L, correct dropped-frame stats, playback resolution, render bar + content-hashed render previews, App Nap opt-out; 1080p H.264 and 3 stacked 1080p streams play with 0 dropped frames | 4K under load, 8K, frame-threaded AV1 decode, reduced-resolution decode for multicam grids | 6–10 |
| M5 | Effects, keyframes, GPU | 🟡 | ~60 CPU effects, 30 transitions, keyframes + value/velocity graphs, wgpu compositor, effect + opacity masks (ellipse/polygon/pen, feather, expansion, modes; CPU/WGSL parity), mask tracking (Lucas–Kanade + RANSAC), adjustment layers, effect presets (built-in + user, JSON import/export) | Full ~150-effect catalogue, WGSL parity for all effects and masks in the live GPU path, Warp Stabilizer, Morph Cut | 20–30 |
| M6 | Export | ✅ | Own H.264 encoder (High/Main/Baseline, B-frames, VBR/CBR/2-pass) → MP4 + own AAC; ProRes, MJPEG, PNG, GIF, WAV; background jobs | Preset library, queue UI, smart render | 6–8 |

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())
}));
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
app.hooks.open_path = Some(Box::new(open_path));
app.hooks.raise_without_focus = Some(Box::new(|| {
window_raise::raise_without_focus();
}));
@ -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).
fn local_offset(unix: i64) -> i32 {
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> {
let Some(s) = &self.latest else { return Err("nothing to save".into()) };
let scratch = s.project.settings.scratch.auto_save.clone().filter(|d| !d.is_empty());
let (dir, name) = match &s.path {
// Project Settings ▸ Scratch Disks ▸ Project Auto Save
Some(p) if scratch.is_some() => {
let p = Path::new(p);
(
PathBuf::from(scratch.clone().unwrap_or_default()),
p.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| s.project.name.clone()),
)
}
Some(p) => {
let p = Path::new(p);
(names::auto_save_dir(p), p.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| s.project.name.clone()))

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`.
fn collapse_history(s: &mut Session, n0: usize, label: &str) {
pub(crate) fn collapse_history(s: &mut Session, n0: usize, label: &str) {
if s.history.undo.len() > n0 {
let first = s.history.undo[n0].1.clone();
s.history.undo.truncate(n0);
@ -482,7 +482,7 @@ fn used_items(p: &Project) -> BTreeSet<ItemId> {
}
/// 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 {
Value::Object(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.
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 rate = item_rate(p, id);
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_eq!(pos("file.newSequenceFromClip"), pos("file.newSequence") + 1);
assert_eq!(pos("file.newBinFromSelection"), pos("file.newBin") + 1);
assert_eq!(pos("file.closeProject") + 1, pos("file.save"));
// Close Project, Close All Projects, Close All Other Projects (M3.11), Save
assert_eq!(pos("file.closeProject") + 3, pos("file.save"));
assert_eq!(pos("file.saveAll"), pos("file.saveCopy") + 1);
assert_eq!(menu_of("clip.timeInterpolation.opticalFlow"), ["Clip", "Video Options", "Time Interpolation"]);
assert_eq!(menu_of("clip.audioGain"), ["Clip", "Audio Options"]);

View File

@ -2094,6 +2094,8 @@ fn build() -> Vec<CommandSpec> {
// Edit ▸ Label ▸ <colour>, Paste Attributes, subclips, Video / Audio Options, Replace With Clip…
// and their menu order
crate::clip_ops::apply_layout(&mut v);
// Search Bin, Find, Project Settings, Scene Edit Detection, Normalize Mix Track… (M3.11)
crate::project_tools::apply_layout(&mut v);
v.shrink_to_fit();
v
}
@ -2744,7 +2746,7 @@ fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Opti
if let Some(p) = &path
&& !cfg!(target_arch = "wasm32")
{
s.previews.set_dir(Some(crate::previews::dir_for_project(p)));
s.previews.set_dir(Some(crate::project_tools::previews_dir_for(&s.project, p)));
}
s.path = path;
s.revision += 1;
@ -2793,6 +2795,9 @@ fn recover(s: &mut Session, id: Option<&str>) -> Result<Value> {
/// Where auto-saves of the current project go.
fn auto_save_dir(s: &Session) -> std::path::PathBuf {
if let Some(d) = s.project.settings.scratch.auto_save.as_deref().filter(|d| !d.is_empty() && s.path.is_some()) {
return std::path::PathBuf::from(d);
}
match (&s.path, &s.persistence) {
(Some(p), _) => filmcraft_format::autosave::auto_save_dir(std::path::Path::new(p)),
(None, Some(per)) => per.data_dir.join(filmcraft_format::autosave::AUTO_SAVE_DIR),

View File

@ -24,8 +24,11 @@ pub mod multicam;
pub mod presets;
pub mod previews;
pub mod project_manager;
pub mod project_tools;
pub mod proxies;
pub mod relink;
pub mod scene_detect;
pub mod sequence_extras;
pub mod sequence_tools;
pub mod settings;
pub mod shortcut_presets;
@ -213,6 +216,9 @@ pub struct EditorState {
/// Sequence markers copied with the clipboard clips (start relative to the copied range).
#[serde(skip)]
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).
@ -262,6 +268,8 @@ pub struct Session {
pub media_jobs: Vec<proxies::PendingJob>,
/// Mask tracking jobs whose keyframes are still being written.
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).
pub presets: presets::PresetLibrary,
/// Exports run a batch at a time by [`Session::pump_jobs`] (hosts without threads: web).
@ -346,6 +354,7 @@ impl Session {
offline: Default::default(),
media_jobs: Vec::new(),
mask_jobs: Vec::new(),
scene_jobs: Vec::new(),
presets: Default::default(),
stepped: Vec::new(),
transcriber: None,
@ -431,6 +440,7 @@ impl Session {
pub fn poll_persistence(&mut self) {
proxies::poll(self);
masks::poll(self);
scene_detect::poll(self);
let Some(p) = self.persistence.as_mut() else { return };
for ev in p.drain_events() {
match ev {
@ -471,7 +481,7 @@ impl Session {
if let Some(p) = &self.path
&& !cfg!(target_arch = "wasm32")
{
self.previews.move_to(previews::dir_for_project(p));
self.previews.move_to(project_tools::previews_dir(self).unwrap_or_else(|| previews::dir_for_project(p)));
}
}

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> {
let pr = preset(str_p(p, "preset").unwrap_or(DEFAULT_PROXY_PRESET)).ok_or_else(|| bad("media.createProxies", "unknown preset (see media.proxyPresets)"))?;
let items = items_param(s, p);
let dest = str_p(p, "destination").map(str::to_string);
// default: Project Settings ▸ Scratch Disks ▸ Captured and Generated, else next to the media
let dest = str_p(p, "destination")
.map(str::to_string)
.or_else(|| s.project.settings.scratch.captured.clone().filter(|d| !d.is_empty()).map(|d| format!("{d}/Proxies")));
let mut work = Vec::new();
let mut taken = Vec::new();
let mut skipped = Vec::new();

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
/// edit a shipped one.
pub const MIGRATIONS: &[Migration] = &[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9, v9_to_v10];
pub const MIGRATIONS: &[Migration] = &[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9, v9_to_v10, v10_to_v11];
/// The schema version this build writes (and the newest it reads).
pub const SCHEMA_VERSION: u32 = MIGRATIONS.len() as u32 + 1;
@ -264,13 +264,19 @@ fn v9_to_v10(doc: Value) -> Result<Value, String> {
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)]
mod tests {
use super::*;
#[test]
fn schema_version_matches_table() {
assert_eq!(SCHEMA_VERSION, 10);
assert_eq!(SCHEMA_VERSION, 11);
}
#[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.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
//! resolved against the `base_dir` passed to the importer); the engine probes/relinks them.
pub mod ale;
pub mod edl;
pub mod fcp7;
pub mod fcpxml;

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 effect;
pub mod essential;
pub mod find;
pub mod graphic;
pub mod keyframe;
pub mod mask;
@ -28,6 +29,7 @@ use serde::{Deserialize, Serialize};
pub use caption::{Caption, CaptionAlign, CaptionAnchor, CaptionFormat, CaptionStyle, CaptionTrack, plain_text};
pub use effect::{EffectDef, EffectInstance, EffectKind, ParamDef, ParamKind, effect_defs, find_effect};
pub use essential::{AudioType, EssentialSound};
pub use find::{FindOp, FindQuery, FindRow, SearchBin};
pub use keyframe::{Interpolation, Keyframe, Param, ParamValue};
pub use mask::{Mask, MaskMode, MaskPath, MaskVertex, TrackMethod};
pub use mixer::{AutomationMode, InputMap, MixerStrip, TrackSend};
@ -444,6 +446,9 @@ pub enum MarkerKind {
Chapter,
Segmentation,
WebLink,
/// Markers ▸ Add Flash Cue Marker… (a cue point for interactive / Flash-style playback; kept
/// for interchange). Schema v11.
FlashCue,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -977,6 +982,42 @@ pub struct ProjectSettings {
/// Project Settings ▸ Ingest Settings: what happens to media on import.
#[serde(default)]
pub ingest: IngestSettings,
/// Project Settings ▸ General ▸ Action and Title Safe Areas: (horizontal, vertical) percent.
#[serde(default = "title_safe_default")]
pub title_safe: (f64, f64),
#[serde(default = "action_safe_default")]
pub action_safe: (f64, f64),
/// Project Settings ▸ General ▸ Capture Format (`DV` or `HDV`; informational, FilmCraft has no
/// tape capture). Schema v11.
#[serde(default = "capture_format_default")]
pub capture_format: String,
/// Project Settings ▸ Scratch Disks. Schema v11.
#[serde(default)]
pub scratch: ScratchDisks,
}
fn title_safe_default() -> (f64, f64) {
(20.0, 20.0)
}
fn action_safe_default() -> (f64, f64) {
(10.0, 10.0)
}
fn capture_format_default() -> String {
"DV".into()
}
/// Project Settings ▸ Scratch Disks: where generated files go. `None` = Same as Project (next to
/// the project file; the data directory for an unsaved project).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ScratchDisks {
/// Captured and Generated media: proxies (when no ingest destination is set), Extract Audio.
pub captured: Option<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).
@ -1016,6 +1057,10 @@ impl Default for ProjectSettings {
default_transition_duration_frames: 24,
default_audio_transition_duration: Tick(TICKS_PER_SECOND),
ingest: IngestSettings::default(),
title_safe: title_safe_default(),
action_safe: action_safe_default(),
capture_format: capture_format_default(),
scratch: ScratchDisks::default(),
}
}
}
@ -1036,6 +1081,10 @@ pub struct Project {
/// are media time. Shared (`Arc`) so undo snapshots don't copy them.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub transcripts: BTreeMap<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>`.
@ -1068,6 +1117,7 @@ impl Project {
next_id: 1,
luts: Vec::new(),
transcripts: BTreeMap::new(),
search_bins: Vec::new(),
}
}

View File

@ -23,6 +23,7 @@ pub mod multicam;
pub mod offline;
pub mod plan;
pub mod preview;
pub mod scene;
pub mod track;
pub mod transitions;
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();
}
}
// fields of the open M3.11 menu dialog (`panels::menu_dialogs`)
if let Some(m) = p.get("menuDialog").and_then(Value::as_object) {
let Some(d) = app.ui.extras.dialog.as_mut() else { return err("no menu dialog is open") };
for (k, v) in m {
d.params[k.as_str()] = v.clone();
}
}
ok(Value::Null)
}
"ui.panel.show" | "ui.panel.close" => {

View File

@ -81,6 +81,9 @@ pub struct HostHooks {
/// (macOS: `orderFrontRegardless`). Without it the app only requests a repaint: it never
/// activates itself for an agent, because the user's keystrokes would land here.
pub raise_without_focus: Option<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)]

View File

@ -77,6 +77,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("view.guideTemplates.apply", "Apply Guide Template", [], None),
uic!("view.guideTemplates.delete", "Delete Guide Template", [], None),
uic!("view.compare.setReference", "Set Comparison Reference", [], None),
uic!("view.dynamicAudioWaveforms", "Dynamic Audio Waveforms", ["View"], None),
uic!("multicam.toggleView", "Multi-Camera View", ["View"], Some("Shift+0")),
uic!("multicam.recordToggle", "Multi-Camera Record On/Off Toggle", [], Some("0")),
uic!("view.theme.dark", "Darkest", ["View", "Appearance"], None),
@ -111,6 +112,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("help.appPage", "FilmCraft on getartcraft.com", ["Help"], None),
uic!("help.github", "FilmCraft on GitHub", ["Help"], None),
uic!("help.reportIssue", "Report an Issue…", ["Help"], None),
uic!("help.revealLogFiles", "Reveal Log Files…", ["Help"], None),
uic!("help.systemCompatibilityReport", "System Compatibility Report…", ["Help"], None),
uic!("app.about", "About FilmCraft", ["Help"], None),
uic!("app.keyboardShortcuts", "Keyboard Shortcuts…", ["Edit"], Some("Cmd+Alt+K")),
uic!("app.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) {
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 Err(e) = &r {
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);
for it in &mut v {
if it.id.starts_with("view.") {
it.checked = crate::panels::monitor_view::checked(app, &it.id);
it.checked = crate::panels::monitor_view::checked(app, &it.id).or(crate::panels::menu_dialogs::checked(app, &it.id));
it.enabled &= crate::panels::monitor_view::enabled(app, &it.id);
}
}

View File

@ -7,6 +7,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
crate::panels::media_dialogs::show(app, ctx);
crate::panels::color_dialogs::show(app, ctx);
crate::panels::clip_dialogs::show(app, ctx);
crate::panels::menu_dialogs::show(app, ctx);
crate::panels::presets::save_dialog(app, ctx);
crate::panels::audio_fx_editor::show(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 {
continue;
}
let resp = ui.selectable_label(false, format!("{} {name}", if *is_dir { "📁" } else { "🎞" }));
let sel = app.ui.extras.media_browser_selection.contains(&path);
let resp = ui.selectable_label(sel, format!("{} {name}", if *is_dir { "📁" } else { "🎞" }));
app.auto.add(&format!("mediaBrowser.entry.{name}"), resp.rect, name);
if resp.clicked() && !*is_dir {
let multi = ui.input(|i| i.modifiers.command);
let s = &mut app.ui.extras.media_browser_selection;
if multi {
if sel {
s.retain(|p| *p != path);
} else {
s.push(path.clone());
}
} else {
*s = vec![path.clone()];
}
}
if resp.double_clicked() {
if *is_dir {
dir = path.clone();

View File

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

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 channel = display.is_channel();
let use_gpu = which == Which::Program && app.gpu.is_some() && !channel;
let use_gpu = which == Which::Program && app.gpu.is_some() && !channel && !crate::panels::menu_dialogs::software_renderer(app);
let cpu_target = target;
let target = match (use_gpu, target) {
(true, Target::Sequence(s)) => Target::SequencePlan(s),

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));
let mut row = 0usize;
list_bin(app, ui, &root, 0, &filter, &mut row, &mut actions, true);
// search bins: saved queries listing their live matches
let mut draw = |app: &mut FilmcraftApp, ui: &mut egui::Ui, bin: &Bin, row: &mut usize, actions: &mut Vec<(String, serde_json::Value)>| {
list_bin(app, ui, bin, 1, "", row, actions, false);
};
crate::panels::menu_dialogs::search_bin_rows(app, ui, &mut row, &mut actions, &mut draw);
}
_ => icon_view(app, ui, &root, &filter, &mut actions),
}
@ -75,6 +80,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
resp.context_menu(|ui| {
for (label, cmd) in [
("New Bin", "file.newBin"),
("New Search Bin", "file.newSearchBin"),
("Find…", "edit.find"),
("Automate to Sequence…", "clip.automateToSequence"),
("New Sequence…", "file.newSequence"),
("Import…", "file.import"),
("Bars and Tone", "file.newBarsAndTone"),
@ -146,8 +154,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
});
} else if resp.clicked() && cmd != "find" {
actions.push((cmd.into(), json!({})));
} else if resp.clicked() {
actions.push((if cmd == "find" { "edit.find" } else { cmd }.into(), json!({})));
}
rx -= 26.0;
}

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 gain = waveform_display_gain(peak, it.gain_db);
let dynamic = app.ui.extras.dynamic_waveforms;
let mut mesh = egui::Mesh::default();
let dur_px = body.width().max(1.0);
for x in (clip.min.x.floor() as i32)..(clip.max.x.ceil() as i32) {
@ -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) {
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;
// off: linear amplitude
let h = if dynamic {
let db = 20.0 * (m * gain).max(1e-5).log10();
let h = ((db + 48.0) / 48.0).clamp(0.0, 1.0) * area.height();
((db + 48.0) / 48.0).clamp(0.0, 1.0) * area.height()
} else {
(m * gain).clamp(0.0, 1.0) * area.height()
};
if h > 0.3 {
mesh.add_colored_rect(Rect::from_min_max(pos2(x as f32, area.max.y - h), pos2(x as f32 + 1.0, area.max.y)), col);
}

View File

@ -442,6 +442,10 @@ pub struct UiState {
/// open when Some). See `panels::clip_dialogs`.
#[serde(default)]
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
@ -721,6 +725,7 @@ impl Default for UiState {
multicam_record: true,
guide_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 |
| `ui.inspect` | – | UI state (tool, workspace, dock, timeline view, playback, fps…) |
| `ui.elements` | `{prefix?}` | every on-screen interactive element: id, label, rect |
| `ui.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit}, program:{…}, source:{…}, clipDialog:{param: value}}` | `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …) |
| `ui.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit}, program:{…}, source:{…}, clipDialog:{param: value}, menuDialog:{param: value}}` | `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`) |
| `ui.panel.show` / `ui.panel.close` | `{panel}` | |
| `ui.click` / `ui.move` | `{id}` or `{x,y}`, `button`, `count`, `modifiers` | synthetic pointer input |
| `ui.drag` | `{from, to, steps, modifiers}` | press–move–release |

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 |
| 9 | M10.5 | transcripts of media items (`project.transcripts`); no-op step |
| 10 | M3.10 | clip time interpolation, Hold Filters, Field Options, audio source channels, Modify ▸ Audio Channels map, subclip Restrict Trims; no-op step |
| 11 | M3.11 | search bins (`project.search_bins`), Flash Cue markers, Project Settings safe areas, capture format and scratch disks; no-op step |
### Migrations
@ -61,8 +62,8 @@ on disk is unchanged until you save. The **first save over it** keeps the origin
A file with a `schema_version` newer than the build supports is **refused**, not half-read:
> this project was saved by a newer version of FilmCraft (project schema v11); this build reads up to
> v10. Update FilmCraft to open it.
> this project was saved by a newer version of FilmCraft (project schema v12); this build reads up to
> v11. Update FilmCraft to open it.
Reading it partially and saving it back would silently drop whatever the newer version added.

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 |
| Render | `crates/render/src/tests.rs` | compositing, opacity, Motion, cross dissolve midpoint, ½-res vs full, GPU plan vs reference, audio mix, audio-effect continuity |
| Multi-camera / sync | `crates/audio-dsp/src/sync.rs`, `crates/engine/src/multicam_tests.rs`, `crates/ui-egui/tests/multicam_ui.rs` | audio offsets within one sample: Rust-generated multi-mic speech (gains −20…+10 dB, SNR 40/25/10/3 dB, zero-phase colouring, fractional delays, a 0 dB-SNR room with a −4 dB echo) and ffmpeg-made camera/recorder files (pink-noise bursts, independent noise; exact lags recovered); timecode (± hours) and marker sync within a frame; Merge Clips; timeline Synchronize keeps frame-aligned starts; multicam structure, angle render (PSNR vs the camera), Switch Audio mix = the angle's track, live switching from a simulated playback clock (cuts, healing, one undo step), Ctrl+N cuts, flatten (frames identical), save/load; `perf_grid_of_four_1080p_angles` (ignored) prints the grid cost |
| Menu long tail (M3.11) | `crates/render/src/scene.rs`, `crates/engine/src/{scene_detect,sequence_extras,project_tools}_tests.rs`, `crates/interchange/src/ale.rs`, `crates/project/src/find.rs`, `crates/ui-egui/tests/menus_ui.rs` | Scene Edit Detection on synthetic frames (hard cuts vs pans, flashes, sensitivity) and on generated movies with known cut points (our own ProRes render of demo scenes; an ffmpeg-made H.264 concat of testsrc2 / SMPTE bars / Mandelbrot): cuts with linked audio, clip markers, subclips, one undo step, background job and cancel; Normalize Mix Track to a target peak; Simplify Sequence; Find / Find Next and search bins; Automate to Sequence (overlap, transitions, insert, unnumbered markers); ALE round trip; Project Settings and scratch disks; menu order and shortcuts against Premiere's; the dialogs by automation id |
| Mixer | `crates/render/src/mixer_tests.rs`, `crates/engine/src/mixer_tests.rs` | sample-exact fader gain and pan laws, automation at block boundaries (bit-identical however requests are cut), solo/mute/solo-safe, sends and submix routing, latency-compensation alignment, render-vs-playback identity, Touch ramp-back, recorder modes (Latch/Touch/Write), thinning, Audio Gain, transition curves; `perf_24_tracks_3_effects_realtime_factor` prints the realtime factor |
| Oracle | `crates/*/tests/*oracle*.rs`, `conformance.rs` | compare with ffmpeg/ffprobe (§2) |
| Robustness / fuzz | `*/tests/robustness.rs`, `*/tests/fuzz.rs` | seeded mutation and truncation of real and synthetic files; nothing may panic |