Merge transcripts (M10.5) and clip/project commands (M3.10); clip schema renumbered to v10
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e222d025cf
|
|
@ -31,7 +31,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
|||
| M0 | Skeleton + visual shell | ✅ | Workspace, 20 crates, dock/workspaces, Premiere 26 look, native menus, control channel, MCP, xtask gates (layers, wasm) | — | — |
|
||||
| M1 | Media I/O | ✅ | MP4/MOV demux+mux, WAV, stills, MJPEG, symphonia audio (MP3/FLAC/ALAC/Vorbis), GOP seek + frame cache, import | Media Browser polish | 2 |
|
||||
| M2 | H.264 decoder | ✅ | Own decoder, bit-exact on 37+ streams, 500–600 fps 1080p | — | — |
|
||||
| M3 | Editing core | 🟡 | Edit algebra (insert/overwrite/razor/lift/extract/ripple/roll/slip/slide/rate-stretch/nest/paste), tools, markers, trim mode + Trim Monitor + dynamic J/K/L trimming, Keyboard Shortcuts editor with FilmCraft/Premiere/FCP/Avid presets; 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, subclip polish, ~220 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); 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 |
|
||||
|
|
|
|||
|
|
@ -42,22 +42,26 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
|
|||
for top in MENUS {
|
||||
let sub = Submenu::new(top, true);
|
||||
let mine: Vec<&Item> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect();
|
||||
let mut subs: Vec<(String, Submenu)> = Vec::new();
|
||||
// submenus by path prefix (e.g. Clip ▸ Video Options ▸ Time Interpolation), created where
|
||||
// their first item appears
|
||||
let mut subs: Vec<(Vec<String>, Submenu)> = Vec::new();
|
||||
for it in mine {
|
||||
let mi = MenuItem::with_id(it.id.clone(), &it.label, true, it.shortcut.as_deref().and_then(accel));
|
||||
native.push((it.id.clone(), mi.clone()));
|
||||
if let Some(name) = it.path.get(1) {
|
||||
if let Some((_, s)) = subs.iter().find(|(n, _)| n == name) {
|
||||
let _ = s.append(&mi);
|
||||
} else {
|
||||
let s = Submenu::new(name, true);
|
||||
let _ = s.append(&mi);
|
||||
let _ = sub.append(&s);
|
||||
subs.push((name.clone(), s));
|
||||
}
|
||||
} else {
|
||||
let _ = sub.append(&mi);
|
||||
let mut parent = sub.clone();
|
||||
for depth in 1..it.path.len() {
|
||||
let key = it.path[..=depth].to_vec();
|
||||
parent = match subs.iter().find(|(k, _)| *k == key) {
|
||||
Some((_, s)) => s.clone(),
|
||||
None => {
|
||||
let s = Submenu::new(&it.path[depth], true);
|
||||
let _ = parent.append(&s);
|
||||
subs.push((key, s.clone()));
|
||||
s
|
||||
}
|
||||
};
|
||||
}
|
||||
let _ = parent.append(&mi);
|
||||
}
|
||||
let _ = bar.append(&sub);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ impl Fx {
|
|||
scale_to_frame: false,
|
||||
essential: None,
|
||||
multicam: None,
|
||||
time_interpolation: Default::default(),
|
||||
hold_filters: false,
|
||||
field_options: None,
|
||||
source_channels: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn put(&mut self, track: TrackId, start: i64, dur: i64, src_in: i64) -> ClipId {
|
||||
|
|
|
|||
|
|
@ -176,6 +176,10 @@ mod tests {
|
|||
scale_to_frame: false,
|
||||
essential: None,
|
||||
multicam: None,
|
||||
time_interpolation: Default::default(),
|
||||
hold_filters: false,
|
||||
field_options: None,
|
||||
source_channels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ fn clip(id: u64, start: f64, dur: f64, src_in: f64) -> TrackItem {
|
|||
scale_to_frame: false,
|
||||
essential: None,
|
||||
multicam: None,
|
||||
time_interpolation: Default::default(),
|
||||
hold_filters: false,
|
||||
field_options: None,
|
||||
source_channels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,615 @@
|
|||
//! Tests of the M3.10 Edit / Clip / File menu commands (`clip_ops`): each one executes, checks the
|
||||
//! result, undoes / redoes where it edits, and checks its disabled state.
|
||||
|
||||
use filmcraft_project::{AudioChannels, FieldProcessing, ItemId, ItemKind, Label, TimeInterpolation, TrackItem};
|
||||
use filmcraft_time::{TICKS_PER_SECOND, Tick};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::media_test_util::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 v1(s: &Session) -> Vec<TrackItem> {
|
||||
s.active_sequence().unwrap().video_tracks[0].items.clone()
|
||||
}
|
||||
|
||||
fn clip(s: &Session, id: u64) -> TrackItem {
|
||||
s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(id)).unwrap().1.clone()
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, clips: &[u64]) {
|
||||
s.execute("timeline.select", json!({"clips": clips})).unwrap();
|
||||
}
|
||||
|
||||
fn menu_of(id: &str) -> Vec<&'static str> {
|
||||
crate::commands::find(id).unwrap_or_else(|| panic!("no command {id}")).menu.to_vec()
|
||||
}
|
||||
|
||||
fn f64_param(it: &TrackItem, effect: &str, param: &str) -> f64 {
|
||||
it.effect(effect).unwrap().param(param).unwrap().value.as_f64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menus_and_shortcuts_follow_premiere() {
|
||||
let ids: Vec<&str> = crate::commands::command_specs().iter().map(|c| c.id).collect();
|
||||
let pos = |id: &str| ids.iter().position(|x| *x == id).unwrap_or_else(|| panic!("{id} missing"));
|
||||
// Edit ▸ Label: Select Label Group, then the 16 colours in Premiere's order
|
||||
let label: Vec<&str> = crate::commands::command_specs().iter().filter(|c| c.menu == ["Edit", "Label"]).map(|c| c.label).collect();
|
||||
assert_eq!(label[0], "Select Label Group");
|
||||
assert_eq!(&label[1..], Label::ALL.iter().map(|l| l.name()).collect::<Vec<_>>().as_slice());
|
||||
assert!(crate::commands::find("edit.label").unwrap().menu.is_empty(), "the parameterised label command is for agents only");
|
||||
assert_eq!(pos("edit.pasteAttributes"), pos("edit.pasteInsert") + 1);
|
||||
assert_eq!(pos("edit.removeAttributes"), pos("edit.pasteAttributes") + 1);
|
||||
assert_eq!(pos("edit.selectAllMatching"), pos("edit.selectAll") + 1);
|
||||
assert!(pos("edit.removeUnused") > pos("edit.label.yellow") && pos("edit.consolidateDuplicates") == pos("edit.removeUnused") + 1);
|
||||
assert_eq!(pos("file.newSequenceFromClip"), pos("file.newSequence") + 1);
|
||||
assert_eq!(pos("file.newBinFromSelection"), pos("file.newBin") + 1);
|
||||
assert_eq!(pos("file.closeProject") + 1, 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"]);
|
||||
assert_eq!(menu_of("clip.replaceFromSourceMatchFrame"), ["Clip", "Replace With Clip"]);
|
||||
// Video Options in Premiere's order
|
||||
let vo: Vec<&str> = crate::commands::command_specs().iter().filter(|c| c.menu.get(1) == Some(&"Video Options")).map(|c| c.label).collect();
|
||||
assert_eq!(
|
||||
vo,
|
||||
[
|
||||
"Frame Hold Options…",
|
||||
"Add Frame Hold",
|
||||
"Insert Frame Hold Segment",
|
||||
"Field Options…",
|
||||
"Frame Sampling",
|
||||
"Frame Blending",
|
||||
"Optical Flow",
|
||||
"Scale to Frame Size",
|
||||
"Fit to frame",
|
||||
"Fill frame"
|
||||
]
|
||||
);
|
||||
let sc = |id: &str| crate::commands::find(id).unwrap().shortcut;
|
||||
assert_eq!(sc("edit.pasteAttributes"), Some("Cmd+Alt+V"));
|
||||
assert_eq!(sc("clip.makeSubclip"), Some("Cmd+U"));
|
||||
assert_eq!(sc("clip.audioChannels"), Some("Shift+G"));
|
||||
assert_eq!(sc("file.newBinFromSelection"), Some("Shift+B"));
|
||||
assert_eq!(sc("file.closeProject"), Some("Cmd+Shift+W"));
|
||||
// every id is unique
|
||||
let mut sorted = ids.clone();
|
||||
sorted.sort();
|
||||
sorted.dedup();
|
||||
assert_eq!(sorted.len(), ids.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_commands_and_select_label_group() {
|
||||
let mut s = demo();
|
||||
assert!(s.execute("edit.label.rose", json!({})).is_err(), "disabled with nothing selected");
|
||||
let c = v1(&s)[0].id.0;
|
||||
select(&mut s, &[c]);
|
||||
s.execute("edit.label.rose", json!({})).unwrap();
|
||||
assert_eq!(clip(&s, c).label, Label::Rose);
|
||||
assert_eq!(s.history.undo.last().unwrap().0, "Label Rose");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(clip(&s, c).label, Label::Iris);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(clip(&s, c).label, Label::Rose);
|
||||
// project items too
|
||||
let item = item_named(&s, "Ocean_Sunset.mp4");
|
||||
s.state.selection.clear();
|
||||
s.execute("project.select", json!({"items": [item.0]})).unwrap();
|
||||
s.execute("edit.label.teal", json!({})).unwrap();
|
||||
assert_eq!(s.project.item(item).unwrap().label, Label::Teal);
|
||||
// Select Label Group (project): every Iris item comes along with the Teal one? No: only Teal.
|
||||
s.execute("edit.selectLabelGroup", json!({})).unwrap();
|
||||
assert_eq!(s.state.project_selection, vec![item]);
|
||||
s.execute("project.select", json!({"items": [item_named(&s, "Aurora_Timelapse.mp4").0]})).unwrap();
|
||||
let r = s.execute("edit.selectLabelGroup", json!({})).unwrap();
|
||||
assert_eq!(r["items"].as_array().unwrap().len(), 5, "the other five Iris footage items: {r}");
|
||||
// Select Label Group (timeline): the Mango overlay alone, the Iris clips together
|
||||
s.state.project_selection.clear();
|
||||
let ov = s.active_sequence().unwrap().video_tracks[1].items[0].id.0;
|
||||
s.state.selection = vec![filmcraft_project::ClipId(ov)];
|
||||
s.execute("edit.selectLabelGroup", json!({})).unwrap();
|
||||
assert_eq!(s.state.selection.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_matching_selects_every_use_of_the_source() {
|
||||
let mut s = demo();
|
||||
let c = v1(&s)[4].clone();
|
||||
select(&mut s, &[c.id.0]);
|
||||
s.execute("edit.selectAllMatching", json!({})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!(s.state.selection.len(), 3, "V1 clip, its audio and the V2 overlay of the same source");
|
||||
assert!(s.state.selection.iter().all(|x| q.find_item(*x).unwrap().1.item == c.item));
|
||||
s.state.selection.clear();
|
||||
assert!(s.execute("edit.selectAllMatching", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_attributes_with_scaled_keyframes_and_remove_attributes() {
|
||||
let mut s = demo();
|
||||
let (a, b) = (v1(&s)[0].clone(), v1(&s)[2].clone());
|
||||
s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "motion", "param": "rotation", "value": 30.0})).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "opacity", "param": "opacity", "value": 40.0})).unwrap();
|
||||
s.execute("timeline.select", json!({"clips": [a.id.0]})).unwrap();
|
||||
s.execute("effects.apply", json!({"clips": [a.id.0], "effect": "gaussian_blur"})).unwrap();
|
||||
// animate scale over the whole of clip A (media time keyframes)
|
||||
let a_now = clip(&s, a.id.0);
|
||||
{
|
||||
let mut p = (*s.project).clone();
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
let it = p.sequence_mut(seq).unwrap().find_item_mut(a.id).unwrap().1;
|
||||
let sc = it.effect_mut("motion").unwrap().param_mut("scale").unwrap();
|
||||
sc.put_keyframe(a_now.source_in, filmcraft_project::ParamValue::Float(100.0));
|
||||
sc.put_keyframe(a_now.source_out(), filmcraft_project::ParamValue::Float(200.0));
|
||||
s.project = std::sync::Arc::new(p);
|
||||
}
|
||||
select(&mut s, &[a.id.0]);
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.state.selection.clear();
|
||||
assert!(s.execute("edit.pasteAttributes", json!({})).is_err(), "nothing selected");
|
||||
select(&mut s, &[b.id.0]);
|
||||
let r = s.execute("edit.pasteAttributes", json!({"opacity": false})).unwrap();
|
||||
assert_eq!(r["clips"], 1, "video only; the audio partner has nothing different to paste: {r}");
|
||||
let got = clip(&s, b.id.0);
|
||||
assert_eq!(f64_param(&got, "motion", "rotation"), 30.0);
|
||||
assert_eq!(f64_param(&got, "opacity", "opacity"), 100.0, "opacity left alone");
|
||||
assert!(got.effect("gaussian_blur").is_some());
|
||||
let kf: Vec<Tick> = got.effect("motion").unwrap().param("scale").unwrap().keyframes.iter().map(|k| k.time).collect();
|
||||
assert_eq!(kf, vec![got.source_in, got.source_out()], "Scale Attribute Times fits A's keyframes to B");
|
||||
// without scaling, keyframes keep their distance from the clip start
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(clip(&s, b.id.0).effect("gaussian_blur").is_none(), "one undo step");
|
||||
s.execute("edit.pasteAttributes", json!({"scaleTimes": false, "effects": false})).unwrap();
|
||||
let got = clip(&s, b.id.0);
|
||||
let kf: Vec<Tick> = got.effect("motion").unwrap().param("scale").unwrap().keyframes.iter().map(|k| k.time).collect();
|
||||
assert_eq!(kf, vec![got.source_in, got.source_in + (a_now.source_out() - a_now.source_in)]);
|
||||
assert!(got.effect("gaussian_blur").is_none(), "effects unticked");
|
||||
assert_eq!(f64_param(&got, "opacity", "opacity"), 40.0);
|
||||
// Remove Attributes: Motion back to defaults, effects gone, opacity kept when unticked
|
||||
s.execute("edit.pasteAttributes", json!({})).unwrap();
|
||||
s.execute("edit.removeAttributes", json!({"opacity": false})).unwrap();
|
||||
let got = clip(&s, b.id.0);
|
||||
assert_eq!(f64_param(&got, "motion", "rotation"), 0.0);
|
||||
assert_eq!(f64_param(&got, "motion", "scale"), 100.0);
|
||||
assert!(got.effect("motion").unwrap().param("scale").unwrap().keyframes.is_empty());
|
||||
assert_eq!(got.effect("motion").unwrap().param("position").unwrap().value.as_vec2().unwrap(), filmcraft_geom::Vec2::new(960.0, 540.0));
|
||||
assert!(got.effect("gaussian_blur").is_none());
|
||||
assert_eq!(f64_param(&got, "opacity", "opacity"), 40.0);
|
||||
// audio: volume pasted onto another audio clip
|
||||
let aud = s.active_sequence().unwrap().audio_tracks[0].items.clone();
|
||||
s.execute("timeline.select", json!({"clips": [aud[0].id.0]})).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": aud[0].id.0, "effect": "volume", "param": "level", "value": -3.0})).unwrap();
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
select(&mut s, &[aud[0].id.0]);
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
select(&mut s, &[aud[3].id.0]);
|
||||
s.execute("edit.pasteAttributes", json!({})).unwrap();
|
||||
assert_eq!(f64_param(&clip(&s, aud[3].id.0), "volume", "level"), -3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_unused_and_consolidate_duplicates() {
|
||||
let mut s = demo();
|
||||
let n = s.project.items.len();
|
||||
let bars = item_named(&s, "Bars and Tone");
|
||||
let r = s.execute("edit.removeUnused", json!({})).unwrap();
|
||||
let removed: Vec<u64> = r["removed"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
||||
assert!(removed.contains(&bars.0));
|
||||
assert_eq!(removed.len(), 3, "bars, leader and colour matte: {r}");
|
||||
assert!(s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Sequence(_))).count() == 2, "sequences are kept");
|
||||
assert_eq!(s.project.items.len(), n - 3);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.items.len(), n);
|
||||
// a subclip of an unused clip keeps its parent; removing an unused subclip too
|
||||
// duplicates: Duplicate makes a copy of the same media; use it on the timeline then consolidate
|
||||
let ocean = item_named(&s, "Ocean_Sunset.mp4");
|
||||
s.execute("project.select", json!({"items": [ocean.0]})).unwrap();
|
||||
let dup = ItemId(s.execute("edit.duplicate", json!({})).unwrap()["items"][0].as_u64().unwrap());
|
||||
s.execute("timeline.place", json!({"item": dup.0, "track": "V3", "audioTrack": "A3", "seconds": 1.0})).unwrap();
|
||||
let r = s.execute("edit.consolidateDuplicates", json!({})).unwrap();
|
||||
assert_eq!(r["removed"], json!([dup.0]));
|
||||
assert!(s.project.item(dup).is_none());
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert!(q.all_tracks().flat_map(|t| t.items.iter()).all(|i| i.item != dup), "clips now use the kept item");
|
||||
assert_eq!(q.video_tracks[2].items[0].item, ocean);
|
||||
assert_eq!(s.execute("edit.consolidateDuplicates", json!({})).unwrap()["removed"], json!([]));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.project.item(dup).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_sequence_from_clip_bin_from_selection_and_offline_file() {
|
||||
let mut s = demo();
|
||||
assert!(s.execute("file.newSequenceFromClip", json!({})).is_err(), "nothing selected in the Project panel");
|
||||
let a = item_named(&s, "Desert_Dunes.mp4");
|
||||
let b = item_named(&s, "Misty_Forest.mp4");
|
||||
s.execute("project.select", json!({"items": [a.0, b.0]})).unwrap();
|
||||
let undo0 = s.history.undo.len();
|
||||
let r = s.execute("file.newSequenceFromClip", json!({})).unwrap();
|
||||
let seq = ItemId(r["sequence"].as_u64().unwrap());
|
||||
assert_eq!(s.state.active_sequence, Some(seq));
|
||||
assert_eq!(s.project.item(seq).unwrap().name, "Desert_Dunes.mp4");
|
||||
let q = s.project.sequence(seq).unwrap();
|
||||
assert_eq!(q.video_tracks[0].items.len(), 2, "both clips, one after the other");
|
||||
assert_eq!(q.video_tracks[0].items[1].start, q.video_tracks[0].items[0].end());
|
||||
assert_eq!(q.audio_tracks[0].items.len(), 2);
|
||||
assert_eq!(s.history.undo.len(), undo0 + 1, "one undo step");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.project.item(seq).is_none());
|
||||
// Bin From Selection
|
||||
s.execute("project.select", json!({"items": [a.0, b.0]})).unwrap();
|
||||
let r = s.execute("file.newBinFromSelection", json!({})).unwrap();
|
||||
let bin = filmcraft_project::BinId(r["bin"].as_u64().unwrap());
|
||||
let found = s.project.root.find_bin(bin).unwrap();
|
||||
assert_eq!(found.name, "Bin 01");
|
||||
assert_eq!(s.project.root.parent_of(a), Some(bin));
|
||||
assert_eq!(s.project.root.parent_of(b), Some(bin));
|
||||
// Offline File
|
||||
let r = s
|
||||
.execute("file.newOfflineFile", json!({"fileName": "A001C003.mov", "tapeName": "A001", "timecode": "01:00:00:00", "seconds": 4.0, "audio": false}))
|
||||
.unwrap();
|
||||
let it = s.project.item(ItemId(r["item"].as_u64().unwrap())).unwrap();
|
||||
let m = it.as_media().unwrap();
|
||||
assert!(m.offline && m.info.audio.is_none() && m.info.video.is_some());
|
||||
assert_eq!(m.info.duration, s.sequence_rate().snap_nearest(Tick(4 * TICKS_PER_SECOND)));
|
||||
assert_eq!(m.info.start_timecode, Some(3600 * 24));
|
||||
assert_eq!(it.metadata["Tape Name"], "A001");
|
||||
assert!(s.execute("file.newOfflineFile", json!({"video": false, "audio": false})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_project_and_save_all() {
|
||||
let mut s = demo();
|
||||
assert!(s.execute("file.saveAll", json!({})).is_err(), "never saved: use Save As");
|
||||
let dir = tmp_dir("close-project");
|
||||
let path = dir.join("p.fcproj").to_string_lossy().into_owned();
|
||||
s.execute("file.saveAs", json!({"path": path})).unwrap();
|
||||
s.execute("project.select", json!({"items": [item_named(&s, "Ocean_Sunset.mp4").0]})).unwrap();
|
||||
s.execute("edit.label.rose", json!({})).unwrap();
|
||||
assert!(s.is_dirty());
|
||||
s.execute("file.saveAll", json!({})).unwrap();
|
||||
assert!(!s.is_dirty());
|
||||
s.execute("edit.label.tan", json!({})).unwrap();
|
||||
assert!(s.execute("file.closeProject", json!({})).is_err(), "unsaved changes");
|
||||
s.execute("file.closeProject", json!({"force": true})).unwrap();
|
||||
assert!(s.project.items.is_empty() && s.path.is_none() && s.state.active_sequence.is_none());
|
||||
assert!(!s.history.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_and_edit_subclips() {
|
||||
let mut s = demo();
|
||||
let ocean = item_named(&s, "Ocean_Sunset.mp4");
|
||||
let r24 = filmcraft_time::FrameRate::FPS_23_976;
|
||||
s.execute("source.open", json!({"item": ocean.0})).unwrap();
|
||||
s.execute("project.setMarks", json!({"item": ocean.0, "in": r24.tick_of(24).0, "out": r24.tick_of(71).0})).unwrap();
|
||||
let r = s.execute("clip.makeSubclip", json!({})).unwrap();
|
||||
let sub = ItemId(r["item"].as_u64().unwrap());
|
||||
let it = s.project.item(sub).unwrap();
|
||||
assert_eq!(it.name, "Ocean_Sunset.mp4.Subclip");
|
||||
let ItemKind::Subclip { parent, range, restrict_trims } = it.kind.clone() else { panic!() };
|
||||
assert_eq!((parent, range.start, range.end(), restrict_trims), (ocean, r24.tick_of(24), r24.tick_of(72), true));
|
||||
assert_eq!(s.project.root.parent_of(sub), s.project.root.parent_of(ocean), "made in the parent's bin");
|
||||
// edit it in: the clip shows the subclip's media
|
||||
s.execute("source.open", json!({"item": sub.0})).unwrap();
|
||||
s.execute("playhead.set", json!({"frame": 0})).unwrap();
|
||||
let r = s.execute("source.overwrite", json!({})).unwrap();
|
||||
let c = clip(&s, r["clips"][0].as_u64().unwrap());
|
||||
assert_eq!((c.source_in, c.duration), (r24.tick_of(24), r24.tick_of(48)));
|
||||
assert_eq!(crate::media_duration(&s.project, &s.media, sub), Some(r24.tick_of(72)), "trims stop at the subclip's end");
|
||||
// Edit Subclip: new range, then no restriction
|
||||
s.execute("project.select", json!({"items": [sub.0]})).unwrap();
|
||||
s.execute("clip.editSubclip", json!({"startFrame": 10, "endFrame": 20, "restrictTrims": false})).unwrap();
|
||||
let ItemKind::Subclip { range, restrict_trims, .. } = s.project.item(sub).unwrap().kind.clone() else { panic!() };
|
||||
assert_eq!((range.start, range.end(), restrict_trims), (r24.tick_of(10), r24.tick_of(20), false));
|
||||
assert_eq!(crate::media_duration(&s.project, &s.media, sub), crate::media_duration(&s.project, &s.media, ocean));
|
||||
assert!(s.execute("clip.editSubclip", json!({"startFrame": 20, "endFrame": 10})).is_err());
|
||||
s.execute("clip.editSubclip", json!({"convertToMaster": true})).unwrap();
|
||||
assert!(s.project.item(sub).unwrap().as_media().is_some(), "now a master clip of the same media");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(matches!(s.project.item(sub).unwrap().kind, ItemKind::Subclip { .. }));
|
||||
// from a timeline clip (nothing in the Source monitor)
|
||||
s.state.source_item = None;
|
||||
s.state.project_selection.clear();
|
||||
let tl = v1(&s)[1].clone();
|
||||
select(&mut s, &[tl.id.0]);
|
||||
let r = s.execute("clip.makeSubclip", json!({"name": "Shot 2", "restrictTrims": false})).unwrap();
|
||||
let ItemKind::Subclip { parent, range, .. } = s.project.item(ItemId(r["item"].as_u64().unwrap())).unwrap().kind.clone() else { panic!() };
|
||||
assert_eq!((parent, range.start, range.end()), (tl.item, tl.source_in, tl.source_out()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_audio_channels_and_breakout_to_mono() {
|
||||
let mut s = demo();
|
||||
let ocean = item_named(&s, "Ocean_Sunset.mp4");
|
||||
s.execute("project.select", json!({"items": [ocean.0]})).unwrap();
|
||||
let r = s.execute("clip.audioChannels", json!({"format": "mono"})).unwrap();
|
||||
assert_eq!(r["items"][0]["clips"], json!([[0], [1]]));
|
||||
let map = s.project.item(ocean).unwrap().as_media().unwrap().interpret.audio_channels.clone().unwrap();
|
||||
assert_eq!(map.format, AudioChannels::Mono);
|
||||
// editing it in makes one mono clip per channel on A1 and A2
|
||||
s.execute("sequence.addTracks", json!({"video": 0, "audio": 1})).unwrap();
|
||||
let r = s.execute("timeline.place", json!({"item": ocean.0, "track": "V3", "audioTrack": "A3", "seconds": 40.0})).unwrap();
|
||||
let ids: Vec<u64> = r["clips"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
||||
let q = s.active_sequence().unwrap();
|
||||
let a3 = q.audio_tracks[2].items.iter().find(|i| ids.contains(&i.id.0)).unwrap();
|
||||
let a4 = q.audio_tracks[3].items.iter().find(|i| ids.contains(&i.id.0)).unwrap();
|
||||
assert_eq!((a3.source_channels.clone(), a4.source_channels.clone()), (vec![0], vec![1]));
|
||||
assert_eq!(a3.link, a4.link);
|
||||
// bad channel lists are refused; custom map with two channels in one clip
|
||||
assert!(s.execute("clip.audioChannels", json!({"format": "stereo", "clips": [[0, 5]]})).is_err());
|
||||
s.execute("clip.audioChannels", json!({"format": "stereo", "clips": [[1, 0]]})).unwrap();
|
||||
assert_eq!(s.project.item(ocean).unwrap().as_media().unwrap().interpret.audio_channels.as_ref().unwrap().clips, vec![vec![1, 0]]);
|
||||
s.execute("clip.audioChannels", json!({"format": "stereo"})).unwrap();
|
||||
assert!(s.project.item(ocean).unwrap().as_media().unwrap().interpret.audio_channels.is_none(), "plain stereo is the default");
|
||||
// timeline clips: pick the channel a clip plays
|
||||
s.state.project_selection.clear();
|
||||
let a1 = s.active_sequence().unwrap().audio_tracks[0].items[0].id;
|
||||
s.state.selection = vec![a1];
|
||||
s.execute("clip.audioChannels", json!({"channels": [1]})).unwrap();
|
||||
assert_eq!(clip(&s, a1.0).source_channels, vec![1]);
|
||||
// Breakout to Mono
|
||||
s.execute("project.select", json!({"items": [ocean.0]})).unwrap();
|
||||
let r = s.execute("clip.breakoutToMono", json!({})).unwrap();
|
||||
let made: Vec<ItemId> = r["items"].as_array().unwrap().iter().map(|v| ItemId(v.as_u64().unwrap())).collect();
|
||||
assert_eq!(made.len(), 2);
|
||||
let names: Vec<&str> = made.iter().map(|i| s.project.item(*i).unwrap().name.as_str()).collect();
|
||||
assert_eq!(names, ["Ocean_Sunset.mp4 Left", "Ocean_Sunset.mp4 Right"]);
|
||||
let right = s.project.item(made[1]).unwrap().as_media().unwrap().interpret.audio_channels.clone().unwrap();
|
||||
assert_eq!(right.clips, vec![vec![1]]);
|
||||
s.state.project_selection = vec![item_named(&s, "Bars and Tone")];
|
||||
s.project = std::sync::Arc::new({
|
||||
let mut p = (*s.project).clone();
|
||||
if let Some(m) = p.item_mut(item_named(&s, "Bars and Tone")).and_then(|i| i.as_media_mut()) {
|
||||
m.info.audio = None;
|
||||
}
|
||||
p
|
||||
});
|
||||
assert!(s.execute("clip.breakoutToMono", json!({})).is_err(), "no audio");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_timecode_and_reset() {
|
||||
let mut s = demo();
|
||||
let ocean = item_named(&s, "Ocean_Sunset.mp4");
|
||||
s.execute("project.select", json!({"items": [ocean.0]})).unwrap();
|
||||
let r = s.execute("clip.modifyTimecode", json!({"timecode": "01:00:00:00", "tapeName": "Reel 1"})).unwrap();
|
||||
let m = s.project.item(ocean).unwrap();
|
||||
let rate = m.as_media().unwrap().frame_rate();
|
||||
assert_eq!(r["startTimecode"], 3600 * rate.timecode_base());
|
||||
assert_eq!(m.metadata["Tape Name"], "Reel 1");
|
||||
s.execute("clip.modifyTimecode", json!({"reset": true})).unwrap();
|
||||
assert_eq!(s.project.item(ocean).unwrap().as_media().unwrap().info.start_timecode, None);
|
||||
assert!(s.execute("clip.modifyTimecode", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_hold_options_add_frame_hold_and_hold_segment() {
|
||||
let mut s = demo();
|
||||
let rate = s.sequence_rate();
|
||||
let c = v1(&s)[1].clone();
|
||||
select(&mut s, &[c.id.0]);
|
||||
// Hold on In point
|
||||
s.execute("clip.frameHoldOptions", json!({"holdOn": "in"})).unwrap();
|
||||
assert_eq!(clip(&s, c.id.0).frame_hold, Some(c.source_in));
|
||||
// Out point / playhead / source timecode / sequence time
|
||||
s.execute("clip.frameHoldOptions", json!({"holdOn": "out", "holdFilters": true})).unwrap();
|
||||
let got = clip(&s, c.id.0);
|
||||
assert_eq!(got.frame_hold, Some(rate.snap(c.source_out() - Tick(1))));
|
||||
assert!(got.hold_filters);
|
||||
s.execute("playhead.set", json!({"time": (c.start + rate.tick_of(10)).0})).unwrap();
|
||||
s.execute("clip.frameHoldOptions", json!({"holdOn": "playhead"})).unwrap();
|
||||
assert_eq!(clip(&s, c.id.0).frame_hold, Some(rate.snap(c.source_in + rate.tick_of(10))));
|
||||
s.execute("clip.frameHoldOptions", json!({"holdOn": "sourceTimecode", "timecode": "00:00:03:00"})).unwrap();
|
||||
assert_eq!(clip(&s, c.id.0).frame_hold, Some(rate.tick_of(72)));
|
||||
s.execute("clip.frameHoldOptions", json!({"holdOn": "sequenceTime", "time": (c.start + rate.tick_of(5)).0})).unwrap();
|
||||
assert_eq!(clip(&s, c.id.0).frame_hold, Some(rate.snap(c.source_in + rate.tick_of(5))));
|
||||
assert!(s.execute("clip.frameHoldOptions", json!({"holdOn": "nowhere"})).is_err());
|
||||
s.execute("clip.frameHoldOptions", json!({"enabled": false})).unwrap();
|
||||
assert_eq!(clip(&s, c.id.0).frame_hold, None);
|
||||
// Hold Filters: effects animate through a hold unless it is on
|
||||
let mut held = clip(&s, c.id.0);
|
||||
held.frame_hold = Some(held.source_in);
|
||||
let late = held.start + rate.tick_of(20);
|
||||
assert_eq!(held.source_time_at(late), held.source_in);
|
||||
assert_eq!(held.effect_time_at(late), held.moving_source_time_at(late));
|
||||
held.hold_filters = true;
|
||||
assert_eq!(held.effect_time_at(late), held.source_in);
|
||||
|
||||
// Add Frame Hold splits at the playhead and holds the rest of the clip
|
||||
let n = v1(&s).len();
|
||||
let t = c.start + rate.tick_of(12);
|
||||
s.execute("playhead.set", json!({"time": t.0})).unwrap();
|
||||
select(&mut s, &[c.id.0]);
|
||||
let r = s.execute("clip.frameHold", json!({})).unwrap();
|
||||
assert_eq!(v1(&s).len(), n + 1);
|
||||
let right = clip(&s, r["clips"][0].as_u64().unwrap());
|
||||
assert_eq!((right.start, right.frame_hold), (t, Some(c.source_in + rate.tick_of(12))));
|
||||
assert_eq!(clip(&s, c.id.0).frame_hold, None, "the left part plays normally");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(v1(&s).len(), n);
|
||||
// Insert Frame Hold Segment: a 2 s hold inserted at the playhead, rippling the sequence
|
||||
let dur0 = s.active_sequence().unwrap().duration();
|
||||
let r = s.execute("clip.insertFrameHoldSegment", json!({})).unwrap();
|
||||
let seg = clip(&s, r["clip"].as_u64().unwrap());
|
||||
assert_eq!((seg.start, seg.duration, seg.frame_hold), (t, rate.snap_nearest(Tick(2 * TICKS_PER_SECOND)), Some(c.source_in + rate.tick_of(12))));
|
||||
assert_eq!(s.active_sequence().unwrap().duration(), dur0 + seg.duration);
|
||||
assert_eq!(v1(&s).len(), n + 2, "split + segment");
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
s.execute("playhead.set", json!({"seconds": 999.0})).unwrap();
|
||||
s.state.selection.clear();
|
||||
assert!(s.execute("clip.insertFrameHoldSegment", json!({})).is_err(), "no clip under the playhead");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_options_and_time_interpolation_are_stored() {
|
||||
let mut s = demo();
|
||||
let c = v1(&s)[0].id.0;
|
||||
select(&mut s, &[c]);
|
||||
s.execute("clip.fieldOptions", json!({"reverseFieldDominance": true, "processing": "flickerRemoval"})).unwrap();
|
||||
let fo = clip(&s, c).field_options.unwrap();
|
||||
assert!(fo.reverse_field_dominance && fo.processing == FieldProcessing::FlickerRemoval);
|
||||
assert!(s.execute("clip.fieldOptions", json!({"processing": "wobble"})).is_err());
|
||||
s.execute("clip.timeInterpolation.frameBlending", json!({})).unwrap();
|
||||
assert_eq!(clip(&s, c).time_interpolation, TimeInterpolation::FrameBlending);
|
||||
s.execute("clip.setTimeInterpolation", json!({"mode": "opticalFlow"})).unwrap();
|
||||
assert_eq!(clip(&s, c).time_interpolation, TimeInterpolation::OpticalFlow);
|
||||
s.execute("clip.speedDuration", json!({"speed": 50.0, "interpolation": "frameSampling"})).unwrap();
|
||||
assert_eq!(clip(&s, c).time_interpolation, TimeInterpolation::FrameSampling);
|
||||
// survives a save / load
|
||||
let bytes = filmcraft_format::encode(&s.project, true);
|
||||
let back = filmcraft_format::decode(&bytes).unwrap().project;
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
assert_eq!(back.sequence(seq).unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1, &clip(&s, c));
|
||||
}
|
||||
|
||||
fn mean_abs_diff(a: &filmcraft_render::Image, b: &filmcraft_render::Image) -> f32 {
|
||||
a.px.iter().zip(&b.px).map(|(x, y)| (x - y).abs()).sum::<f32>() / a.px.len() as f32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_blending_renders_in_between_frames() {
|
||||
let mut s = demo();
|
||||
let c = v1(&s)[2].clone();
|
||||
select(&mut s, &[c.id.0]);
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
select(&mut s, &[c.id.0]);
|
||||
s.execute("clip.speedDuration", json!({"speed": 50.0})).unwrap();
|
||||
let rate = s.sequence_rate();
|
||||
// an odd frame at 50 % lands half-way between two source frames
|
||||
let t = c.start + rate.tick_of(31);
|
||||
s.execute("playhead.set", json!({"time": t.0})).unwrap();
|
||||
let it = clip(&s, c.id.0);
|
||||
let src_rate = filmcraft_time::FrameRate::FPS_23_976;
|
||||
assert!(filmcraft_render::interpolation_blend(&it, t, src_rate).is_none(), "frame sampling");
|
||||
let sampled = s.render_program(0.125).unwrap();
|
||||
s.execute("clip.timeInterpolation.frameBlending", json!({})).unwrap();
|
||||
let it = clip(&s, c.id.0);
|
||||
let (next, w) = filmcraft_render::interpolation_blend(&it, t, src_rate).unwrap();
|
||||
assert!((w - 0.5).abs() < 0.01, "half-way: {w}");
|
||||
assert_eq!(next, src_rate.tick_of(src_rate.frame_at(it.source_time_at(t)) + 1));
|
||||
let blended = s.render_program(0.125).unwrap();
|
||||
assert!(mean_abs_diff(&sampled, &blended) > 1e-4, "blending changes the frame");
|
||||
// the blend is the average of the two source frames: the next timeline frame samples the later one
|
||||
s.execute("clip.timeInterpolation.frameSampling", json!({})).unwrap();
|
||||
s.execute("playhead.set", json!({"time": (t + rate.frame_duration()).0})).unwrap();
|
||||
let later = s.render_program(0.125).unwrap();
|
||||
let mut avg = sampled.clone();
|
||||
for (a, b) in avg.px.iter_mut().zip(&later.px) {
|
||||
*a = (*a + b) * 0.5;
|
||||
}
|
||||
assert!(mean_abs_diff(&avg, &blended) < mean_abs_diff(&sampled, &blended), "closer to the average than to either frame");
|
||||
// optical flow falls back to blending
|
||||
s.execute("clip.timeInterpolation.opticalFlow", json!({})).unwrap();
|
||||
s.execute("playhead.set", json!({"time": t.0})).unwrap();
|
||||
assert!(mean_abs_diff(&s.render_program(0.125).unwrap(), &blended) < 1e-6);
|
||||
// at 100 % nothing is blended
|
||||
s.execute("clip.speedDuration", json!({"speed": 100.0})).unwrap();
|
||||
assert!(filmcraft_render::interpolation_blend(&clip(&s, c.id.0), t, src_rate).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_and_fill_frame() {
|
||||
let mut s = demo();
|
||||
let r = s.execute("file.newColorMatte", json!({"width": 960, "height": 1080, "color": "#ff0000"})).unwrap();
|
||||
let matte = r["item"].as_u64().unwrap();
|
||||
let r = s.execute("timeline.place", json!({"item": matte, "track": "V3", "seconds": 0.0})).unwrap();
|
||||
let c = r["clips"][0].as_u64().unwrap();
|
||||
select(&mut s, &[c]);
|
||||
s.execute("clip.fillFrame", json!({})).unwrap();
|
||||
assert!((f64_param(&clip(&s, c), "motion", "scale") - 200.0).abs() < 1e-9);
|
||||
s.execute("clip.fitToFrame", json!({})).unwrap();
|
||||
let got = clip(&s, c);
|
||||
assert!((f64_param(&got, "motion", "scale") - 100.0).abs() < 1e-9);
|
||||
assert_eq!(got.effect("motion").unwrap().param("anchor").unwrap().value.as_vec2().unwrap(), filmcraft_geom::Vec2::new(480.0, 540.0));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!((f64_param(&clip(&s, c), "motion", "scale") - 200.0).abs() < 1e-9);
|
||||
s.state.selection.clear();
|
||||
assert!(s.execute("clip.fitToFrame", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_audio_writes_a_wav_and_imports_it() {
|
||||
let mut s = demo();
|
||||
let dir = tmp_dir("extract-audio");
|
||||
let forest = item_named(&s, "Misty_Forest.mp4");
|
||||
s.execute("project.select", json!({"items": [forest.0]})).unwrap();
|
||||
// unsaved project, generator media: there is nowhere to put the file
|
||||
assert!(s.execute("clip.extractAudio", json!({})).is_err());
|
||||
s.execute("file.saveAs", json!({"path": dir.join("x.fcproj").to_string_lossy()})).unwrap();
|
||||
let n0 = s.history.undo.len();
|
||||
let r = s.execute("clip.extractAudio", json!({})).unwrap();
|
||||
let e = &r["extracted"][0];
|
||||
let path = e["path"].as_str().unwrap();
|
||||
assert!(path.ends_with("Misty_Forest Audio Extracted.wav"), "{path}");
|
||||
assert!(std::path::Path::new(path).exists());
|
||||
let item = s.project.item(ItemId(e["item"].as_u64().unwrap())).unwrap();
|
||||
let m = item.as_media().unwrap();
|
||||
assert!(m.info.video.is_none());
|
||||
assert_eq!(m.info.audio.as_ref().unwrap().channels, 2);
|
||||
let src_dur = s.project.item(forest).unwrap().duration();
|
||||
assert!((m.info.duration - src_dur).0.abs() < TICKS_PER_SECOND / 100, "same length");
|
||||
assert_eq!(s.project.root.parent_of(item.id), s.project.root.parent_of(forest));
|
||||
assert_eq!(s.history.undo.len(), n0 + 1);
|
||||
// again: a new name, not an overwrite
|
||||
let r = s.execute("clip.extractAudio", json!({"items": [forest.0]})).unwrap();
|
||||
assert!(r["extracted"][0]["path"].as_str().unwrap().ends_with("Audio Extracted 2.wav"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_with_clip_from_source_match_frame_and_bin() {
|
||||
let mut s = demo();
|
||||
let rate = s.sequence_rate();
|
||||
let c = v1(&s)[3].clone();
|
||||
let forest = item_named(&s, "Misty_Forest.mp4");
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
select(&mut s, &[c.id.0]);
|
||||
assert!(s.execute("clip.replaceFromSource", json!({})).is_err(), "nothing in the Source monitor");
|
||||
s.execute("source.open", json!({"item": forest.0})).unwrap();
|
||||
s.execute("project.setMarks", json!({"item": forest.0, "in": rate.tick_of(10).0})).unwrap();
|
||||
s.execute("clip.replaceFromSource", json!({})).unwrap();
|
||||
let got = clip(&s, c.id.0);
|
||||
assert_eq!((got.item, got.name.as_str(), got.source_in, got.duration), (forest, "Misty_Forest.mp4", rate.tick_of(10), c.duration));
|
||||
assert_eq!(got.effects, c.effects, "effects stay with the timeline clip");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// Match Frame: the Source monitor frame lands on the sequence playhead
|
||||
s.execute("playhead.set", json!({"time": (c.start + rate.tick_of(6)).0})).unwrap();
|
||||
s.execute("source.setPlayhead", json!({"frame": 50})).unwrap();
|
||||
s.execute("clip.replaceFromSourceMatchFrame", json!({})).unwrap();
|
||||
let got = clip(&s, c.id.0);
|
||||
assert_eq!(got.source_in, rate.tick_of(44));
|
||||
assert_eq!(got.source_time_at(c.start + rate.tick_of(6)), rate.tick_of(50));
|
||||
s.execute("source.setPlayhead", json!({"frame": 2})).unwrap();
|
||||
assert!(s.execute("clip.replaceFromSourceMatchFrame", json!({})).is_err(), "not enough media before the frame");
|
||||
// From Bin: the Project panel selection
|
||||
let dunes = item_named(&s, "Desert_Dunes.mp4");
|
||||
s.execute("project.select", json!({"items": [dunes.0]})).unwrap();
|
||||
s.execute("clip.replaceFromBin", json!({})).unwrap();
|
||||
assert_eq!((clip(&s, c.id.0).item, clip(&s, c.id.0).source_in), (dunes, Tick::ZERO));
|
||||
// an audio-only item can't replace a video clip
|
||||
let music = item_named(&s, "Ambient_Score.wav");
|
||||
assert!(s.execute("clip.replaceFromBin", json!({"item": music.0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_source_channels_pick_what_a_clip_plays() {
|
||||
let mut it = v1(&demo())[0].clone();
|
||||
assert_eq!(filmcraft_render::audio::source_pair(&it, 2), (0, 1));
|
||||
assert_eq!(filmcraft_render::audio::source_pair(&it, 1), (0, 0));
|
||||
it.source_channels = vec![1];
|
||||
assert_eq!(filmcraft_render::audio::source_pair(&it, 2), (1, 1));
|
||||
it.source_channels = vec![1, 0];
|
||||
assert_eq!(filmcraft_render::audio::source_pair(&it, 2), (1, 0));
|
||||
it.source_channels = vec![5];
|
||||
assert_eq!(filmcraft_render::audio::source_pair(&it, 2), (0, 0), "missing channels fall back to the first");
|
||||
}
|
||||
|
|
@ -277,7 +277,7 @@ fn new_generator(s: &mut Session, g: Generator, name: &str, label: Label, p: &Va
|
|||
}
|
||||
|
||||
/// Place a project item on the timeline (drag from Project, or Insert/Overwrite from source).
|
||||
fn place_item(
|
||||
pub(crate) fn place_item(
|
||||
s: &mut Session,
|
||||
item: ItemId,
|
||||
range: TimeRange,
|
||||
|
|
@ -318,10 +318,27 @@ fn place_item(
|
|||
placements.push((vdest.expect("checked"), v));
|
||||
}
|
||||
if has_a {
|
||||
let adest = adest.expect("checked");
|
||||
let (arange, aat) = audio_split.unwrap_or((range, at));
|
||||
let mut a = p.make_track_item(item, TrackKind::Audio, aat, arange, rate).ok_or_else(|| bad(label, "bad item"))?;
|
||||
a.link = link;
|
||||
placements.push((adest.expect("checked"), a));
|
||||
// Modify ▸ Audio Channels with several audio clips: one per clip, on the tracks below
|
||||
let extra: Vec<Vec<u16>> = p
|
||||
.item(item)
|
||||
.and_then(|i| i.as_media())
|
||||
.and_then(|m| m.interpret.audio_channels.as_ref())
|
||||
.map(|m| m.clips.iter().skip(1).cloned().collect())
|
||||
.unwrap_or_default();
|
||||
let tracks: Vec<TrackId> = p.sequence(seq_id).map(|q| q.audio_tracks.iter().map(|t| t.id).collect()).unwrap_or_default();
|
||||
let first = tracks.iter().position(|t| *t == adest).unwrap_or(0);
|
||||
a.link = if link.is_none() && !extra.is_empty() { Some(p.alloc_id()) } else { link };
|
||||
placements.push((adest, a.clone()));
|
||||
for (k, chans) in extra.into_iter().enumerate() {
|
||||
let Some(tid) = tracks.get(first + k + 1) else { break };
|
||||
let mut b = a.clone();
|
||||
b.id = ClipId(p.alloc_id());
|
||||
b.source_channels = chans;
|
||||
placements.push((*tid, b));
|
||||
}
|
||||
}
|
||||
let snapshot = p.clone();
|
||||
let durations = move |id: ItemId| crate::media_duration(&snapshot, &media, id);
|
||||
|
|
@ -343,7 +360,7 @@ fn place_item(
|
|||
})
|
||||
}
|
||||
|
||||
fn source_range(s: &Session) -> Option<(ItemId, TimeRange)> {
|
||||
pub(crate) fn source_range(s: &Session) -> Option<(ItemId, TimeRange)> {
|
||||
let item = s.state.source_item?;
|
||||
let pi = s.project.item(item)?;
|
||||
let dur = match &pi.kind {
|
||||
|
|
@ -355,6 +372,10 @@ fn source_range(s: &Session) -> Option<(ItemId, TimeRange)> {
|
|||
ItemKind::Sequence(q) => (q.mark_in, q.mark_out),
|
||||
_ => (None, None),
|
||||
};
|
||||
// a subclip shows its range of the parent's media (its clips use the parent's media time)
|
||||
if let ItemKind::Subclip { range, .. } = &pi.kind {
|
||||
return Some((item, *range));
|
||||
}
|
||||
let start = mi.unwrap_or(Tick::ZERO);
|
||||
let rate = pi.frame_rate();
|
||||
let end = mo.map(|o| o + rate.frame_duration()).unwrap_or(dur).min(if dur.0 > 0 { dur } else { Tick::MAX });
|
||||
|
|
@ -821,7 +842,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
})?;
|
||||
Ok(json!({"items": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}),
|
||||
cmd!("edit.label", "Label", ["Edit"], None, r#"{"label":"Violet|Iris|…"}"#, always, |s, p| {
|
||||
cmd!("edit.label", "Label", [], None, r#"{"label":"Violet|Iris|…"}"#, always, |s, p| {
|
||||
let l = str_p(p, "label").and_then(Label::from_name).ok_or_else(|| bad("edit.label", "unknown label"))?;
|
||||
let clips = s.state.selection.clone();
|
||||
let items = s.state.project_selection.clone();
|
||||
|
|
@ -864,16 +885,23 @@ fn build() -> Vec<CommandSpec> {
|
|||
"Speed/Duration…",
|
||||
["Clip"],
|
||||
Some("Cmd+R"),
|
||||
r#"{"clips":[id]?,"speed":percent=100,"reverse":bool,"ripple":bool}"#,
|
||||
r#"{"clips":[id]?,"speed":percent=100,"reverse":bool,"ripple":bool,"interpolation":"frameSampling|frameBlending|opticalFlow"?}"#,
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let speed = f64_p(p, "speed").unwrap_or(100.0) / 100.0;
|
||||
let reverse = bool_p(p, "reverse").unwrap_or(false);
|
||||
let ripple = bool_p(p, "ripple").unwrap_or(false);
|
||||
let interp = match str_p(p, "interpolation") {
|
||||
Some(m) => Some(filmcraft_project::TimeInterpolation::from_name(m).ok_or_else(|| bad("clip.speedDuration", "unknown interpolation"))?),
|
||||
None => None,
|
||||
};
|
||||
let sel = with_links(s, &clips_p(s, p));
|
||||
s.edit_sequence("Speed/Duration", |q, ctx, _| {
|
||||
for c in &sel {
|
||||
edit::set_speed(q, *c, speed, reverse, ripple, ctx)?;
|
||||
if let (Some(m), Some((_, it))) = (interp, q.find_item_mut(*c)) {
|
||||
it.time_interpolation = m;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
|
@ -947,25 +975,15 @@ fn build() -> Vec<CommandSpec> {
|
|||
cmd!(
|
||||
"clip.audioGain",
|
||||
"Audio Gain…",
|
||||
["Clip"],
|
||||
["Clip", "Audio Options"],
|
||||
Some("G"),
|
||||
r#"{"mode":"set|adjust|normalizeMax|normalizeAll"?,"db":f64,"relative":bool?}"#,
|
||||
has_selection,
|
||||
|s, p| crate::mixer::audio_gain(s, p)
|
||||
),
|
||||
query!("clip.audioPeak", "Audio Clip Peak Amplitude", r#"{"clips":[id]?}"#, |s, p| crate::mixer::audio_peak(s, p)),
|
||||
cmd!("clip.frameHold", "Add Frame Hold", ["Clip", "Video Options"], None, "{}", has_selection, |s, _| {
|
||||
let t = s.playhead();
|
||||
let sel = s.state.selection.clone();
|
||||
s.edit_sequence("Add Frame Hold", |q, _, _| {
|
||||
for c in &sel {
|
||||
if let Some((_, i)) = q.find_item_mut(*c) {
|
||||
i.frame_hold = Some(i.source_time_at(t.clamp(i.start, i.end() - Tick(1))));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
cmd!("clip.frameHold", "Add Frame Hold", ["Clip", "Video Options"], None, r#"{"clips":[id]?,"time":ticks?}"#, has_seq, |s, p| {
|
||||
crate::clip_ops::add_frame_hold(s, p)
|
||||
}),
|
||||
cmd!("clip.nest", "Nest…", ["Clip"], None, r#"{"name":str}"#, has_selection, |s, p| nest(s, p)),
|
||||
// ================= Sequence =================
|
||||
|
|
@ -1390,6 +1408,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
};
|
||||
let (mi, mo) = match &pi.kind {
|
||||
ItemKind::Media(m) => (m.mark_in, m.mark_out.map(|o| o + pi.frame_rate().frame_duration())),
|
||||
ItemKind::Subclip { range, .. } => (Some(range.start), Some(range.end())),
|
||||
_ => (None, None),
|
||||
};
|
||||
let sin = p.get("sourceIn").and_then(Value::as_i64).map(Tick).or(mi).unwrap_or_default();
|
||||
|
|
@ -1997,10 +2016,9 @@ fn build() -> Vec<CommandSpec> {
|
|||
v.extend(crate::masks::commands());
|
||||
v.extend(crate::presets::commands());
|
||||
v.extend(crate::transcript::commands());
|
||||
// Labels as individual commands (Edit ▸ Label ▸ <name>)
|
||||
for l in Label::ALL {
|
||||
let _ = l;
|
||||
}
|
||||
// Edit ▸ Label ▸ <colour>, Paste Attributes, subclips, Video / Audio Options, Replace With Clip…
|
||||
// and their menu order
|
||||
crate::clip_ops::apply_layout(&mut v);
|
||||
v.shrink_to_fit();
|
||||
v
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
pub mod autosave;
|
||||
pub mod captions;
|
||||
pub mod clip_ops;
|
||||
pub mod color;
|
||||
pub mod commands;
|
||||
pub mod demo;
|
||||
|
|
@ -714,7 +715,9 @@ pub fn media_duration(p: &Project, _pool: &MediaPool, id: ItemId) -> Option<Tick
|
|||
_ => Some(m.info.duration),
|
||||
},
|
||||
filmcraft_project::ItemKind::Sequence(s) => Some(s.duration()),
|
||||
filmcraft_project::ItemKind::Subclip { range, .. } => Some(range.end()),
|
||||
// a subclip that restricts trims ends at its Out point; otherwise its parent's media is the limit
|
||||
filmcraft_project::ItemKind::Subclip { range, restrict_trims: true, .. } => Some(range.end()),
|
||||
filmcraft_project::ItemKind::Subclip { parent, .. } => media_duration(p, _pool, *parent),
|
||||
filmcraft_project::ItemKind::AdjustmentLayer { .. } | filmcraft_project::ItemKind::Graphic { .. } => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -722,6 +725,8 @@ pub fn media_duration(p: &Project, _pool: &MediaPool, id: ItemId) -> Option<Tick
|
|||
#[cfg(test)]
|
||||
mod autosave_tests;
|
||||
#[cfg(test)]
|
||||
mod clip_ops_tests;
|
||||
#[cfg(test)]
|
||||
mod color_tests;
|
||||
#[cfg(test)]
|
||||
mod essential_sound_tests;
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ fn media_of(p: &Project, item: ItemId) -> Option<(ItemId, &filmcraft_project::Me
|
|||
let it = p.item(item)?;
|
||||
match &it.kind {
|
||||
ItemKind::Media(m) => Some((item, m, None)),
|
||||
ItemKind::Subclip { parent, range } => media_of(p, *parent).map(|(i, m, _)| (i, m, Some(*range))),
|
||||
ItemKind::Subclip { parent, range, .. } => media_of(p, *parent).map(|(i, m, _)| (i, m, Some(*range))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
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];
|
||||
|
||||
/// The schema version this build writes (and the newest it reads).
|
||||
pub const SCHEMA_VERSION: u32 = MIGRATIONS.len() as u32 + 1;
|
||||
|
|
@ -258,13 +258,19 @@ fn v8_to_v9(doc: Value) -> Result<Value, String> {
|
|||
Ok(doc)
|
||||
}
|
||||
|
||||
/// v9 → v10: clip time interpolation, Hold Filters, Field Options and audio source channels; the
|
||||
/// Modify ▸ Audio Channels map; subclips' Restrict Trims flag. Existing data needs no change.
|
||||
fn v9_to_v10(doc: Value) -> Result<Value, String> {
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn schema_version_matches_table() {
|
||||
assert_eq!(SCHEMA_VERSION, 9);
|
||||
assert_eq!(SCHEMA_VERSION, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -52,6 +52,26 @@ fn v1_edit_loads_with_defaults_for_later_fields() {
|
|||
seq.check().unwrap();
|
||||
}
|
||||
|
||||
/// Schema 8 (before M3.10): no time interpolation / Hold Filters / Field Options / source channels
|
||||
/// on clips, no audio channel map, subclips without Restrict Trims.
|
||||
#[test]
|
||||
fn v8_subclip_loads_with_m3_10_defaults() {
|
||||
let l = decode(&fixture("v8-subclip.fcproj")).unwrap();
|
||||
assert_eq!(l.schema_version, 8);
|
||||
assert!(l.migrated());
|
||||
let p = &l.project;
|
||||
assert_eq!(p.name, "Schema 8 Edit");
|
||||
let ItemKind::Subclip { parent, range, restrict_trims } = &p.item(ItemId(48)).unwrap().kind else { panic!("not a subclip") };
|
||||
assert_eq!((*parent, range.duration.0 > 0, *restrict_trims), (ItemId(5), true, false));
|
||||
assert!(p.item(ItemId(5)).unwrap().as_media().unwrap().interpret.audio_channels.is_none());
|
||||
let clip = &p.sequence(ItemId(21)).unwrap().video_tracks[0].items[0];
|
||||
assert!(clip.time_interpolation.is_default() && !clip.hold_filters && clip.field_options.is_none() && clip.source_channels.is_empty());
|
||||
// resaved in the current schema, losslessly
|
||||
let again = decode(&encode(p, true)).unwrap();
|
||||
assert_eq!(again.schema_version, SCHEMA_VERSION);
|
||||
assert_eq!(&again.project, p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgraded_file_resaves_in_current_schema_losslessly() {
|
||||
let old = decode(&fixture("v1-edit.fcproj")).unwrap().project;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,347 @@
|
|||
{
|
||||
"format": "filmcraft.project",
|
||||
"schema_version": 8,
|
||||
"generator": "FilmCraft 0.1.0",
|
||||
"project": {
|
||||
"name": "Schema 8 Edit",
|
||||
"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
|
||||
},
|
||||
"root": {
|
||||
"id": 0,
|
||||
"name": "Schema 8 Edit",
|
||||
"children": [
|
||||
{
|
||||
"Bin": {
|
||||
"id": 1,
|
||||
"name": "Footage",
|
||||
"children": [
|
||||
{
|
||||
"Item": 5
|
||||
},
|
||||
{
|
||||
"Item": 48
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Item": 21
|
||||
}
|
||||
]
|
||||
},
|
||||
"items": {
|
||||
"5": {
|
||||
"id": 5,
|
||||
"name": "Ocean_Sunset.mp4",
|
||||
"label": "Iris",
|
||||
"kind": {
|
||||
"Media": {
|
||||
"media": {
|
||||
"Generator": {
|
||||
"Demo": "OceanSunset"
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"name": "Ocean_Sunset.mp4",
|
||||
"kind": "Movie",
|
||||
"duration": 4572288000000,
|
||||
"video": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"frame_rate": {
|
||||
"num": 24000,
|
||||
"den": 1001
|
||||
},
|
||||
"par": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"codec": "H.264 (synthetic)",
|
||||
"pixel_format": "RGBA 8-bit",
|
||||
"color": {
|
||||
"matrix": "Bt709",
|
||||
"transfer": "Srgb",
|
||||
"primaries": "Bt709",
|
||||
"range": "Full"
|
||||
},
|
||||
"has_alpha": false,
|
||||
"bitrate": null
|
||||
},
|
||||
"audio": {
|
||||
"sample_rate": 48000,
|
||||
"channels": 2,
|
||||
"codec": "PCM (synthetic)",
|
||||
"bits_per_sample": 32
|
||||
},
|
||||
"container": "Synthetic MPEG-4",
|
||||
"start_timecode": null,
|
||||
"file_size": null
|
||||
},
|
||||
"interpret": {
|
||||
"frame_rate": null,
|
||||
"par": null,
|
||||
"ignore_alpha": false,
|
||||
"invert_alpha": false
|
||||
},
|
||||
"mark_in": null,
|
||||
"mark_out": null,
|
||||
"markers": [],
|
||||
"offline": false,
|
||||
"proxy": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"21": {
|
||||
"id": 21,
|
||||
"name": "Assembly",
|
||||
"label": "Forest",
|
||||
"kind": {
|
||||
"Sequence": {
|
||||
"settings": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"frame_rate": {
|
||||
"num": 24000,
|
||||
"den": 1001
|
||||
},
|
||||
"par": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"sample_rate": 48000,
|
||||
"drop_frame": false,
|
||||
"preset": "HD 1080p 23.976",
|
||||
"audio_master": "Stereo",
|
||||
"preview_codec": "ProRes 422",
|
||||
"max_bit_depth": false,
|
||||
"max_render_quality": false,
|
||||
"working_space": "Rec. 709"
|
||||
},
|
||||
"video_tracks": [
|
||||
{
|
||||
"id": 15,
|
||||
"kind": "Video",
|
||||
"name": "Video 1",
|
||||
"items": [
|
||||
{
|
||||
"id": 29,
|
||||
"item": 5,
|
||||
"name": "Ocean_Sunset.mp4",
|
||||
"label": "Iris",
|
||||
"start": 0,
|
||||
"duration": 1260755496000,
|
||||
"source_in": 243675432000,
|
||||
"speed": 1.0,
|
||||
"enabled": true,
|
||||
"effects": [
|
||||
{
|
||||
"effect": "motion",
|
||||
"enabled": true,
|
||||
"params": {
|
||||
"anchor": {
|
||||
"value": {
|
||||
"Vec2": {
|
||||
"x": 960.0,
|
||||
"y": 540.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"anti_flicker": {
|
||||
"value": {
|
||||
"Float": 0.0
|
||||
}
|
||||
},
|
||||
"position": {
|
||||
"value": {
|
||||
"Vec2": {
|
||||
"x": 960.0,
|
||||
"y": 540.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"rotation": {
|
||||
"value": {
|
||||
"Float": 0.0
|
||||
}
|
||||
},
|
||||
"scale": {
|
||||
"value": {
|
||||
"Float": 100.0
|
||||
}
|
||||
},
|
||||
"scale_width": {
|
||||
"value": {
|
||||
"Float": 100.0
|
||||
}
|
||||
},
|
||||
"uniform_scale": {
|
||||
"value": {
|
||||
"Bool": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"effect": "opacity",
|
||||
"enabled": true,
|
||||
"params": {
|
||||
"blend": {
|
||||
"value": {
|
||||
"Choice": 0
|
||||
}
|
||||
},
|
||||
"opacity": {
|
||||
"value": {
|
||||
"Float": 100.0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"effect": "time_remap",
|
||||
"enabled": true,
|
||||
"params": {
|
||||
"speed": {
|
||||
"value": {
|
||||
"Float": 100.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"transitions": [],
|
||||
"locked": false,
|
||||
"sync_lock": true,
|
||||
"enabled": true,
|
||||
"muted": false,
|
||||
"solo": false,
|
||||
"channels": "Stereo",
|
||||
"volume_db": 0.0,
|
||||
"pan": 0.0
|
||||
}
|
||||
],
|
||||
"audio_tracks": [
|
||||
{
|
||||
"id": 18,
|
||||
"kind": "Audio",
|
||||
"name": "Audio 1",
|
||||
"items": [
|
||||
{
|
||||
"id": 30,
|
||||
"item": 5,
|
||||
"name": "Ocean_Sunset.mp4",
|
||||
"label": "Iris",
|
||||
"start": 0,
|
||||
"duration": 1260755496000,
|
||||
"source_in": 243675432000,
|
||||
"speed": 1.0,
|
||||
"enabled": true,
|
||||
"effects": [
|
||||
{
|
||||
"effect": "volume",
|
||||
"enabled": true,
|
||||
"params": {
|
||||
"bypass": {
|
||||
"value": {
|
||||
"Bool": false
|
||||
}
|
||||
},
|
||||
"level": {
|
||||
"value": {
|
||||
"Float": -8.0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"effect": "channel_volume",
|
||||
"enabled": true,
|
||||
"params": {
|
||||
"bypass": {
|
||||
"value": {
|
||||
"Bool": false
|
||||
}
|
||||
},
|
||||
"left": {
|
||||
"value": {
|
||||
"Float": 0.0
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
"value": {
|
||||
"Float": 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"effect": "panner",
|
||||
"enabled": true,
|
||||
"params": {
|
||||
"balance": {
|
||||
"value": {
|
||||
"Float": 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"transitions": [],
|
||||
"locked": false,
|
||||
"sync_lock": true,
|
||||
"enabled": true,
|
||||
"muted": false,
|
||||
"solo": false,
|
||||
"channels": "Stereo",
|
||||
"volume_db": 0.0,
|
||||
"pan": 0.0
|
||||
}
|
||||
],
|
||||
"markers": [
|
||||
{
|
||||
"id": 800000,
|
||||
"start": 0,
|
||||
"duration": 0,
|
||||
"name": "Open",
|
||||
"comment": "",
|
||||
"kind": "Comment",
|
||||
"color": "Green"
|
||||
}
|
||||
],
|
||||
"mark_in": null,
|
||||
"mark_out": null,
|
||||
"work_area": null,
|
||||
"start_timecode": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"48": {
|
||||
"id": 48,
|
||||
"name": "Ocean_Sunset.mp4.Subclip",
|
||||
"label": "Iris",
|
||||
"kind": {
|
||||
"Subclip": {
|
||||
"parent": 5,
|
||||
"range": {
|
||||
"start": 254270016000,
|
||||
"duration": 508540032000
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {},
|
||||
"created": 48
|
||||
}
|
||||
},
|
||||
"next_id": 49
|
||||
}
|
||||
}
|
||||
|
|
@ -426,6 +426,10 @@ impl Builder {
|
|||
scale_to_frame: false,
|
||||
essential: None,
|
||||
multicam: None,
|
||||
time_interpolation: Default::default(),
|
||||
hold_filters: false,
|
||||
field_options: None,
|
||||
source_channels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -779,6 +779,10 @@ mod tests {
|
|||
scale_to_frame: false,
|
||||
essential: None,
|
||||
multicam: None,
|
||||
time_interpolation: Default::default(),
|
||||
hold_filters: false,
|
||||
field_options: None,
|
||||
source_channels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -164,6 +164,32 @@ pub struct Interpretation {
|
|||
/// Color Management ▸ override the colour space detected from the file's metadata.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color_space: Option<filmcraft_color::ColorSpace>,
|
||||
/// Modify ▸ Audio Channels: how the source channels map to the audio clips made when the item
|
||||
/// is edited into a sequence. None = one stereo clip of the first two channels.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub audio_channels: Option<AudioChannelMap>,
|
||||
}
|
||||
|
||||
/// Modify ▸ Audio Channels: the clip channel format and, per audio clip, the source channels it
|
||||
/// plays (0-based). A mono clip has one channel (played on both sides); a stereo clip two.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AudioChannelMap {
|
||||
pub format: AudioChannels,
|
||||
pub clips: Vec<Vec<u16>>,
|
||||
}
|
||||
|
||||
impl AudioChannelMap {
|
||||
/// The default mapping of a source with `channels` channels in `format`: mono = one clip per
|
||||
/// channel, stereo = channel pairs, 5.1 / adaptive = one clip with every channel.
|
||||
pub fn for_format(format: AudioChannels, channels: u16) -> Self {
|
||||
let n = channels.max(1);
|
||||
let clips = match format {
|
||||
AudioChannels::Mono => (0..n).map(|c| vec![c]).collect(),
|
||||
AudioChannels::Stereo => (0..n).step_by(2).map(|c| if c + 1 < n { vec![c, c + 1] } else { vec![c] }).collect(),
|
||||
AudioChannels::Surround51 | AudioChannels::Adaptive => vec![(0..n).collect()],
|
||||
};
|
||||
Self { format, clips }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -227,6 +253,10 @@ pub enum ItemKind {
|
|||
Subclip {
|
||||
parent: ItemId,
|
||||
range: TimeRange,
|
||||
/// Make Subclip ▸ Restrict Trims To Subclip Boundaries: clips of the subclip can't be
|
||||
/// trimmed past its end (otherwise the parent's media is the limit).
|
||||
#[serde(default)]
|
||||
restrict_trims: bool,
|
||||
},
|
||||
AdjustmentLayer {
|
||||
width: u32,
|
||||
|
|
@ -483,6 +513,74 @@ pub struct TrackItem {
|
|||
/// Multi-camera clip (a nested multi-camera source sequence): enabled flag and angle.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub multicam: Option<MulticamSel>,
|
||||
/// Video Options ▸ Time Interpolation: how frames are made when the clip's speed is changed.
|
||||
#[serde(default, skip_serializing_if = "TimeInterpolation::is_default")]
|
||||
pub time_interpolation: TimeInterpolation,
|
||||
/// Frame Hold Options ▸ Hold Filters: effects are evaluated at the held frame's time instead
|
||||
/// of animating through the hold.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub hold_filters: bool,
|
||||
/// Video Options ▸ Field Options… (stored; FilmCraft renders progressive frames).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub field_options: Option<FieldOptions>,
|
||||
/// Audio clips: the source channels this clip plays (0-based; one = mono on both sides, two =
|
||||
/// left/right). Empty = the first two channels (mono sources on both sides).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub source_channels: Vec<u16>,
|
||||
}
|
||||
|
||||
/// Time interpolation for speed-changed clips (Clip ▸ Video Options ▸ Time Interpolation).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TimeInterpolation {
|
||||
/// Show the nearest earlier source frame (repeats or drops frames).
|
||||
#[default]
|
||||
FrameSampling,
|
||||
/// Cross-fade the two source frames around the exact media time.
|
||||
FrameBlending,
|
||||
/// Motion-compensated in-between frames. Rendered as frame blending for now.
|
||||
OpticalFlow,
|
||||
}
|
||||
|
||||
impl TimeInterpolation {
|
||||
pub const ALL: [TimeInterpolation; 3] = [TimeInterpolation::FrameSampling, TimeInterpolation::FrameBlending, TimeInterpolation::OpticalFlow];
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == TimeInterpolation::FrameSampling
|
||||
}
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
TimeInterpolation::FrameSampling => "frameSampling",
|
||||
TimeInterpolation::FrameBlending => "frameBlending",
|
||||
TimeInterpolation::OpticalFlow => "opticalFlow",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TimeInterpolation::FrameSampling => "Frame Sampling",
|
||||
TimeInterpolation::FrameBlending => "Frame Blending",
|
||||
TimeInterpolation::OpticalFlow => "Optical Flow",
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|t| t.name().eq_ignore_ascii_case(s) || t.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Field Options… (stored with the clip).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FieldOptions {
|
||||
pub reverse_field_dominance: bool,
|
||||
pub processing: FieldProcessing,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FieldProcessing {
|
||||
#[default]
|
||||
None,
|
||||
AlwaysDeinterlace,
|
||||
FlickerRemoval,
|
||||
}
|
||||
|
||||
impl TrackItem {
|
||||
|
|
@ -497,6 +595,19 @@ impl TrackItem {
|
|||
if let Some(h) = self.frame_hold {
|
||||
return h;
|
||||
}
|
||||
self.moving_source_time_at(t)
|
||||
}
|
||||
/// Media time at which effects and intrinsic attributes are evaluated at timeline time `t`: a
|
||||
/// frame hold freezes them only with Hold Filters on; otherwise they animate through the hold.
|
||||
pub fn effect_time_at(&self, t: Tick) -> Tick {
|
||||
match self.frame_hold {
|
||||
Some(h) if self.hold_filters => h,
|
||||
Some(_) => self.moving_source_time_at(t),
|
||||
None => self.source_time_at(t),
|
||||
}
|
||||
}
|
||||
/// Media time at timeline time `t` ignoring any frame hold.
|
||||
pub fn moving_source_time_at(&self, t: Tick) -> Tick {
|
||||
let rel = t - self.start;
|
||||
let scaled = Tick((rel.0 as f64 * self.speed.abs()).round() as i64);
|
||||
if self.reverse {
|
||||
|
|
@ -1059,7 +1170,7 @@ impl Project {
|
|||
}
|
||||
let mut users = vec![item];
|
||||
for it in self.items.values_mut() {
|
||||
if let ItemKind::Subclip { parent, range } = &mut it.kind
|
||||
if let ItemKind::Subclip { parent, range, .. } = &mut it.kind
|
||||
&& *parent == item
|
||||
{
|
||||
range.start -= delta;
|
||||
|
|
@ -1104,6 +1215,10 @@ impl Project {
|
|||
// a multi-camera source sequence edits in as a multi-camera clip showing its first angle
|
||||
let multicam =
|
||||
it.as_sequence().and_then(|q| q.multicam.as_ref()).map(|m| MulticamSel { enabled: true, angle: m.first_video_angle().unwrap_or(0) as u32 });
|
||||
let source_channels = match (kind, &it.kind) {
|
||||
(TrackKind::Audio, ItemKind::Media(m)) => m.interpret.audio_channels.as_ref().and_then(|a| a.clips.first().cloned()).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let id = ClipId(self.alloc_id());
|
||||
let dur = seq_rate.snap_nearest(source.duration).max(seq_rate.frame_duration());
|
||||
Some(TrackItem {
|
||||
|
|
@ -1126,6 +1241,10 @@ impl Project {
|
|||
scale_to_frame: false,
|
||||
essential: None,
|
||||
multicam,
|
||||
time_interpolation: TimeInterpolation::default(),
|
||||
hold_filters: false,
|
||||
field_options: None,
|
||||
source_channels,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -209,15 +209,27 @@ fn raw_stereo(item: &TrackItem, src: &dyn filmcraft_media::MediaSource, x0: i64,
|
|||
})
|
||||
};
|
||||
let Some(buf) = buf else { return out };
|
||||
let right = if buf.channel_count() >= 2 { 1 } else { 0 };
|
||||
let (left, right) = source_pair(item, buf.channel_count());
|
||||
for (c, dst) in out.iter_mut().enumerate() {
|
||||
let ch = &buf.channels[if c == 0 { 0 } else { right }];
|
||||
let ch = &buf.channels[if c == 0 { left } else { right }];
|
||||
let m = n.min(ch.len());
|
||||
dst[off..off + m].copy_from_slice(&ch[..m]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The source channels (left, right) an audio clip plays: its `source_channels` (one = mono on
|
||||
/// both sides; Modify ▸ Audio Channels, Breakout to Mono), else the first two (mono sources on
|
||||
/// both sides). Channels the source doesn't have fall back to the first.
|
||||
pub fn source_pair(item: &TrackItem, available: usize) -> (usize, usize) {
|
||||
let ok = |c: u16| if (c as usize) < available { c as usize } else { 0 };
|
||||
match item.source_channels.as_slice() {
|
||||
[] => (0, if available >= 2 { 1 } else { 0 }),
|
||||
[m] => (ok(*m), ok(*m)),
|
||||
[l, r, ..] => (ok(*l), ok(*r)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Peak (min, max) pairs per bucket of `bucket` samples for channel `ch` — waveform display.
|
||||
pub fn peaks(buf: &AudioBuffer, ch: usize, bucket: usize) -> Vec<(f32, f32)> {
|
||||
let Some(c) = buf.channels.get(ch) else { return Vec::new() };
|
||||
|
|
|
|||
|
|
@ -135,6 +135,17 @@ impl Image {
|
|||
out
|
||||
}
|
||||
|
||||
/// `self × (1 − w) + other × w` (same size; premultiplied, so this is a proper cross-fade).
|
||||
pub fn lerp(mut self, other: &Image, w: f32) -> Image {
|
||||
if other.w != self.w || other.h != self.h {
|
||||
return self;
|
||||
}
|
||||
for (a, b) in self.px.iter_mut().zip(&other.px) {
|
||||
*a += (*b - *a) * w;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn scale_alpha(&mut self, a: f32) {
|
||||
if (a - 1.0).abs() < 1e-6 {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
|
|||
if !opts.effects {
|
||||
continue;
|
||||
}
|
||||
let mt = item.source_time_at(t);
|
||||
let mt = item.effect_time_at(t);
|
||||
let mut adjusted = canvas.clone();
|
||||
let cx = effects::FxCtx {
|
||||
t: mt,
|
||||
|
|
@ -276,7 +276,10 @@ pub(crate) fn item_layer(
|
|||
tc: &str,
|
||||
) -> Option<(Image, f32, Blend)> {
|
||||
let (w, h) = output_size(seq, opts.scale);
|
||||
let mt = item.source_time_at(t);
|
||||
// `ft`: the media time of the frame shown; `mt`: where effects and Motion/Opacity are evaluated
|
||||
// (they differ inside a frame hold without Hold Filters).
|
||||
let ft = item.source_time_at(t);
|
||||
let mt = item.effect_time_at(t);
|
||||
let src_size = source_size(project, item.item)?;
|
||||
let motion = motion_matrix(seq, item, src_size, mt);
|
||||
// How many output pixels one source pixel covers → request a reduced frame when possible.
|
||||
|
|
@ -286,22 +289,32 @@ pub(crate) fn item_layer(
|
|||
let mut layer = match &pi.kind {
|
||||
ItemKind::Media(_) | ItemKind::Subclip { .. } => {
|
||||
let src = sources.source(item.item)?;
|
||||
let frame = src.video_frame(FrameRequest { time: mt, scale: want }).ok()?;
|
||||
let frame = src.video_frame(FrameRequest { time: ft, scale: want }).ok()?;
|
||||
let n = decimation(frame.width as f32, src_size.0 as f32 * want);
|
||||
colorman::decode(project, item.item, &frame, n, &seq.settings.color)
|
||||
let img = colorman::decode(project, item.item, &frame, n, &seq.settings.color);
|
||||
match interpolation_blend(item, t, src.info().frame_rate()) {
|
||||
Some((next_time, wgt)) => match src.video_frame(FrameRequest { time: next_time, scale: want }) {
|
||||
Ok(f2) if f2.width == frame.width && f2.height == frame.height => {
|
||||
let b = colorman::decode(project, item.item, &f2, n, &seq.settings.color);
|
||||
img.lerp(&b, wgt)
|
||||
}
|
||||
_ => img,
|
||||
},
|
||||
None => img,
|
||||
}
|
||||
}
|
||||
ItemKind::Sequence(nested) => {
|
||||
let sub = RenderOptions { scale: want, effects: opts.effects, depth: opts.depth + 1, captions: false, working_output: true };
|
||||
match item.multicam_angle(nested) {
|
||||
// a multi-camera clip shows its angle's track only (nothing for an audio-only angle)
|
||||
Some(angle) => match nested.angle_video_track_index(angle) {
|
||||
Some(ti) => render_seq_tracks(project, nested, mt, sub, sources, Some(ti)),
|
||||
Some(ti) => render_seq_tracks(project, nested, ft, sub, sources, Some(ti)),
|
||||
None => {
|
||||
let (nw, nh) = output_size(nested, want);
|
||||
Image::new(nw, nh)
|
||||
}
|
||||
},
|
||||
None => render_seq(project, nested, mt, sub, sources),
|
||||
None => render_seq(project, nested, ft, sub, sources),
|
||||
}
|
||||
}
|
||||
ItemKind::AdjustmentLayer { .. } => return None,
|
||||
|
|
@ -367,6 +380,33 @@ pub fn render_clip(
|
|||
item_layer(project, seq, item, t, opts, sources, &tc).map(|(img, _, _)| img)
|
||||
}
|
||||
|
||||
/// Frame Blending / Optical Flow on a speed-changed clip: the later source frame to mix in and
|
||||
/// its weight (0..1) at timeline `t`, or None when the exact media time falls on a source frame
|
||||
/// (or the clip plays at 100 %, is frame-held, or uses Frame Sampling).
|
||||
///
|
||||
/// TODO(optical flow): motion-compensated interpolation; Optical Flow renders as Frame Blending.
|
||||
pub fn interpolation_blend(item: &TrackItem, t: Tick, src_rate: filmcraft_time::FrameRate) -> Option<(Tick, f32)> {
|
||||
if item.time_interpolation == filmcraft_project::TimeInterpolation::FrameSampling || item.frame_hold.is_some() {
|
||||
return None;
|
||||
}
|
||||
if (item.speed.abs() - 1.0).abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let mt = item.source_time_at(t);
|
||||
if src_rate.frame_duration().0 <= 0 {
|
||||
return None;
|
||||
}
|
||||
// position in source frames from the media origin
|
||||
let f = src_rate.frame_at(mt);
|
||||
let f0 = src_rate.tick_of(f);
|
||||
let frac = (mt - f0).0 as f64 / (src_rate.tick_of(f + 1) - f0).0.max(1) as f64;
|
||||
if frac <= 1e-6 {
|
||||
return None;
|
||||
}
|
||||
// the mix depends only on the media position, so reversed clips blend the same pair
|
||||
Some((src_rate.tick_of(f + 1), frac.clamp(0.0, 1.0) as f32))
|
||||
}
|
||||
|
||||
/// Largest power-of-two box decimation that keeps at least `target_w` pixels of width.
|
||||
fn decimation(have_w: f32, target_w: f32) -> usize {
|
||||
let mut n = 1usize;
|
||||
|
|
|
|||
|
|
@ -143,7 +143,9 @@ fn push_item(
|
|||
extra_opacity: f32,
|
||||
out: &mut Vec<PlanLayer>,
|
||||
) {
|
||||
let mt = item.source_time_at(t);
|
||||
// frame time (`ft`) vs. effect time (`mt`): they differ inside a frame hold without Hold Filters
|
||||
let ft = item.source_time_at(t);
|
||||
let mt = item.effect_time_at(t);
|
||||
let (op, bl) = crate::opacity_blend(item, mt);
|
||||
// A multi-camera clip that only shows its angle (no effects, untransformed, same frame size)
|
||||
// draws the angle's clip directly: no CPU pass over the nested sequence.
|
||||
|
|
@ -154,10 +156,10 @@ fn push_item(
|
|||
&& (nested.settings.width, nested.settings.height) == (seq.settings.width, seq.settings.height)
|
||||
&& near_identity(&motion_matrix(seq, item, (nested.settings.width, nested.settings.height), mt))
|
||||
&& let Some(tr) = nested.angle_video_track_index(angle).and_then(|i| nested.video_tracks.get(i))
|
||||
&& !tr.transitions.iter().any(|x| x.range().contains(mt))
|
||||
&& !tr.transitions.iter().any(|x| x.range().contains(ft))
|
||||
{
|
||||
if let Some(inner) = tr.item_at(mt).filter(|i| i.enabled) {
|
||||
push_item(project, nested, inner, mt, opts, sources, extra_opacity * op, out);
|
||||
if let Some(inner) = tr.item_at(ft).filter(|i| i.enabled) {
|
||||
push_item(project, nested, inner, ft, opts, sources, extra_opacity * op, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -182,10 +184,11 @@ fn push_item(
|
|||
let motion = motion_matrix(seq, item, size, mt);
|
||||
let lin = ((motion.a * motion.a + motion.b * motion.b).sqrt()).max((motion.c * motion.c + motion.d * motion.d).sqrt());
|
||||
let want = (lin * opts.scale as f64).clamp(1.0 / 64.0, 1.0) as f32;
|
||||
let Ok(frame) = src.video_frame(FrameRequest { time: mt, scale: want }) else { return };
|
||||
let Ok(frame) = src.video_frame(FrameRequest { time: ft, scale: want }) else { return };
|
||||
let cs = crate::colorman::source_space(project, item.item, &frame);
|
||||
// log / HDR / wide-gamut media is converted on the CPU (below)
|
||||
if !crate::colorman::needs_management(&seq.settings.color, cs, &frame) {
|
||||
// log / HDR / wide-gamut media is converted on the CPU (below), and so are blended
|
||||
// in-between frames (Frame Blending / Optical Flow on speed-changed clips)
|
||||
if !crate::colorman::needs_management(&seq.settings.color, cs, &frame) && crate::interpolation_blend(item, t, src.info().frame_rate()).is_none() {
|
||||
let px_scale = frame.width as f64 / size.0.max(1) as f64;
|
||||
let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion).then_apply(&Affine::scale(1.0 / px_scale, 1.0 / px_scale));
|
||||
out.push(PlanLayer { frame, matrix: m, opacity: op * extra_opacity });
|
||||
|
|
|
|||
|
|
@ -336,7 +336,7 @@ fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, depth: u32) {
|
|||
h.json(&pi.name);
|
||||
}
|
||||
}
|
||||
ItemKind::Subclip { parent, range } => {
|
||||
ItemKind::Subclip { parent, range, .. } => {
|
||||
h.json(&("subclip", range));
|
||||
hash_source(h, project, *parent, depth + 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,6 +173,13 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
|
|||
Err(e) => return err(format!("`{k}`: {e}")),
|
||||
}
|
||||
}
|
||||
// fields of the open Edit / Clip / File dialog (`panels::clip_dialogs`)
|
||||
if let Some(m) = p.get("clipDialog").and_then(Value::as_object) {
|
||||
let Some(d) = app.ui.clip_dialog.as_mut() else { return err("no clip dialog is open") };
|
||||
for (k, v) in m {
|
||||
d.params[k.as_str()] = v.clone();
|
||||
}
|
||||
}
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.panel.show" | "ui.panel.close" => {
|
||||
|
|
|
|||
|
|
@ -151,6 +151,9 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val
|
|||
}
|
||||
return r;
|
||||
}
|
||||
if let Some(r) = crate::panels::clip_dialogs::route(app, id, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
if let Some(r) = crate::panels::media_dialogs::route(app, id, ¶ms) {
|
||||
if let Err(e) = &r {
|
||||
app.ui.status = e.clone();
|
||||
|
|
@ -464,27 +467,7 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
|
|||
if mine.is_empty() {
|
||||
ui.add_enabled(false, egui::Button::new("(empty)"));
|
||||
}
|
||||
let mut subs: Vec<&str> = Vec::new();
|
||||
for it in &mine {
|
||||
if it.path.len() > 1 {
|
||||
let sub = it.path[1].as_str();
|
||||
if !subs.contains(&sub) {
|
||||
subs.push(sub);
|
||||
ui.menu_button(sub, |ui| {
|
||||
ui.set_min_width(220.0);
|
||||
for s in mine.iter().filter(|x| x.path.get(1).map(String::as_str) == Some(sub)) {
|
||||
if menu_entry(ui, s) {
|
||||
clicked = Some(s.id.clone());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if menu_entry(ui, it) {
|
||||
clicked = Some(it.id.clone());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
menu_level(ui, &mine, 1, &mut clicked);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -493,6 +476,28 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
|
|||
}
|
||||
}
|
||||
|
||||
/// One menu level: items whose path ends here, and a submenu (at its first item's position) for
|
||||
/// each deeper path segment, recursively (e.g. Clip ▸ Video Options ▸ Time Interpolation).
|
||||
fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>) {
|
||||
let mut subs: Vec<&str> = Vec::new();
|
||||
for it in items {
|
||||
if let Some(sub) = it.path.get(depth).map(String::as_str) {
|
||||
if subs.contains(&sub) {
|
||||
continue;
|
||||
}
|
||||
subs.push(sub);
|
||||
let inner: Vec<&MenuItem> = items.iter().copied().filter(|x| x.path.get(depth).map(String::as_str) == Some(sub)).collect();
|
||||
ui.menu_button(sub, |ui| {
|
||||
ui.set_min_width(220.0);
|
||||
menu_level(ui, &inner, depth + 1, clicked);
|
||||
});
|
||||
} else if menu_entry(ui, it) {
|
||||
*clicked = Some(it.id.clone());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn menu_entry(ui: &mut egui::Ui, it: &MenuItem) -> bool {
|
||||
// checkable items leave room for a checkmark drawn at the left
|
||||
let label = if it.checked.is_some() { format!(" {}", it.label) } else { it.label.clone() };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,544 @@
|
|||
//! Edit / Clip / File menu dialogs (M3.10): Paste Attributes, Remove Attributes, Offline File,
|
||||
//! Make Subclip, Edit Subclip, Modify ▸ Audio Channels, Modify ▸ Timecode, Frame Hold Options,
|
||||
//! Field Options, and the Close Project "save changes?" prompt.
|
||||
//!
|
||||
//! The open dialog lives in `UiState::clip_dialog` (serde): the engine command it runs and the
|
||||
//! parameters being edited — the same JSON the command takes — so agents can open a dialog from
|
||||
//! the menu, change `params` with `ui.set`, and click OK. Menu items and shortcuts invoked without
|
||||
//! params open the dialog; with params the engine command runs directly.
|
||||
//!
|
||||
//! Automation ids (`<p>` = the dialog prefix below, `<key>` = a parameter):
|
||||
//! - every dialog: `<p>.ok`, `<p>.cancel`, and `<p>.<key>` for each control;
|
||||
//! - Paste Attributes `pasteAttributes.*`: `motion`, `opacity`, `timeRemapping`, `volume`,
|
||||
//! `channelVolume`, `panner`, `effects`, `effect.<id>`, `scaleTimes`;
|
||||
//! - Remove Attributes `removeAttributes.*`: the same without `scaleTimes`;
|
||||
//! - Offline File `offlineFile.*`: `fileName`, `name`, `tapeName`, `video`, `audio`, `width`,
|
||||
//! `height`, `fps`, `timecode`, `seconds`, `description`;
|
||||
//! - Make Subclip `makeSubclip.*`: `name`, `startFrame`, `endFrame`, `restrictTrims`;
|
||||
//! - Edit Subclip `editSubclip.*`: `startFrame`, `endFrame`, `restrictTrims`, `convertToMaster`;
|
||||
//! - Audio Channels `audioChannels.*`: `format.<mono|stereo|5.1|adaptive>`, `count`,
|
||||
//! `clip.<n>.ch.<c>` (n, c 0-based);
|
||||
//! - Timecode `timecode.*`: `timecode`, `tapeName`, `reset`;
|
||||
//! - Frame Hold Options `frameHold.*`: `enabled`, `holdOn.<in|out|playhead|sourceTimecode|sequenceTime>`,
|
||||
//! `timecode`, `holdFilters`;
|
||||
//! - Field Options `fieldOptions.*`: `reverseFieldDominance`, `processing.<none|alwaysDeinterlace|flickerRemoval>`;
|
||||
//! - Close Project `closeProject.save`, `closeProject.dontSave`, `closeProject.cancel`.
|
||||
|
||||
use egui::{Align2, RichText};
|
||||
use filmcraft_project::{AudioChannelMap, AudioChannels, ItemKind, TrackKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FilmcraftApp;
|
||||
use crate::state::ClipDialogDraft;
|
||||
|
||||
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 and automation prefix of a command's dialog.
|
||||
fn meta(command: &str) -> Option<(&'static str, &'static str)> {
|
||||
Some(match command {
|
||||
"edit.pasteAttributes" => ("Paste Attributes", "pasteAttributes"),
|
||||
"edit.removeAttributes" => ("Remove Attributes", "removeAttributes"),
|
||||
"file.newOfflineFile" => ("New Offline File", "offlineFile"),
|
||||
"clip.makeSubclip" => ("Make Subclip", "makeSubclip"),
|
||||
"clip.editSubclip" => ("Edit Subclip", "editSubclip"),
|
||||
"clip.audioChannels" => ("Modify Clip: Audio Channels", "audioChannels"),
|
||||
"clip.modifyTimecode" => ("Modify Clip: Timecode", "timecode"),
|
||||
"clip.frameHoldOptions" => ("Frame Hold Options", "frameHold"),
|
||||
"clip.fieldOptions" => ("Field Options", "fieldOptions"),
|
||||
"file.closeProject" => ("Save Project", "closeProject"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
const INTRINSIC_VIDEO: [(&str, &str); 3] = [("motion", "Motion"), ("opacity", "Opacity"), ("timeRemapping", "Time Remapping")];
|
||||
const INTRINSIC_AUDIO: [(&str, &str); 3] = [("volume", "Volume"), ("channelVolume", "Channel Volume"), ("panner", "Panner")];
|
||||
const FORMATS: [(&str, &str); 4] = [("mono", "Mono"), ("stereo", "Stereo"), ("5.1", "5.1"), ("adaptive", "Adaptive")];
|
||||
const HOLD_ON: [(&str, &str); 5] =
|
||||
[("in", "In Point"), ("out", "Out Point"), ("playhead", "Playhead"), ("sourceTimecode", "Source Timecode"), ("sequenceTime", "Sequence Time")];
|
||||
|
||||
/// Menu / shortcut entry points without params open a dialog. Returns None for other ids.
|
||||
pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
|
||||
let empty = params.as_object().is_none_or(|m| m.is_empty());
|
||||
if !empty {
|
||||
return None;
|
||||
}
|
||||
// Save All on a never-saved project asks where to save, like Save
|
||||
if id == "file.saveAll" && app.session.path.is_none() {
|
||||
return Some(app.file_dialog("file.save", params));
|
||||
}
|
||||
if id == "file.closeProject" && !app.session.is_dirty() {
|
||||
return None;
|
||||
}
|
||||
meta(id)?;
|
||||
if let Err(e) = filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session)) {
|
||||
app.ui.status = e.clone();
|
||||
return Some(Err(e));
|
||||
}
|
||||
let (params, info) = defaults(app, id);
|
||||
app.ui.clip_dialog = Some(ClipDialogDraft { command: id.into(), params, info, error: String::new() });
|
||||
Some(Ok(json!({"dialog": meta(id).map(|m| m.1)})))
|
||||
}
|
||||
|
||||
/// Initial parameters (and display info) of a command's dialog.
|
||||
fn defaults(app: &FilmcraftApp, id: &str) -> (Value, Value) {
|
||||
let s = &app.session;
|
||||
match id {
|
||||
"edit.pasteAttributes" | "edit.removeAttributes" => {
|
||||
// effects offered: the copied clips' (Paste) or the selected clips' (Remove) standard effects
|
||||
let mut names: Vec<(String, String)> = Vec::new();
|
||||
let mut add = |e: &filmcraft_project::EffectInstance| {
|
||||
if let Some(d) = e.def().filter(|d| !d.intrinsic && !filmcraft_project::graphic::is_layer_id(d.id))
|
||||
&& !names.iter().any(|n| n.0 == d.id)
|
||||
{
|
||||
names.push((d.id.to_string(), d.name.to_string()));
|
||||
}
|
||||
};
|
||||
let mut has_v = false;
|
||||
let mut has_a = false;
|
||||
if id == "edit.pasteAttributes" {
|
||||
for (k, _, it) in &s.state.clipboard {
|
||||
has_v |= *k == TrackKind::Video;
|
||||
has_a |= *k == TrackKind::Audio;
|
||||
it.effects.iter().for_each(&mut add);
|
||||
}
|
||||
} else if let Some(q) = s.active_sequence() {
|
||||
for c in &s.state.selection {
|
||||
if let Some((tid, it)) = q.find_item(*c) {
|
||||
let v = q.video_tracks.iter().any(|t| t.id == tid);
|
||||
has_v |= v;
|
||||
has_a |= !v;
|
||||
it.effects.iter().for_each(&mut add);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut p = json!({"motion": has_v, "opacity": has_v, "timeRemapping": has_v, "volume": has_a, "channelVolume": has_a, "panner": has_a,
|
||||
"effects": names.iter().map(|n| n.0.clone()).collect::<Vec<_>>()});
|
||||
if id == "edit.pasteAttributes" {
|
||||
p["scaleTimes"] = json!(true);
|
||||
}
|
||||
(p, json!({"video": has_v, "audio": has_a, "effects": names}))
|
||||
}
|
||||
"file.newOfflineFile" => {
|
||||
let st = s.active_sequence().map(|q| q.settings.clone()).unwrap_or_default();
|
||||
(
|
||||
json!({"fileName": "Offline.mov", "name": "", "tapeName": "", "description": "", "video": true, "audio": true, "width": st.width, "height": st.height,
|
||||
"fps": st.frame_rate.as_f64(), "timecode": "00:00:00:00", "seconds": 10.0}),
|
||||
Value::Null,
|
||||
)
|
||||
}
|
||||
"clip.makeSubclip" => {
|
||||
let d = filmcraft_engine::clip_ops::subclip_defaults(s, &json!({})).unwrap_or_else(|| json!({}));
|
||||
(json!({"name": d["name"], "startFrame": d["startFrame"], "endFrame": d["endFrame"], "restrictTrims": true}), json!({"fps": d["fps"]}))
|
||||
}
|
||||
"clip.editSubclip" => {
|
||||
let sub = s.state.project_selection.iter().find_map(|i| s.project.item(*i).filter(|it| matches!(it.kind, ItemKind::Subclip { .. })));
|
||||
let Some(it) = sub else { return (json!({}), Value::Null) };
|
||||
let ItemKind::Subclip { parent, range, restrict_trims } = &it.kind else { return (json!({}), Value::Null) };
|
||||
let rate = s.project.item(*parent).and_then(|p| p.as_media()).map(|m| m.frame_rate()).unwrap_or_default();
|
||||
(
|
||||
json!({"item": it.id.0, "startFrame": rate.frame_at(range.start), "endFrame": rate.frame_at(range.end()), "restrictTrims": restrict_trims, "convertToMaster": false}),
|
||||
json!({"name": it.name, "fps": rate.as_f64()}),
|
||||
)
|
||||
}
|
||||
"clip.audioChannels" => {
|
||||
let item = s.state.project_selection.iter().find_map(|i| s.project.item(*i).and_then(|it| it.as_media()).filter(|m| m.info.has_audio()));
|
||||
match item {
|
||||
Some(m) => {
|
||||
let n = m.info.audio.as_ref().map_or(2, |a| a.channels) as u16;
|
||||
let map = m.interpret.audio_channels.clone().unwrap_or_else(|| AudioChannelMap::for_format(AudioChannels::Stereo, n));
|
||||
(json!({"format": format_name(map.format), "clips": map.clips}), json!({"channels": n, "items": true}))
|
||||
}
|
||||
None => (json!({"channels": [0, 1]}), json!({"channels": 2, "items": false})),
|
||||
}
|
||||
}
|
||||
"clip.modifyTimecode" => {
|
||||
let it = s.state.project_selection.iter().find_map(|i| s.project.item(*i).filter(|it| it.as_media().is_some()));
|
||||
let (tc, tape) = it
|
||||
.map(|it| {
|
||||
let m = it.as_media().expect("media");
|
||||
let f = m.info.start_timecode.unwrap_or(0);
|
||||
(filmcraft_time::format_timecode_frames(f, m.frame_rate(), false), it.metadata.get("Tape Name").cloned().unwrap_or_default())
|
||||
})
|
||||
.unwrap_or_else(|| ("00:00:00:00".into(), String::new()));
|
||||
(json!({"timecode": tc, "tapeName": tape}), Value::Null)
|
||||
}
|
||||
"clip.frameHoldOptions" => {
|
||||
let it = s.active_sequence().and_then(|q| s.state.selection.iter().find_map(|c| q.find_item(*c).map(|x| x.1.clone())));
|
||||
let enabled = it.as_ref().is_none_or(|i| i.frame_hold.is_some());
|
||||
let hold_filters = it.as_ref().is_some_and(|i| i.hold_filters);
|
||||
let tc = it
|
||||
.as_ref()
|
||||
.map(|i| {
|
||||
let rate = s.project.item(i.item).and_then(|p| p.as_media()).map(|m| m.frame_rate()).unwrap_or_default();
|
||||
filmcraft_time::format_timecode_frames(rate.frame_at(i.frame_hold.unwrap_or(i.source_in)), rate, false)
|
||||
})
|
||||
.unwrap_or_else(|| "00:00:00:00".into());
|
||||
(json!({"enabled": enabled, "holdOn": "in", "timecode": tc, "holdFilters": hold_filters}), Value::Null)
|
||||
}
|
||||
"clip.fieldOptions" => (json!({"reverseFieldDominance": false, "processing": "none"}), Value::Null),
|
||||
_ => (json!({}), Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_name(f: AudioChannels) -> &'static str {
|
||||
match f {
|
||||
AudioChannels::Mono => "mono",
|
||||
AudioChannels::Stereo => "stereo",
|
||||
AudioChannels::Surround51 => "5.1",
|
||||
AudioChannels::Adaptive => "adaptive",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_of(name: &str) -> AudioChannels {
|
||||
match name {
|
||||
"mono" => AudioChannels::Mono,
|
||||
"5.1" => AudioChannels::Surround51,
|
||||
"adaptive" => AudioChannels::Adaptive,
|
||||
_ => AudioChannels::Stereo,
|
||||
}
|
||||
}
|
||||
|
||||
fn check(ui: &mut egui::Ui, elems: &mut Elems, pre: &str, p: &mut Value, key: &str, label: &str, enabled: bool) {
|
||||
let mut v = p.get(key).and_then(Value::as_bool).unwrap_or(false);
|
||||
let r = ui.add_enabled(enabled, egui::Checkbox::new(&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 frames(ui: &mut egui::Ui, elems: &mut Elems, pre: &str, p: &mut Value, key: &str, label: &str, fps: f64) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
let mut v = p.get(key).and_then(Value::as_i64).unwrap_or(0);
|
||||
let r = ui.add(egui::DragValue::new(&mut v).range(0..=i64::MAX / 4).suffix(" fr"));
|
||||
push(elems, format!("{pre}.{key}"), &r, label);
|
||||
if r.changed() {
|
||||
p[key] = json!(v);
|
||||
}
|
||||
let rate = filmcraft_time::FrameRate::from_f64(if fps > 0.0 { fps } else { 24.0 });
|
||||
ui.label(RichText::new(filmcraft_time::format_timecode_frames(v, rate, false)).monospace().weak());
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw the open dialog, if any.
|
||||
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.clip_dialog.clone() else { return };
|
||||
let Some((title, pre)) = meta(&d.command) else {
|
||||
app.ui.clip_dialog = None;
|
||||
return;
|
||||
};
|
||||
let mut elems: Elems = Vec::new();
|
||||
let mut action: Option<&'static str> = None;
|
||||
let project_name = app.session.project.name.clone();
|
||||
let can_save = app.session.path.is_some();
|
||||
egui::Window::new(title).collapsible(false).resizable(false).default_width(360.0).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| {
|
||||
let p = &mut d.params;
|
||||
match d.command.as_str() {
|
||||
"edit.pasteAttributes" | "edit.removeAttributes" => {
|
||||
let (v, a) = (d.info["video"].as_bool().unwrap_or(true), d.info["audio"].as_bool().unwrap_or(true));
|
||||
ui.label(RichText::new("Video Attributes").strong());
|
||||
for (k, l) in INTRINSIC_VIDEO {
|
||||
check(ui, &mut elems, pre, p, k, l, v);
|
||||
}
|
||||
ui.label(RichText::new("Audio Attributes").strong());
|
||||
for (k, l) in INTRINSIC_AUDIO {
|
||||
check(ui, &mut elems, pre, p, k, l, a);
|
||||
}
|
||||
let effects: Vec<(String, String)> = d.info["effects"]
|
||||
.as_array()
|
||||
.map(|x| x.iter().filter_map(|e| Some((e[0].as_str()?.to_string(), e[1].as_str()?.to_string()))).collect())
|
||||
.unwrap_or_default();
|
||||
ui.label(RichText::new("Effects").strong());
|
||||
if effects.is_empty() {
|
||||
ui.label(RichText::new("No effects").weak());
|
||||
}
|
||||
for (id, name) in &effects {
|
||||
let mut on = p["effects"].as_array().is_some_and(|x| x.iter().any(|e| e.as_str() == Some(id)));
|
||||
let r = ui.checkbox(&mut on, name);
|
||||
push(&mut elems, format!("{pre}.effect.{id}"), &r, name.as_str());
|
||||
if r.changed() {
|
||||
let mut list: Vec<Value> = p["effects"].as_array().cloned().unwrap_or_default();
|
||||
list.retain(|e| e.as_str() != Some(id));
|
||||
if on {
|
||||
list.push(json!(id));
|
||||
}
|
||||
p["effects"] = Value::Array(list);
|
||||
}
|
||||
}
|
||||
if d.command == "edit.pasteAttributes" {
|
||||
ui.separator();
|
||||
check(ui, &mut elems, pre, p, "scaleTimes", "Scale Attribute Times", true);
|
||||
}
|
||||
}
|
||||
"file.newOfflineFile" => {
|
||||
text(ui, &mut elems, pre, p, "fileName", "File Name:", 220.0);
|
||||
text(ui, &mut elems, pre, p, "name", "Clip Name:", 220.0);
|
||||
text(ui, &mut elems, pre, p, "tapeName", "Tape Name:", 220.0);
|
||||
text(ui, &mut elems, pre, p, "description", "Description:", 220.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Contains:");
|
||||
check(ui, &mut elems, pre, p, "video", "Video", true);
|
||||
check(ui, &mut elems, pre, p, "audio", "Audio", true);
|
||||
});
|
||||
let has_v = p["video"].as_bool().unwrap_or(true);
|
||||
ui.add_enabled_ui(has_v, |ui| {
|
||||
number(ui, &mut elems, pre, p, "width", "Width:", 16.0..=16384.0, " px");
|
||||
number(ui, &mut elems, pre, p, "height", "Height:", 16.0..=16384.0, " px");
|
||||
number(ui, &mut elems, pre, p, "fps", "Frame Rate:", 1.0..=240.0, " fps");
|
||||
});
|
||||
text(ui, &mut elems, pre, p, "timecode", "Media Start:", 120.0);
|
||||
number(ui, &mut elems, pre, p, "seconds", "Duration:", 0.05..=86_400.0, " s");
|
||||
}
|
||||
"clip.makeSubclip" | "clip.editSubclip" => {
|
||||
let fps = d.info["fps"].as_f64().unwrap_or(24.0);
|
||||
if d.command == "clip.makeSubclip" {
|
||||
text(ui, &mut elems, pre, p, "name", "Name:", 240.0);
|
||||
} else {
|
||||
ui.label(format!("Subclip: {}", d.info["name"].as_str().unwrap_or_default()));
|
||||
}
|
||||
frames(ui, &mut elems, pre, p, "startFrame", "Start:", fps);
|
||||
frames(ui, &mut elems, pre, p, "endFrame", "End:", fps);
|
||||
check(ui, &mut elems, pre, p, "restrictTrims", "Restrict Trims To Subclip Boundaries", true);
|
||||
if d.command == "clip.editSubclip" {
|
||||
check(ui, &mut elems, pre, p, "convertToMaster", "Convert to Master Clip", true);
|
||||
}
|
||||
}
|
||||
"clip.audioChannels" => {
|
||||
let n = d.info["channels"].as_u64().unwrap_or(2).max(1) as u16;
|
||||
if d.info["items"].as_bool().unwrap_or(false) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Clip Channel Format:");
|
||||
for (k, l) in FORMATS {
|
||||
let r = ui.radio(p["format"].as_str() == Some(k), l);
|
||||
push(&mut elems, format!("{pre}.format.{k}"), &r, l);
|
||||
if r.clicked() {
|
||||
p["format"] = json!(k);
|
||||
p["clips"] = json!(AudioChannelMap::for_format(format_of(k), n).clips);
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut clips: Vec<Vec<u16>> = serde_json::from_value(p["clips"].clone()).unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Number of Audio Clips:");
|
||||
let mut count = clips.len() as u32;
|
||||
let r = ui.add(egui::DragValue::new(&mut count).range(1..=n as u32));
|
||||
push(&mut elems, format!("{pre}.count"), &r, "Number of Audio Clips");
|
||||
if r.changed() {
|
||||
let count = count.max(1) as usize;
|
||||
while clips.len() < count {
|
||||
let c = (clips.len() as u16).min(n - 1);
|
||||
clips.push(vec![c]);
|
||||
}
|
||||
clips.truncate(count);
|
||||
}
|
||||
});
|
||||
ui.label(RichText::new("Media Source Channels").strong());
|
||||
egui::Grid::new("audio-channel-matrix").striped(true).show(ui, |ui| {
|
||||
ui.label("");
|
||||
for c in 0..n {
|
||||
ui.label(format!("Ch. {}", c + 1));
|
||||
}
|
||||
ui.end_row();
|
||||
for (ci, chans) in clips.iter_mut().enumerate() {
|
||||
ui.label(format!("Clip {}", ci + 1));
|
||||
for c in 0..n {
|
||||
let mut on = chans.contains(&c);
|
||||
let r = ui.checkbox(&mut on, "");
|
||||
push(&mut elems, format!("{pre}.clip.{ci}.ch.{c}"), &r, format!("Clip {} Ch. {}", ci + 1, c + 1));
|
||||
if r.changed() {
|
||||
if on {
|
||||
chans.push(c);
|
||||
chans.sort_unstable();
|
||||
} else {
|
||||
chans.retain(|x| *x != c);
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
p["clips"] = json!(clips);
|
||||
} else {
|
||||
ui.label("Source channels the selected audio clips play:");
|
||||
let mut chans: Vec<u16> = serde_json::from_value(p["channels"].clone()).unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
for c in 0..n.max(2) {
|
||||
let mut on = chans.contains(&c);
|
||||
let r = ui.checkbox(&mut on, format!("Ch. {}", c + 1));
|
||||
push(&mut elems, format!("{pre}.clip.0.ch.{c}"), &r, format!("Ch. {}", c + 1));
|
||||
if r.changed() {
|
||||
if on {
|
||||
chans.push(c);
|
||||
chans.sort_unstable();
|
||||
} else {
|
||||
chans.retain(|x| *x != c);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
p["channels"] = json!(chans);
|
||||
}
|
||||
}
|
||||
"clip.modifyTimecode" => {
|
||||
text(ui, &mut elems, pre, p, "timecode", "Set Timecode:", 120.0);
|
||||
text(ui, &mut elems, pre, p, "tapeName", "Tape Name:", 200.0);
|
||||
check(ui, &mut elems, pre, p, "reset", "Use the file's timecode", true);
|
||||
}
|
||||
"clip.frameHoldOptions" => {
|
||||
check(ui, &mut elems, pre, p, "enabled", "Hold On", true);
|
||||
let on = p["enabled"].as_bool().unwrap_or(true);
|
||||
ui.add_enabled_ui(on, |ui| {
|
||||
for (k, l) in HOLD_ON {
|
||||
let r = ui.radio(p["holdOn"].as_str() == Some(k), l);
|
||||
push(&mut elems, format!("{pre}.holdOn.{k}"), &r, l);
|
||||
if r.clicked() {
|
||||
p["holdOn"] = json!(k);
|
||||
}
|
||||
}
|
||||
if matches!(p["holdOn"].as_str(), Some("sourceTimecode" | "sequenceTime")) {
|
||||
text(ui, &mut elems, pre, p, "timecode", "Timecode:", 120.0);
|
||||
}
|
||||
check(ui, &mut elems, pre, p, "holdFilters", "Hold Filters", true);
|
||||
});
|
||||
}
|
||||
"clip.fieldOptions" => {
|
||||
check(ui, &mut elems, pre, p, "reverseFieldDominance", "Reverse Field Dominance", true);
|
||||
ui.label(RichText::new("Processing Options").strong());
|
||||
for (k, l) in [("none", "None"), ("alwaysDeinterlace", "Always Deinterlace"), ("flickerRemoval", "Flicker Removal")] {
|
||||
let r = ui.radio(p["processing"].as_str() == Some(k), l);
|
||||
push(&mut elems, format!("{pre}.processing.{k}"), &r, l);
|
||||
if r.clicked() {
|
||||
p["processing"] = json!(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
"file.closeProject" => {
|
||||
ui.label(format!("Save changes to “{project_name}” before closing?"));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.button("Cancel");
|
||||
push(&mut elems, format!("{pre}.cancel"), &r, "Cancel");
|
||||
if r.clicked() {
|
||||
action = Some("cancel");
|
||||
}
|
||||
let r = ui.button("Don't Save");
|
||||
push(&mut elems, format!("{pre}.dontSave"), &r, "Don't Save");
|
||||
if r.clicked() {
|
||||
action = Some("dontSave");
|
||||
}
|
||||
let r = ui.add_enabled(can_save, egui::Button::new("Save"));
|
||||
push(&mut elems, format!("{pre}.save"), &r, "Save");
|
||||
if r.clicked() {
|
||||
action = Some("save");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
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| {
|
||||
let r = ui.button("Cancel");
|
||||
push(&mut elems, format!("{pre}.cancel"), &r, "Cancel");
|
||||
if r.clicked() {
|
||||
action = Some("cancel");
|
||||
}
|
||||
let r = ui.button("OK");
|
||||
push(&mut elems, format!("{pre}.ok"), &r, "OK");
|
||||
if r.clicked() {
|
||||
action = Some("ok");
|
||||
}
|
||||
});
|
||||
});
|
||||
for (id, r, l) in elems {
|
||||
app.auto.add(&id, r, &l);
|
||||
}
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
action = Some("cancel");
|
||||
}
|
||||
match action {
|
||||
Some("cancel") => {
|
||||
app.ui.clip_dialog = None;
|
||||
return;
|
||||
}
|
||||
Some("ok") => {
|
||||
let params = finish(&d);
|
||||
match app.session.execute(&d.command, params) {
|
||||
Ok(_) => {
|
||||
app.ui.clip_dialog = None;
|
||||
return;
|
||||
}
|
||||
Err(e) => d.error = e.to_string(),
|
||||
}
|
||||
}
|
||||
Some("dontSave") | Some("save") => {
|
||||
if action == Some("save")
|
||||
&& let Err(e) = app.session.execute("file.save", json!({}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
app.ui.clip_dialog = None;
|
||||
return;
|
||||
}
|
||||
if let Err(e) = app.session.execute("file.closeProject", json!({"force": true})) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
app.ui.clip_dialog = None;
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
app.ui.clip_dialog = Some(d);
|
||||
}
|
||||
|
||||
/// The parameters sent on OK (dialog-only fields dropped or converted).
|
||||
fn finish(d: &ClipDialogDraft) -> Value {
|
||||
let mut p = d.params.clone();
|
||||
match d.command.as_str() {
|
||||
"file.newOfflineFile" => {
|
||||
if p["name"].as_str().is_some_and(str::is_empty) {
|
||||
p.as_object_mut().map(|m| m.remove("name"));
|
||||
}
|
||||
}
|
||||
"clip.modifyTimecode" if p["reset"].as_bool() == Some(true) => {
|
||||
p.as_object_mut().map(|m| m.remove("timecode"));
|
||||
}
|
||||
// the timecode field only applies to Source Timecode / Sequence Time
|
||||
"clip.frameHoldOptions" if !matches!(p["holdOn"].as_str(), Some("sourceTimecode" | "sequenceTime")) => {
|
||||
p.as_object_mut().map(|m| m.remove("timecode"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ use crate::{Dialog, FilmcraftApp};
|
|||
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::presets::save_dialog(app, ctx);
|
||||
crate::panels::multicam::show_dialog(app, ctx);
|
||||
crate::panels::monitor_view::dialogs(app, ctx);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Panel bodies. `show` dispatches on [`PanelKind`]; drag-and-drop between panels (project items,
|
||||
//! effects) is carried in egui temp data so the timeline/monitors can accept drops.
|
||||
|
||||
pub mod clip_dialogs;
|
||||
pub mod color_dialogs;
|
||||
pub mod dialogs;
|
||||
pub mod effect_controls;
|
||||
|
|
|
|||
|
|
@ -432,6 +432,23 @@ pub struct UiState {
|
|||
/// Open guide dialog (Add Guide / Save Guides as Template / Manage Guides).
|
||||
#[serde(default)]
|
||||
pub guide_dialog: Option<GuideDialog>,
|
||||
/// Edit / Clip / File menu dialog (Paste Attributes, Make Subclip, Frame Hold Options, …;
|
||||
/// open when Some). See `panels::clip_dialogs`.
|
||||
#[serde(default)]
|
||||
pub clip_dialog: Option<ClipDialogDraft>,
|
||||
}
|
||||
|
||||
/// An open Edit / Clip / File menu dialog: the engine command it runs on OK and the parameters
|
||||
/// being edited (the same JSON the command takes, so agents can fill it with `ui.set`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ClipDialogDraft {
|
||||
pub command: String,
|
||||
pub params: serde_json::Value,
|
||||
/// Extra data shown by the dialog (effect names, channel count, …); not sent.
|
||||
#[serde(default)]
|
||||
pub info: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
/// A pen mask in progress: vertices placed so far, in clip pixels (`[x, y, tangent x, tangent y]`).
|
||||
|
|
@ -695,6 +712,7 @@ impl Default for UiState {
|
|||
sync_dialog: None,
|
||||
multicam_record: true,
|
||||
guide_dialog: None,
|
||||
clip_dialog: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,193 @@
|
|||
//! Headless UI tests of the Edit / Clip / File menu dialogs (M3.10): Paste Attributes, Make
|
||||
//! Subclip, Frame Hold Options, Modify ▸ Audio Channels, the Close Project prompt, and nested
|
||||
//! menus (Clip ▸ Video Options ▸ Time Interpolation).
|
||||
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use filmcraft_engine::Session;
|
||||
use filmcraft_engine::project::{ItemKind, Label};
|
||||
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 app = FilmcraftApp::new(s).with_control(rx);
|
||||
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 {
|
||||
// the control channel's clicks and keys enter through the input hook
|
||||
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 v1(&mut self, i: usize) -> filmcraft_engine::project::TrackItem {
|
||||
self.app().session.active_sequence().unwrap().video_tracks[0].items[i].clone()
|
||||
}
|
||||
|
||||
fn item_named(&mut self, name: &str) -> u64 {
|
||||
self.app().session.project.items.values().find(|i| i.name == name).unwrap().id.0
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_attributes_dialog() {
|
||||
let mut d = Driver::new();
|
||||
let (a, b) = (d.v1(0), d.v1(2));
|
||||
d.exec("effects.setParam", json!({"clip": a.id.0, "effect": "motion", "param": "rotation", "value": 45.0}));
|
||||
d.exec("effects.setParam", json!({"clip": a.id.0, "effect": "opacity", "param": "opacity", "value": 25.0}));
|
||||
d.exec("effects.apply", json!({"clips": [a.id.0], "effect": "gaussian_blur"}));
|
||||
d.exec("timeline.select", json!({"clips": [a.id.0]}));
|
||||
d.exec("edit.copy", json!({}));
|
||||
d.exec("timeline.select", json!({"clips": [b.id.0]}));
|
||||
assert_eq!(d.menu("edit.pasteAttributes")["dialog"], "pasteAttributes");
|
||||
for id in
|
||||
["pasteAttributes.motion", "pasteAttributes.opacity", "pasteAttributes.volume", "pasteAttributes.effect.gaussian_blur", "pasteAttributes.scaleTimes"]
|
||||
{
|
||||
assert!(d.has(id), "{id}");
|
||||
}
|
||||
// untick Opacity and the blur, then OK
|
||||
d.click("pasteAttributes.opacity");
|
||||
d.click("pasteAttributes.effect.gaussian_blur");
|
||||
d.click("pasteAttributes.ok");
|
||||
assert!(d.app().ui.clip_dialog.is_none(), "closed on OK");
|
||||
let got = d.v1(2);
|
||||
assert_eq!(got.effect("motion").unwrap().param("rotation").unwrap().value.as_f64(), Some(45.0));
|
||||
assert_eq!(got.effect("opacity").unwrap().param("opacity").unwrap().value.as_f64(), Some(100.0));
|
||||
assert!(got.effect("gaussian_blur").is_none());
|
||||
// Cancel leaves the clip alone; with params the command runs directly (no dialog)
|
||||
d.menu("edit.removeAttributes");
|
||||
d.click("removeAttributes.cancel");
|
||||
assert!(d.app().ui.clip_dialog.is_none());
|
||||
assert_eq!(d.v1(2).effect("motion").unwrap().param("rotation").unwrap().value.as_f64(), Some(45.0));
|
||||
d.exec("edit.removeAttributes", json!({"opacity": false}));
|
||||
assert_eq!(d.v1(2).effect("motion").unwrap().param("rotation").unwrap().value.as_f64(), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_subclip_frame_hold_and_audio_channel_dialogs() {
|
||||
let mut d = Driver::new();
|
||||
let ocean = d.item_named("Ocean_Sunset.mp4");
|
||||
d.exec("source.open", json!({"item": ocean}));
|
||||
d.exec("project.setMarks", json!({"item": ocean, "in": filmcraft_time::FrameRate::FPS_23_976.tick_of(24).0}));
|
||||
d.menu("clip.makeSubclip");
|
||||
assert_eq!(d.app().ui.clip_dialog.as_ref().unwrap().params["startFrame"], 24);
|
||||
for id in ["makeSubclip.name", "makeSubclip.startFrame", "makeSubclip.endFrame", "makeSubclip.restrictTrims", "makeSubclip.ok"] {
|
||||
assert!(d.has(id), "{id}");
|
||||
}
|
||||
d.ok("ui.set", json!({"clipDialog": {"name": "Waves", "endFrame": 48}}));
|
||||
d.click("makeSubclip.ok");
|
||||
let sub = d.app().session.project.items.values().find(|i| i.name == "Waves").cloned().expect("subclip made");
|
||||
let ItemKind::Subclip { range, .. } = sub.kind else { panic!() };
|
||||
assert_eq!(range.duration, filmcraft_time::FrameRate::FPS_23_976.tick_of(24));
|
||||
|
||||
// Frame Hold Options on the selected clip: Hold On ▸ Playhead
|
||||
let c = d.v1(1);
|
||||
d.exec("timeline.select", json!({"clips": [c.id.0]}));
|
||||
d.exec("playhead.set", json!({"time": (c.start + filmcraft_time::FrameRate::FPS_23_976.tick_of(5)).0}));
|
||||
d.menu("clip.frameHoldOptions");
|
||||
assert_eq!(d.app().ui.clip_dialog.as_ref().unwrap().params["enabled"], false, "Hold On starts unticked for a clip without a hold");
|
||||
d.click("frameHold.enabled");
|
||||
d.click("frameHold.holdOn.playhead");
|
||||
d.click("frameHold.holdFilters");
|
||||
d.click("frameHold.ok");
|
||||
let got = d.v1(1);
|
||||
assert_eq!(got.frame_hold, Some(c.source_in + filmcraft_time::FrameRate::FPS_23_976.tick_of(5)));
|
||||
assert!(got.hold_filters);
|
||||
|
||||
// Modify ▸ Audio Channels (Shift+G) on a project item: Mono makes one clip per channel
|
||||
d.exec("project.select", json!({"items": [ocean]}));
|
||||
d.menu("clip.audioChannels");
|
||||
assert!(d.has("audioChannels.clip.0.ch.1"));
|
||||
d.click("audioChannels.format.mono");
|
||||
assert_eq!(d.app().ui.clip_dialog.as_ref().unwrap().params["clips"], json!([[0], [1]]));
|
||||
d.click("audioChannels.ok");
|
||||
let m = d.app().session.project.item(filmcraft_engine::project::ItemId(ocean)).unwrap().as_media().unwrap().interpret.audio_channels.clone().unwrap();
|
||||
assert_eq!(m.clips, vec![vec![0], vec![1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_project_prompt_and_nested_menus() {
|
||||
let mut d = Driver::new();
|
||||
// nested menu paths reach the menu list
|
||||
let menus = d.ok("ui.menu.list", json!({}));
|
||||
let item = menus.as_array().unwrap().iter().find(|m| m["id"] == "clip.timeInterpolation.frameBlending").unwrap().clone();
|
||||
assert_eq!(item["path"], json!(["Clip", "Video Options", "Time Interpolation"]));
|
||||
let labels: Vec<&str> = menus.as_array().unwrap().iter().filter(|m| m["path"] == json!(["Edit", "Label"])).filter_map(|m| m["label"].as_str()).collect();
|
||||
assert_eq!(labels.len(), 17);
|
||||
// label from the menu
|
||||
let c = d.v1(0);
|
||||
d.exec("timeline.select", json!({"clips": [c.id.0]}));
|
||||
d.menu("edit.label.yellow");
|
||||
assert_eq!(d.v1(0).label, Label::Yellow);
|
||||
// the project is dirty: Close Project asks
|
||||
assert!(d.app().session.is_dirty());
|
||||
assert_eq!(d.menu("file.closeProject")["dialog"], "closeProject");
|
||||
assert!(d.has("closeProject.save") && d.has("closeProject.dontSave"));
|
||||
d.click("closeProject.cancel");
|
||||
assert!(!d.app().session.project.items.is_empty());
|
||||
d.menu("file.closeProject");
|
||||
d.click("closeProject.dontSave");
|
||||
assert!(d.app().session.project.items.is_empty(), "closed without saving");
|
||||
assert!(d.app().ui.clip_dialog.is_none());
|
||||
}
|
||||
|
|
@ -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:{…}}` | `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)) |
|
||||
| `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.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 |
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ writing them is `crates/format` (`filmcraft-format`); the engine's `file.*` comm
|
|||
```json
|
||||
{
|
||||
"format": "filmcraft.project",
|
||||
"schema_version": 9,
|
||||
"schema_version": 10,
|
||||
"generator": "FilmCraft 0.1.0",
|
||||
"project": { "name": "…", "settings": { … }, "root": { … }, "items": { … }, "next_id": 48 }
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ writing them is `crates/format` (`filmcraft-format`); the engine's `file.*` comm
|
|||
| 7 | M5.5 | effect masks: `masks` on effect instances with keyframable Bézier `Path` values; no-op step |
|
||||
| 8 | M12 | multi-camera source sequences and clips, merged clips; no-op step |
|
||||
| 9 | M10.5 | transcripts of media items (`project.transcripts`); no-op step |
|
||||
| 10 | M3.10 | clip time interpolation, Hold Filters, Field Options, audio source channels, Modify ▸ Audio Channels map, subclip Restrict Trims; no-op step |
|
||||
|
||||
### Migrations
|
||||
|
||||
|
|
@ -60,8 +61,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 v10); this build reads up to
|
||||
> v9. Update FilmCraft to open it.
|
||||
> this project was saved by a newer version of FilmCraft (project schema v11); this build reads up to
|
||||
> v10. Update FilmCraft to open it.
|
||||
|
||||
Reading it partially and saving it back would silently drop whatever the newer version added.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue