565 lines
29 KiB
Rust
565 lines
29 KiB
Rust
//! 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 export_selection_project_of_a_bin_exports_its_contents() {
|
|
let mut s = demo();
|
|
let (footage_bin, bin_items) = s
|
|
.project
|
|
.root
|
|
.children
|
|
.iter()
|
|
.find_map(|c| match c {
|
|
filmcraft_project::BinEntry::Bin(b) if b.name == "Footage" => {
|
|
let mut items = Vec::new();
|
|
b.all_items(&mut items);
|
|
Some((b.id.0, items))
|
|
}
|
|
_ => None,
|
|
})
|
|
.expect("Footage bin");
|
|
assert_eq!(bin_items.len(), 6);
|
|
|
|
// Selecting the bin (its id, not its items) must export the bin's contents, not an empty project.
|
|
s.execute("project.select", json!({"items": [footage_bin]})).unwrap();
|
|
let dir = tmp_dir("export-bin");
|
|
let path = dir.join("Footage.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!(r["items"].as_array().unwrap().len(), p.items.len(), "reported items match the saved project");
|
|
let reported: std::collections::BTreeSet<ItemId> = r["items"].as_array().unwrap().iter().map(|v| ItemId(v.as_u64().unwrap())).collect();
|
|
assert_eq!(reported, bin_items.iter().copied().collect(), "the bin's items are the exported items");
|
|
for n in ["Ocean_Sunset.mp4", "Neon_Loop.mov", "Misty_Forest.mp4"] {
|
|
assert!(p.items.values().any(|i| i.name == n), "{n}");
|
|
}
|
|
assert!(!p.items.values().any(|i| i.name == "Ambient_Score.wav"), "the Audio bin stayed behind");
|
|
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 { .. })));
|
|
|
|
// A mixed bin + item request must not report the bin id, which names no item in the output.
|
|
let explicit = dir.join("Mixed.fcproj").to_string_lossy().to_string();
|
|
let r = s.execute("file.exportSelectionProject", json!({"items": [footage_bin, bin_items[0].0], "path": explicit})).unwrap();
|
|
let p = filmcraft_format::decode(&std::fs::read(&explicit).unwrap()).unwrap().project;
|
|
for v in r["items"].as_array().unwrap() {
|
|
let id = ItemId(v.as_u64().unwrap());
|
|
assert!(p.item(id).is_some(), "reported item {id:?} is in the output");
|
|
}
|
|
|
|
// An empty bin has nothing to export: an actionable error, no misleading empty file.
|
|
let empty_bin = s.execute("file.newBin", json!({"name": "Empty"})).unwrap()["bin"].as_u64().unwrap();
|
|
s.execute("project.select", json!({"items": [empty_bin]})).unwrap();
|
|
let empty_path = dir.join("Empty.fcproj").to_string_lossy().to_string();
|
|
assert!(s.execute("file.exportSelectionProject", json!({"path": empty_path})).is_err());
|
|
assert!(!std::path::Path::new(&empty_path).exists(), "no empty project written");
|
|
}
|
|
|
|
#[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 file_reveal_enabled_for_imported_clip() {
|
|
let dir = tmp_dir("reveal");
|
|
let path = dir.join("take.mp4");
|
|
make_movie(&path, DemoScene::Aurora, 64, 36, 8);
|
|
let (mut s, items, _) = session_with(&[&path]);
|
|
assert!(!s.is_enabled("file.reveal"), "disabled with no Project panel selection");
|
|
s.execute("project.select", json!({"items": [items[0].0]})).unwrap();
|
|
assert!(s.is_enabled("file.reveal"));
|
|
assert_eq!(s.execute("file.reveal", json!({})).unwrap()["paths"], json!([path.to_string_lossy()]));
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
|
|
/// Issue #916: overlap is clamped to half of each clip for placement, but the Cross Dissolve
|
|
/// used the unclamped `overlapFrames` and started before the first clip.
|
|
#[test]
|
|
fn automate_to_sequence_clamps_transition_to_overlap() {
|
|
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"];
|
|
let ids: Vec<ItemId> = names.iter().map(|n| item_named(&s, n)).collect();
|
|
for id in &ids {
|
|
s.execute("project.setMarks", json!({"item": id.0, "in": 0, "out": rate.tick_of(19).0})).unwrap();
|
|
}
|
|
s.execute("project.select", json!({"items": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
|
s.execute("clip.automateToSequence", json!({"ordering": "selection", "method": "overwrite", "overlapFrames": 30})).unwrap();
|
|
let q = s.active_sequence().unwrap();
|
|
let v = &q.video_tracks[0];
|
|
assert_eq!(v.items.len(), 2);
|
|
assert_eq!(v.items[0].start, Tick::ZERO);
|
|
assert_eq!(v.items[1].start, rate.tick_of(10), "overlap clamped to half of a 20-frame clip");
|
|
assert_eq!(v.transitions.len(), 1);
|
|
let t = &v.transitions[0];
|
|
assert_eq!(t.duration, rate.tick_of(10), "dissolve matches clamped overlap, not 30");
|
|
assert!(t.start >= v.items[0].start, "dissolve must not start before the first clip: {t:?}");
|
|
assert!(t.end() <= v.items[1].end(), "dissolve must not end after the second clip: {t:?}");
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
|
|
#[test]
|
|
fn clear_deletes_a_bin_with_everything_in_it() {
|
|
let mut s = demo();
|
|
let before = s.project.items.len();
|
|
let outer = s.execute("file.newBin", json!({"name": "Old footage"})).unwrap()["bin"].as_u64().unwrap();
|
|
let inner = s.execute("file.newBin", json!({"name": "Inside", "parent": outer})).unwrap()["bin"].as_u64().unwrap();
|
|
let empty = s.execute("file.newBin", json!({"name": "Empty"})).unwrap()["bin"].as_u64().unwrap();
|
|
let ocean = item_named(&s, "Ocean_Sunset.mp4");
|
|
s.execute("project.moveToBin", json!({"items": [ocean.0], "bin": inner})).unwrap();
|
|
let dunes = item_named(&s, "Desert_Dunes.mp4");
|
|
let select = |s: &mut Session| s.execute("project.select", json!({"items": [dunes.0]})).unwrap();
|
|
select(&mut s);
|
|
// an empty bin, by its id: gone, nothing else touched
|
|
let r = s.execute("project.delete", json!({"items": [empty]})).unwrap();
|
|
assert_eq!(r, json!({"items": 0, "bins": 1, "offline": 0}));
|
|
assert!(s.project.root.find_bin(filmcraft_project::BinId(empty)).is_none());
|
|
assert_eq!(s.project.items.len(), before);
|
|
// a bin holding a sub-bin holding a clip: all three go
|
|
select(&mut s);
|
|
let r = s.execute("project.delete", json!({"items": [outer]})).unwrap();
|
|
assert_eq!(r, json!({"items": 1, "bins": 1, "offline": 0}));
|
|
assert!(s.project.root.find_bin(filmcraft_project::BinId(outer)).is_none());
|
|
assert!(s.project.root.find_bin(filmcraft_project::BinId(inner)).is_none());
|
|
assert!(s.project.item(ocean).is_none());
|
|
assert_eq!(s.project.items.len(), before - 1);
|
|
// undo puts the bins and the clip back where they were
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
assert!(s.project.root.find_bin(filmcraft_project::BinId(inner)).is_some());
|
|
assert_eq!(s.project.root.parent_of(ocean), Some(filmcraft_project::BinId(inner)));
|
|
// the clip, its bin and the bin around it all named: each counted once
|
|
select(&mut s);
|
|
let r = s.execute("project.delete", json!({"items": [ocean.0, inner, outer]})).unwrap();
|
|
assert_eq!(r, json!({"items": 1, "bins": 1, "offline": 0}));
|
|
assert_eq!(s.project.items.len(), before - 1);
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
// the project's own top bin is never cleared
|
|
let root = s.project.root.id.0;
|
|
select(&mut s);
|
|
s.execute("project.delete", json!({"items": [root]})).unwrap();
|
|
assert_eq!(s.project.items.len(), before);
|
|
}
|
|
|
|
#[test]
|
|
fn deleting_media_used_on_the_timeline_makes_it_offline() {
|
|
let mut s = Session::default();
|
|
s.execute("file.newProject", json!({"name": "Offline"})).unwrap();
|
|
s.execute("file.newSequence", json!({"name": "Seq", "fps": 25, "video": 1, "audio": 0, "width": 16, "height": 16})).unwrap();
|
|
let item = s
|
|
.execute("file.newOfflineFile", json!({"name": "Clip", "seconds": 2, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16}))
|
|
.unwrap()["item"]
|
|
.as_u64()
|
|
.unwrap();
|
|
s.execute("timeline.place", json!({"item": item, "track": "V1", "time": 0})).unwrap();
|
|
let clips_before = s.project.sequences().next().unwrap().as_sequence().unwrap().video_tracks[0].items.len();
|
|
let r = s.execute("project.delete", json!({"items": [item]})).unwrap();
|
|
assert_eq!(r["offline"], 1);
|
|
assert_eq!(r["items"], 0);
|
|
assert!(s.project.item(ItemId(item)).unwrap().as_media().unwrap().offline);
|
|
assert_eq!(s.project.sequences().next().unwrap().as_sequence().unwrap().video_tracks[0].items.len(), clips_before);
|
|
let unused = s
|
|
.execute("file.newOfflineFile", json!({"name": "Spare", "seconds": 1, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16}))
|
|
.unwrap()["item"]
|
|
.as_u64()
|
|
.unwrap();
|
|
let r = s.execute("project.delete", json!({"items": [unused]})).unwrap();
|
|
assert_eq!(r["items"], 1);
|
|
assert_eq!(r["offline"], 0);
|
|
assert!(s.project.item(ItemId(unused)).is_none());
|
|
}
|