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:
Brandon Thomas 2026-10-01 23:34:12 -04:00
commit e222d025cf
30 changed files with 3691 additions and 82 deletions

View File

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

View File

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

View File

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

View File

@ -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(),
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -48,7 +48,7 @@ pub type Migration = fn(Value) -> Result<Value, String>;
/// `MIGRATIONS[i]` upgrades schema `i + 1` to `i + 2`. Append one function per schema bump; never
/// edit a shipped one.
pub const MIGRATIONS: &[Migration] = &[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9];
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]

View File

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

View File

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

View File

@ -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(),
}
}

View File

@ -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(),
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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" => {

View File

@ -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, &params) {
return r;
}
if let Some(r) = crate::panels::media_dialogs::route(app, id, &params) {
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() };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`):
| `engine.commands` / `ui.menu.list` | – | command registry / menu tree |
| `ui.inspect` | – | UI state (tool, workspace, dock, timeline view, playback, fps…) |
| `ui.elements` | `{prefix?}` | every on-screen interactive element: id, label, rect |
| `ui.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit}, program:{…}, source:{…}}` | `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 |

View File

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