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:
commit
feae349814
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 <take></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());
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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, ¶ms) {
|
||||
if let Err(e) = &r {
|
||||
app.ui.status = e.clone();
|
||||
}
|
||||
return r;
|
||||
}
|
||||
if let Some(r) = crate::panels::media_dialogs::route(app, id, ¶ms) {
|
||||
if let Err(e) = &r {
|
||||
app.ui.status = e.clone();
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue