diff --git a/ROADMAP.md b/ROADMAP.md index a3110d81..bd889a9e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/apps/filmcraft/src/native_menu.rs b/apps/filmcraft/src/native_menu.rs index 626a045c..9288db79 100644 --- a/apps/filmcraft/src/native_menu.rs +++ b/apps/filmcraft/src/native_menu.rs @@ -42,22 +42,26 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, 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, 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); } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index de336128..49354f41 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -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 { diff --git a/crates/edit/src/through.rs b/crates/edit/src/through.rs index d0203355..658fcded 100644 --- a/crates/edit/src/through.rs +++ b/crates/edit/src/through.rs @@ -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(), } } diff --git a/crates/edit/src/transcript/tests.rs b/crates/edit/src/transcript/tests.rs index b44e139e..2db93824 100644 --- a/crates/edit/src/transcript/tests.rs +++ b/crates/edit/src/transcript/tests.rs @@ -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(), } } diff --git a/crates/engine/src/clip_ops.rs b/crates/engine/src/clip_ops.rs new file mode 100644 index 00000000..2ce26488 --- /dev/null +++ b/crates/engine/src/clip_ops.rs @@ -0,0 +1,1619 @@ +//! Edit / Clip / File menu commands for project items and clips (M3.10): labels and Select Label +//! Group, Paste / Remove Attributes, Select All Matching, Remove Unused, Consolidate Duplicates, +//! Sequence From Clip, Bin From Selection, Offline File, Close Project, Save All, Make / Edit +//! Subclip, Modify ▸ Audio Channels / Timecode, Video Options (Frame Hold Options, Add Frame Hold, +//! Insert Frame Hold Segment, Field Options, Time Interpolation, Fit / Fill frame), Audio Options +//! (Breakout to Mono, Extract Audio) and Replace With Clip. +//! +//! Every command mutates through `Session::edit` / `edit_sequence`, so it is one undo step. +//! [`apply_layout`] places these commands (and a few existing ones) in Premiere's menu order. + +use std::collections::{BTreeMap, BTreeSet}; + +use filmcraft_edit as edit; +use filmcraft_project::{ + AudioChannelMap, AudioChannels, BinEntry, ClipId, FieldOptions, FieldProcessing, ItemId, ItemKind, Label, MediaClip, MediaRef, ParamValue, Project, + TimeInterpolation, TrackItem, TrackKind, +}; +use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange, parse_timecode}; +use serde_json::{Value, json}; + +use crate::commands::{ + CommandSpec, always, bad, bool_p, clips_p, f64_p, has_project_selection, has_selection, has_seq, item_p, place_item, source_range, str_p, time_p, u64_p, + with_links, +}; +use crate::{EngineError, Result, Session}; + +type Run = fn(&mut Session, &Value) -> Result; +type Enabled = fn(&Session) -> std::result::Result<(), String>; + +fn spec( + id: &'static str, + label: &'static str, + menu: &'static [&'static str], + shortcut: Option<&'static str>, + params: &'static str, + enabled: Enabled, + run: Run, +) -> CommandSpec { + CommandSpec { id, label, menu, shortcut, params, enabled, run, journal: true } +} + +macro_rules! label_cmd { + ($id:literal, $name:literal, $l:expr) => { + CommandSpec { + id: $id, + label: $name, + menu: &["Edit", "Label"], + shortcut: None, + params: r#"{"items":[id]?,"clips":[id]?}"#, + enabled: can_label, + run: |s, p| apply_label(s, p, $l), + journal: true, + } + }; +} + +/// The new commands. +pub(crate) fn commands() -> Vec { + vec![ + // ---------------- File ---------------- + spec("file.newSequenceFromClip", "Sequence From Clip", &["File", "New"], None, r#"{"items":[id]?}"#, has_clip_items, sequence_from_clip), + spec( + "file.newBinFromSelection", + "Bin From Selection", + &["File", "New"], + Some("Shift+B"), + r#"{"items":[id]?,"name":str?}"#, + has_project_selection, + bin_from_selection, + ), + spec( + "file.newOfflineFile", + "Offline File…", + &["File", "New"], + None, + r#"{"name":str?,"fileName":str?,"tapeName":str?,"video":bool=true,"audio":bool=true,"width":u32?,"height":u32?,"fps":f64?,"sampleRate":u32=48000,"channels":u32=2,"timecode":"HH:MM:SS:FF"?,"seconds":f64=10,"description":str?}"#, + always, + offline_file, + ), + spec("file.closeProject", "Close Project", &["File"], Some("Cmd+Shift+W"), r#"{"force":bool?}"#, always, close_project), + spec("file.saveAll", "Save All", &["File"], None, "{}", always, |s, _| s.execute("file.save", json!({}))), + // ---------------- Edit ---------------- + spec( + "edit.pasteAttributes", + "Paste Attributes…", + &["Edit"], + Some("Cmd+Alt+V"), + r#"{"clips":[id]?,"motion":bool=true,"opacity":bool=true,"timeRemapping":bool=true,"volume":bool=true,"channelVolume":bool=true,"panner":bool=true,"effects":bool|[effectId]=true,"scaleTimes":bool=true}"#, + can_paste_attributes, + paste_attributes, + ), + spec( + "edit.removeAttributes", + "Remove Attributes…", + &["Edit"], + None, + r#"{"clips":[id]?,"motion":bool=true,"opacity":bool=true,"timeRemapping":bool=true,"volume":bool=true,"channelVolume":bool=true,"panner":bool=true,"effects":bool|[effectId]=true}"#, + has_selection, + remove_attributes, + ), + spec("edit.selectAllMatching", "Select All Matching", &["Edit"], None, r#"{"clips":[id]?}"#, has_selection, select_all_matching), + spec("edit.selectLabelGroup", "Select Label Group", &["Edit", "Label"], None, "{}", can_label, select_label_group), + label_cmd!("edit.label.violet", "Violet", Label::Violet), + label_cmd!("edit.label.iris", "Iris", Label::Iris), + label_cmd!("edit.label.caribbean", "Caribbean", Label::Caribbean), + label_cmd!("edit.label.lavender", "Lavender", Label::Lavender), + label_cmd!("edit.label.cerulean", "Cerulean", Label::Cerulean), + label_cmd!("edit.label.forest", "Forest", Label::Forest), + label_cmd!("edit.label.rose", "Rose", Label::Rose), + label_cmd!("edit.label.mango", "Mango", Label::Mango), + label_cmd!("edit.label.purple", "Purple", Label::Purple), + label_cmd!("edit.label.blue", "Blue", Label::Blue), + label_cmd!("edit.label.teal", "Teal", Label::Teal), + label_cmd!("edit.label.magenta", "Magenta", Label::Magenta), + label_cmd!("edit.label.tan", "Tan", Label::Tan), + label_cmd!("edit.label.green", "Green", Label::Green), + label_cmd!("edit.label.brown", "Brown", Label::Brown), + label_cmd!("edit.label.yellow", "Yellow", Label::Yellow), + spec("edit.removeUnused", "Remove Unused", &["Edit"], None, "{}", has_project_items, remove_unused), + spec("edit.consolidateDuplicates", "Consolidate Duplicates", &["Edit"], None, "{}", has_project_items, consolidate_duplicates), + // ---------------- Clip ---------------- + spec( + "clip.makeSubclip", + "Make Subclip…", + &["Clip"], + Some("Cmd+U"), + r#"{"item":id?,"clip":id?,"name":str?,"start":ticks?,"end":ticks?,"startFrame":i64?,"endFrame":i64?,"restrictTrims":bool=true}"#, + can_make_subclip, + make_subclip, + ), + spec( + "clip.editSubclip", + "Edit Subclip…", + &["Clip"], + None, + r#"{"item":id?,"start":ticks?,"end":ticks?,"startFrame":i64?,"endFrame":i64?,"restrictTrims":bool?,"convertToMaster":bool?}"#, + has_subclip, + edit_subclip, + ), + spec( + "clip.audioChannels", + "Audio Channels…", + &["Clip", "Modify"], + Some("Shift+G"), + r#"{"items":[id]?,"format":"mono|stereo|5.1|adaptive","clips":[[channel]]?,"channels":[channel]?}"#, + has_audio_target, + audio_channels, + ), + spec( + "clip.modifyTimecode", + "Timecode…", + &["Clip", "Modify"], + None, + r#"{"item":id?,"timecode":"HH:MM:SS:FF"?,"frame":i64?,"tapeName":str?,"reset":bool?}"#, + has_media_selection, + modify_timecode, + ), + spec( + "clip.frameHoldOptions", + "Frame Hold Options…", + &["Clip", "Video Options"], + None, + r#"{"clips":[id]?,"enabled":bool=true,"holdOn":"sourceTimecode|sequenceTime|in|out|playhead","time":ticks?,"timecode":str?,"holdFilters":bool=false}"#, + has_selection, + frame_hold_options, + ), + spec( + "clip.insertFrameHoldSegment", + "Insert Frame Hold Segment", + &["Clip", "Video Options"], + None, + r#"{"clip":id?,"time":ticks?,"seconds":f64=2}"#, + has_seq, + insert_frame_hold_segment, + ), + spec( + "clip.fieldOptions", + "Field Options…", + &["Clip", "Video Options"], + None, + r#"{"clips":[id]?,"reverseFieldDominance":bool=false,"processing":"none|alwaysDeinterlace|flickerRemoval"}"#, + has_selection, + field_options, + ), + spec( + "clip.timeInterpolation.frameSampling", + "Frame Sampling", + &["Clip", "Video Options", "Time Interpolation"], + None, + r#"{"clips":[id]?}"#, + has_selection, + |s, p| set_interpolation(s, p, TimeInterpolation::FrameSampling), + ), + spec( + "clip.timeInterpolation.frameBlending", + "Frame Blending", + &["Clip", "Video Options", "Time Interpolation"], + None, + r#"{"clips":[id]?}"#, + has_selection, + |s, p| set_interpolation(s, p, TimeInterpolation::FrameBlending), + ), + spec( + "clip.timeInterpolation.opticalFlow", + "Optical Flow", + &["Clip", "Video Options", "Time Interpolation"], + None, + r#"{"clips":[id]?}"#, + has_selection, + |s, p| set_interpolation(s, p, TimeInterpolation::OpticalFlow), + ), + spec( + "clip.setTimeInterpolation", + "Set Time Interpolation", + &[], + None, + r#"{"clips":[id]?,"mode":"frameSampling|frameBlending|opticalFlow"}"#, + has_selection, + |s, p| { + let m = str_p(p, "mode").and_then(TimeInterpolation::from_name).ok_or_else(|| bad("clip.setTimeInterpolation", "need `mode`"))?; + set_interpolation(s, p, m) + }, + ), + spec("clip.fitToFrame", "Fit to frame", &["Clip", "Video Options"], None, r#"{"clips":[id]?}"#, has_selection, |s, p| fit_fill(s, p, false)), + spec("clip.fillFrame", "Fill frame", &["Clip", "Video Options"], None, r#"{"clips":[id]?}"#, has_selection, |s, p| fit_fill(s, p, true)), + spec("clip.breakoutToMono", "Breakout to Mono", &["Clip", "Audio Options"], None, r#"{"items":[id]?}"#, has_audio_items, breakout_to_mono), + spec("clip.extractAudio", "Extract Audio", &["Clip", "Audio Options"], None, r#"{"items":[id]?,"dir":str?}"#, has_extractable, extract_audio), + spec("clip.replaceFromSource", "From Source Monitor", &["Clip", "Replace With Clip"], None, r#"{"clips":[id]?}"#, can_replace_from_source, |s, p| { + replace_with(s, p, Replace::Source) + }), + spec( + "clip.replaceFromSourceMatchFrame", + "From Source Monitor, Match Frame", + &["Clip", "Replace With Clip"], + None, + r#"{"clips":[id]?}"#, + can_replace_from_source, + |s, p| replace_with(s, p, Replace::MatchFrame), + ), + spec("clip.replaceFromBin", "From Bin", &["Clip", "Replace With Clip"], None, r#"{"clips":[id]?,"item":id?}"#, can_replace_from_bin, |s, p| { + replace_with(s, p, Replace::Bin) + }), + ] +} + +/// Where a run of commands goes in the menus (the registry order is the menu order). +enum At { + After(&'static str), + Before(&'static str), +} + +const LABEL_IDS: [&str; 16] = [ + "edit.label.violet", + "edit.label.iris", + "edit.label.caribbean", + "edit.label.lavender", + "edit.label.cerulean", + "edit.label.forest", + "edit.label.rose", + "edit.label.mango", + "edit.label.purple", + "edit.label.blue", + "edit.label.teal", + "edit.label.magenta", + "edit.label.tan", + "edit.label.green", + "edit.label.brown", + "edit.label.yellow", +]; + +/// Add [`commands`] to the registry and arrange the File / Edit / Clip menus in Premiere's order +/// (moving a few existing commands into their submenus' order too). +pub(crate) fn apply_layout(v: &mut Vec) { + let mut pool = commands(); + let mut edit_tail: Vec<&'static str> = vec!["edit.selectLabelGroup"]; + edit_tail.extend(LABEL_IDS); + edit_tail.extend(["edit.removeUnused", "edit.consolidateDuplicates"]); + let layout: Vec<(At, Vec<&'static str>)> = vec![ + (At::After("file.newSequence"), vec!["file.newSequenceFromClip"]), + (At::After("file.newBin"), vec!["file.newBinFromSelection", "file.newOfflineFile"]), + (At::Before("file.save"), vec!["file.closeProject"]), + (At::After("file.saveCopy"), vec!["file.saveAll"]), + (At::After("edit.pasteInsert"), vec!["edit.pasteAttributes", "edit.removeAttributes"]), + (At::After("edit.selectAll"), vec!["edit.selectAllMatching"]), + (At::After("edit.duplicate"), edit_tail), + ( + At::After("clip.rename"), + vec![ + "clip.makeSubclip", + "clip.editSubclip", + "clip.audioChannels", + "clip.interpretFootage", + "clip.modifyTimecode", + "clip.frameHoldOptions", + "clip.frameHold", + "clip.insertFrameHoldSegment", + "clip.fieldOptions", + "clip.timeInterpolation.frameSampling", + "clip.timeInterpolation.frameBlending", + "clip.timeInterpolation.opticalFlow", + "clip.scaleToFrameSize", + "clip.fitToFrame", + "clip.fillFrame", + "clip.audioGain", + "clip.breakoutToMono", + "clip.extractAudio", + ], + ), + (At::After("source.overwrite"), vec!["clip.replaceFromSource", "clip.replaceFromSourceMatchFrame", "clip.replaceFromBin"]), + ]; + for (at, ids) in layout { + let mut run = Vec::new(); + for id in ids { + if let Some(i) = v.iter().position(|c| c.id == id) { + run.push(v.remove(i)); + } else if let Some(i) = pool.iter().position(|c| c.id == id) { + run.push(pool.remove(i)); + } + } + let idx = match at { + At::After(a) => v.iter().position(|c| c.id == a).map(|i| i + 1), + At::Before(b) => v.iter().position(|c| c.id == b), + } + .unwrap_or(v.len()); + v.splice(idx..idx, run); + } + v.extend(pool); +} + +// --------------------------------------------------------------------------------------------- +// enablement +// --------------------------------------------------------------------------------------------- + +fn can_label(s: &Session) -> std::result::Result<(), String> { + if s.state.selection.is_empty() && s.state.project_selection.is_empty() { Err("select clips or project items".into()) } else { Ok(()) } +} +fn has_project_items(s: &Session) -> std::result::Result<(), String> { + if s.project.items.values().any(|i| !matches!(i.kind, ItemKind::Graphic { .. })) { Ok(()) } else { Err("the project is empty".into()) } +} +fn is_clip_item(p: &Project, id: ItemId) -> bool { + p.item(id).is_some_and(|i| matches!(i.kind, ItemKind::Media(_) | ItemKind::Subclip { .. } | ItemKind::Sequence(_) | ItemKind::AdjustmentLayer { .. })) +} +fn has_clip_items(s: &Session) -> std::result::Result<(), String> { + if s.state.project_selection.iter().any(|i| is_clip_item(&s.project, *i)) { Ok(()) } else { Err("select a clip in the Project panel".into()) } +} +fn can_paste_attributes(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + if s.state.clipboard.is_empty() { Err("copy a clip first".into()) } else { Ok(()) } +} +fn subclip_source(s: &Session) -> Option { + s.state + .source_item + .filter(|i| media_root(&s.project, *i).is_some()) + .or_else(|| s.state.project_selection.iter().copied().find(|i| media_root(&s.project, *i).is_some())) +} +fn can_make_subclip(s: &Session) -> std::result::Result<(), String> { + if subclip_source(s).is_some() || !s.state.selection.is_empty() { Ok(()) } else { Err("open a clip in the Source monitor or select one".into()) } +} +fn has_subclip(s: &Session) -> std::result::Result<(), String> { + if s.state.project_selection.iter().any(|i| s.project.item(*i).is_some_and(|it| matches!(it.kind, ItemKind::Subclip { .. }))) { + Ok(()) + } else { + Err("select a subclip in the Project panel".into()) + } +} +fn has_media_selection(s: &Session) -> std::result::Result<(), String> { + if s.state.project_selection.iter().any(|i| s.project.item(*i).and_then(|it| it.as_media()).is_some()) { + Ok(()) + } else { + Err("select a clip in the Project panel".into()) + } +} +fn audio_items(s: &Session) -> Vec { + s.state.project_selection.iter().copied().filter(|i| s.project.item(*i).and_then(|it| it.as_media()).is_some_and(|m| m.info.has_audio())).collect() +} +fn has_audio_items(s: &Session) -> std::result::Result<(), String> { + if audio_items(s).is_empty() { Err("select a clip with audio in the Project panel".into()) } else { Ok(()) } +} +fn selected_audio_clips(s: &Session) -> Vec { + let Some(q) = s.active_sequence() else { return Vec::new() }; + s.state.selection.iter().copied().filter(|c| q.audio_tracks.iter().any(|t| t.item(*c).is_some())).collect() +} +fn has_audio_target(s: &Session) -> std::result::Result<(), String> { + if audio_items(s).is_empty() && selected_audio_clips(s).is_empty() { Err("select a clip with audio".into()) } else { Ok(()) } +} +fn extract_items(s: &Session) -> Vec { + let mut out: Vec = s.state.project_selection.clone(); + if out.is_empty() + && let Some(q) = s.active_sequence() + { + for c in &s.state.selection { + if let Some((_, i)) = q.find_item(*c) + && !out.contains(&i.item) + { + out.push(i.item); + } + } + } + out.retain(|i| media_root(&s.project, *i).is_some_and(|(_, m, _)| m.info.has_audio())); + out +} +fn has_extractable(s: &Session) -> std::result::Result<(), String> { + if extract_items(s).is_empty() { Err("select a clip with audio".into()) } else { Ok(()) } +} +fn can_replace_from_source(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + s.state.source_item.map(|_| ()).ok_or_else(|| "open a clip in the Source monitor".into()) +} +fn can_replace_from_bin(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + if s.state.project_selection.iter().any(|i| is_clip_item(&s.project, *i)) { Ok(()) } else { Err("select a clip in the Project panel".into()) } +} + +// --------------------------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------------------------- + +/// The media item behind `id` (subclips resolve to their parent) and the subclip range. +fn media_root(p: &Project, id: ItemId) -> Option<(ItemId, &MediaClip, Option)> { + match &p.item(id)?.kind { + ItemKind::Media(m) => Some((id, m, None)), + ItemKind::Subclip { parent, range, .. } => media_root(p, *parent).map(|(i, m, _)| (i, m, Some(*range))), + _ => None, + } +} + +/// Frame rate of an item, resolving subclips to their media. +fn item_rate(p: &Project, id: ItemId) -> FrameRate { + match media_root(p, id) { + Some((_, m, _)) => m.frame_rate(), + None => p.item(id).map(|i| i.frame_rate()).unwrap_or_default(), + } +} + +fn items_p(s: &Session, p: &Value) -> Vec { + match p.get("items").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect(), + None => item_p(p, "item").map(|i| vec![i]).unwrap_or_else(|| s.state.project_selection.clone()), + } +} + +/// Fold the undo steps pushed since the history had `n0` entries into one step named `label`. +fn collapse_history(s: &mut Session, n0: usize, label: &str) { + if s.history.undo.len() > n0 { + let first = s.history.undo[n0].1.clone(); + s.history.undo.truncate(n0); + s.history.undo.push((label.to_string(), first)); + } +} + +/// Every item referenced by a clip in any sequence (subclips pull in their parents, nested +/// sequences count as used). +fn used_items(p: &Project) -> BTreeSet { + let mut used = BTreeSet::new(); + for it in p.items.values() { + if let ItemKind::Sequence(q) = &it.kind { + for t in q.all_tracks() { + for i in &t.items { + used.insert(i.item); + } + } + if let Some(m) = &q.merged { + let v = serde_json::to_value(m).unwrap_or_default(); + collect_ids(&v, &mut used, p); + } + } + } + // subclip parents of anything kept + let mut changed = true; + while changed { + changed = false; + for it in p.items.values() { + if let ItemKind::Subclip { parent, .. } = it.kind + && (used.contains(&it.id) || !is_removable(p, it.id)) + && used.insert(parent) + { + changed = true; + } + } + } + used +} + +/// Item ids mentioned in a JSON value (keys named `item`/`items`/`video`/`audio` holding ids). +fn collect_ids(v: &Value, out: &mut BTreeSet, p: &Project) { + match v { + Value::Object(m) => { + for (k, x) in m { + if matches!(k.as_str(), "item" | "items" | "video" | "audio") { + for n in x.as_array().cloned().unwrap_or_else(|| vec![x.clone()]) { + if let Some(id) = n.as_u64().map(ItemId).filter(|i| p.items.contains_key(i)) { + out.insert(id); + } + } + } + collect_ids(x, out, p); + } + } + Value::Array(a) => a.iter().for_each(|x| collect_ids(x, out, p)), + _ => {} + } +} + +/// Remove Unused removes clips only: never sequences or graphic sources. +fn is_removable(p: &Project, id: ItemId) -> bool { + p.item(id).is_some_and(|i| matches!(i.kind, ItemKind::Media(_) | ItemKind::Subclip { .. } | ItemKind::AdjustmentLayer { .. })) +} + +fn remove_items(p: &mut Project, ids: &[ItemId]) { + for i in ids { + p.items.remove(i); + p.root.remove_item(*i); + } +} + +/// Keyframe times of an effect instance shifted / scaled from clip `from` to clip `to` (media time). +fn retime_effect(e: &mut filmcraft_project::EffectInstance, from: &TrackItem, to: &TrackItem, scale: bool) { + let (f0, f1) = (from.source_in, from.source_out()); + let (t0, t1) = (to.source_in, to.source_out()); + let map = |k: Tick| -> Tick { + if scale && f1 > f0 { + let r = (k - f0).0 as f64 / (f1 - f0).0 as f64; + t0 + Tick((r * (t1 - t0).0 as f64).round() as i64) + } else { + t0 + (k - f0) + } + }; + for p in e.params.values_mut() { + for k in &mut p.keyframes { + k.time = map(k.time); + } + } + for m in &mut e.masks { + for p in [&mut m.path, &mut m.feather, &mut m.opacity, &mut m.expansion] { + for k in &mut p.keyframes { + k.time = map(k.time); + } + } + } +} + +// --------------------------------------------------------------------------------------------- +// File +// --------------------------------------------------------------------------------------------- + +fn sequence_from_clip(s: &mut Session, p: &Value) -> Result { + let items: Vec = items_p(s, p).into_iter().filter(|i| is_clip_item(&s.project, *i)).collect(); + let first = *items.first().ok_or_else(|| bad("file.newSequenceFromClip", "select a clip in the Project panel"))?; + let pi = s.project.item(first).ok_or_else(|| bad("file.newSequenceFromClip", "no such item"))?.clone(); + let mut settings = match &pi.kind { + ItemKind::Sequence(q) => q.settings.clone(), + _ => media_root(&s.project, first).map(|(_, m, _)| crate::commands::default_seq_settings_for(&m.info)).unwrap_or_default(), + }; + if let ItemKind::AdjustmentLayer { width, height, rate, .. } = &pi.kind { + settings.width = *width; + settings.height = *height; + settings.frame_rate = *rate; + } + let channels = media_root(&s.project, first).and_then(|(_, m, _)| m.info.audio.as_ref().map(|a| a.channels)).unwrap_or(2); + let n_audio = media_root(&s.project, first).and_then(|(_, m, _)| m.interpret.audio_channels.as_ref().map(|a| a.clips.len())).unwrap_or(1).max(1); + let n0 = s.history.undo.len(); + let name = pi.name.clone(); + let bin = s.project.root.parent_of(first).filter(|b| *b != s.project.root.id); + let seq = s.edit("New Sequence From Clip", |pr, st| { + let id = pr.new_sequence(&name, settings, 1, n_audio.max(1), bin); + if channels == 1 + && let Some(q) = pr.sequence_mut(id) + { + q.settings.audio_master = AudioChannels::Stereo; + } + st.active_sequence = Some(id); + if !st.open_sequences.contains(&id) { + st.open_sequences.push(id); + } + Ok(id) + })?; + let mut at = Tick::ZERO; + let mut placed = Vec::new(); + for item in items { + let Some(range) = item_range(&s.project, item) else { continue }; + let tg = s.targeting(); + let ids = place_item(s, item, range, at, tg.video_dest, tg.audio_dest, false, "New Sequence From Clip", None)?; + at = s.active_sequence().map(|q| q.duration()).unwrap_or(at); + placed.extend(ids.iter().map(|c| c.0)); + } + collapse_history(s, n0, "New Sequence From Clip"); + s.state.selection.clear(); + s.events.push(crate::Event::OpenSequence(seq)); + Ok(json!({"sequence": seq.0, "clips": placed})) +} + +/// The media range an item edits in with: its In/Out marks, a subclip's range, or the whole item. +fn item_range(p: &Project, id: ItemId) -> Option { + let it = p.item(id)?; + let rate = item_rate(p, id); + Some(match &it.kind { + ItemKind::Media(m) => { + let full = if m.info.duration.0 > 0 { m.info.duration } else { p.settings.default_still_duration }; + let a = m.mark_in.unwrap_or(Tick::ZERO); + let b = m.mark_out.map(|o| o + rate.frame_duration()).unwrap_or(full); + TimeRange::from_bounds(a, b.max(a + rate.frame_duration())) + } + ItemKind::Subclip { range, .. } => *range, + ItemKind::Sequence(q) => { + let a = q.mark_in.unwrap_or(Tick::ZERO); + let b = q.mark_out.map(|o| o + q.settings.frame_rate.frame_duration()).unwrap_or(q.duration()); + TimeRange::from_bounds(a, b.max(a + q.settings.frame_rate.frame_duration())) + } + _ => TimeRange::new(Tick::ZERO, it.duration()), + }) +} + +fn bin_from_selection(s: &mut Session, p: &Value) -> Result { + let items = items_p(s, p); + if items.is_empty() { + return Err(bad("file.newBinFromSelection", "select items in the Project panel")); + } + let parent = s.project.root.parent_of(items[0]).filter(|b| *b != s.project.root.id); + let name = match str_p(p, "name") { + Some(n) => n.to_string(), + None => { + let mut n = 1; + let mut names = Vec::new(); + bin_names(&s.project.root, &mut names); + while names.contains(&format!("Bin {n:02}")) { + n += 1; + } + format!("Bin {n:02}") + } + }; + let bin = s.edit("New Bin From Selection", |pr, _| { + let b = pr.add_bin(&name, parent); + pr.move_to_bin(&items, Some(b)); + Ok(b) + })?; + Ok(json!({"bin": bin.0, "moved": items.len()})) +} + +fn bin_names(b: &filmcraft_project::Bin, out: &mut Vec) { + for c in &b.children { + if let BinEntry::Bin(x) = c { + out.push(x.name.clone()); + bin_names(x, out); + } + } +} + +fn offline_file(s: &mut Session, p: &Value) -> Result { + let st = s.active_sequence().map(|q| q.settings.clone()).unwrap_or_default(); + let has_v = bool_p(p, "video").unwrap_or(true); + let has_a = bool_p(p, "audio").unwrap_or(true); + if !has_v && !has_a { + return Err(bad("file.newOfflineFile", "an offline file needs video or audio")); + } + let rate = f64_p(p, "fps").map(FrameRate::from_f64).unwrap_or(st.frame_rate); + let width = u64_p(p, "width").map(|v| v as u32).unwrap_or(st.width); + let height = u64_p(p, "height").map(|v| v as u32).unwrap_or(st.height); + let secs = f64_p(p, "seconds").unwrap_or(10.0).max(rate.frame_duration().seconds()); + let duration = rate.snap_nearest(Tick::from_seconds_f64(secs)).max(rate.frame_duration()); + let start_tc = match str_p(p, "timecode") { + Some(tc) => Some(parse_timecode(tc, rate, false, 0).map_err(|e| bad("file.newOfflineFile", format!("timecode: {e:?}")))?), + None => None, + }; + let file_name = str_p(p, "fileName").map(str::to_string).filter(|n| !n.is_empty()).unwrap_or_else(|| "Offline.mov".into()); + let name = str_p(p, "name").map(str::to_string).filter(|n| !n.is_empty()).unwrap_or_else(|| file_name.clone()); + let info = filmcraft_media::MediaInfo { + name: file_name.clone(), + kind: if has_v { filmcraft_media::MediaKind::Movie } else { filmcraft_media::MediaKind::AudioOnly }, + duration, + video: has_v.then(|| filmcraft_media::VideoStreamInfo { + width, + height, + frame_rate: rate, + par: (1, 1), + codec: String::new(), + pixel_format: String::new(), + color: Default::default(), + has_alpha: false, + bitrate: None, + }), + audio: has_a.then(|| filmcraft_media::AudioStreamInfo { + sample_rate: u64_p(p, "sampleRate").unwrap_or(48_000) as u32, + channels: u64_p(p, "channels").unwrap_or(2).max(1) as u32, + codec: String::new(), + bits_per_sample: None, + }), + container: String::new(), + start_timecode: start_tc, + file_size: None, + }; + let mut meta = BTreeMap::new(); + if let Some(t) = str_p(p, "tapeName").filter(|t| !t.is_empty()) { + meta.insert("Tape Name".to_string(), t.to_string()); + } + if let Some(d) = str_p(p, "description").filter(|t| !t.is_empty()) { + meta.insert("Description".to_string(), d.to_string()); + } + let id = s.edit("New Offline File", |pr, stt| { + let id = pr.add_item( + &name, + if has_v { Label::Iris } else { Label::Caribbean }, + ItemKind::Media(MediaClip { + media: MediaRef::File { path: file_name }, + info, + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: true, + proxy: None, + identity: None, + }), + None, + ); + if let Some(it) = pr.item_mut(id) { + it.metadata = meta; + } + stt.project_selection = vec![id]; + Ok(id) + })?; + Ok(json!({"item": id.0})) +} + +fn close_project(s: &mut Session, p: &Value) -> Result { + if s.is_dirty() && !bool_p(p, "force").unwrap_or(false) { + return Err(EngineError::Other("the project has unsaved changes: save it first, or pass {\"force\": true} to discard them".into())); + } + s.project = std::sync::Arc::new(Project::new("Untitled")); + s.history = Default::default(); + s.history.limit = 200; + s.state = Session::default().state; + if s.path.take().is_some() { + s.previews.reset_temp(); + } + s.revision += 1; + s.saved_revision = s.revision; + s.events.push(crate::Event::ProjectChanged { revision: s.revision }); + Ok(json!({"closed": true})) +} + +// --------------------------------------------------------------------------------------------- +// Edit +// --------------------------------------------------------------------------------------------- + +fn apply_label(s: &mut Session, p: &Value, l: Label) -> Result { + let items = match p.get("items").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect(), + None if p.get("clips").is_some() => Vec::new(), + None => s.state.project_selection.clone(), + }; + let clips = match p.get("clips").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect(), + None if p.get("items").is_some() => Vec::new(), + None => s.state.selection.clone(), + }; + if items.is_empty() && clips.is_empty() { + return Err(EngineError::Other("select clips or project items".into())); + } + let seq = s.state.active_sequence; + let n = s.edit(&format!("Label {}", l.name()), |pr, _| { + let mut n = 0; + for i in &items { + if let Some(it) = pr.item_mut(*i) { + it.label = l; + n += 1; + } + } + if let Some(q) = seq.and_then(|q| pr.sequence_mut(q)) { + for c in &clips { + if let Some((_, it)) = q.find_item_mut(*c) { + it.label = l; + n += 1; + } + } + } + Ok(n) + })?; + Ok(json!({"label": l.name(), "labelled": n})) +} + +fn select_label_group(s: &mut Session, _: &Value) -> Result { + if !s.state.selection.is_empty() { + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let labels: BTreeSet<&str> = s.state.selection.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| i.label.name())).collect(); + let sel: Vec = q.all_tracks().flat_map(|t| t.items.iter()).filter(|i| labels.contains(i.label.name())).map(|i| i.id).collect(); + s.state.selection = sel; + return Ok(json!({"clips": s.state.selection.iter().map(|c| c.0).collect::>()})); + } + let labels: BTreeSet<&str> = s.state.project_selection.iter().filter_map(|i| s.project.item(*i).map(|it| it.label.name())).collect(); + let mut all = Vec::new(); + s.project.root.all_items(&mut all); + let sel: Vec = all.into_iter().filter(|i| s.project.item(*i).is_some_and(|it| labels.contains(it.label.name()))).collect(); + s.state.project_selection = sel; + Ok(json!({"items": s.state.project_selection.iter().map(|i| i.0).collect::>()})) +} + +fn select_all_matching(s: &mut Session, p: &Value) -> Result { + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let sources: BTreeSet = clips_p(s, p).iter().filter_map(|c| q.find_item(*c).map(|(_, i)| i.item)).collect(); + let sel: Vec = q.all_tracks().flat_map(|t| t.items.iter()).filter(|i| sources.contains(&i.item)).map(|i| i.id).collect(); + s.state.selection = sel; + Ok(json!({"clips": s.state.selection.iter().map(|c| c.0).collect::>()})) +} + +fn remove_unused(s: &mut Session, _: &Value) -> Result { + let used = used_items(&s.project); + let unused: Vec = s.project.items.keys().copied().filter(|i| is_removable(&s.project, *i) && !used.contains(i)).collect(); + if unused.is_empty() { + return Ok(json!({"removed": []})); + } + let gone = unused.clone(); + s.edit("Remove Unused", |pr, st| { + remove_items(pr, &gone); + st.project_selection.retain(|i| !gone.contains(i)); + if st.source_item.is_some_and(|i| gone.contains(&i)) { + st.source_item = None; + } + Ok(()) + })?; + for i in &unused { + s.media.remove(*i); + } + Ok(json!({"removed": unused.iter().map(|i| i.0).collect::>()})) +} + +/// Media items that refer to the same file (or generator) with the same interpretation are +/// duplicates; the oldest one is kept and every reference to the others moves to it. +fn consolidate_duplicates(s: &mut Session, _: &Value) -> Result { + let mut groups: BTreeMap> = BTreeMap::new(); + for it in s.project.items.values() { + if let ItemKind::Media(m) = &it.kind { + // same file (or generator), interpretation and streams + let shape = (m.info.video.is_some(), m.info.audio.is_some(), m.info.duration); + let key = serde_json::to_string(&(&m.media, &m.interpret, m.offline, shape)).unwrap_or_default(); + groups.entry(key).or_default().push(it.id); + } + } + let mut remap: BTreeMap = BTreeMap::new(); + for ids in groups.values().filter(|g| g.len() > 1) { + for d in &ids[1..] { + remap.insert(*d, ids[0]); + } + } + if remap.is_empty() { + return Ok(json!({"removed": []})); + } + let map = remap.clone(); + s.edit("Consolidate Duplicates", |pr, st| { + for it in pr.items.values_mut() { + match &mut it.kind { + ItemKind::Sequence(q) => { + for t in q.all_tracks_mut() { + for i in &mut t.items { + if let Some(k) = map.get(&i.item) { + i.item = *k; + } + } + } + } + ItemKind::Subclip { parent, .. } => { + if let Some(k) = map.get(parent) { + *parent = *k; + } + } + _ => {} + } + } + // keep the marks / markers of the kept item; carry markers of duplicates over + let dups: Vec = map.keys().copied().collect(); + for d in &dups { + let markers = pr.item(*d).and_then(|i| i.as_media()).map(|m| m.markers.clone()).unwrap_or_default(); + if let Some(m) = pr.item_mut(map[d]).and_then(|i| i.as_media_mut()) { + for mk in markers { + if !m.markers.iter().any(|x| x.start == mk.start && x.name == mk.name) { + m.markers.push(mk); + } + } + m.markers.sort_by_key(|x| x.start); + } + } + remove_items(pr, &dups); + for i in st.project_selection.iter_mut() { + if let Some(k) = map.get(i) { + *i = *k; + } + } + st.project_selection.dedup(); + if let Some(k) = st.source_item.and_then(|i| map.get(&i)) { + st.source_item = Some(*k); + } + Ok(()) + })?; + for d in remap.keys() { + s.media.remove(*d); + } + Ok(json!({"removed": remap.keys().map(|i| i.0).collect::>(), "kept": remap.values().map(|i| i.0).collect::>()})) +} + +/// Which attributes a Paste / Remove Attributes call touches. +struct AttrSel { + intrinsic: Vec<&'static str>, + /// None = every standard effect; Some(ids) = those effects (Some(empty) = none). + effects: Option>, +} + +fn attr_sel(p: &Value) -> AttrSel { + let on = |k: &str| bool_p(p, k).unwrap_or(true); + let mut intrinsic = Vec::new(); + for (k, id) in [ + ("motion", "motion"), + ("opacity", "opacity"), + ("timeRemapping", "time_remap"), + ("volume", "volume"), + ("channelVolume", "channel_volume"), + ("panner", "panner"), + ] { + if on(k) { + intrinsic.push(id); + } + } + let effects = match p.get("effects") { + Some(Value::Bool(false)) => Some(Vec::new()), + Some(Value::Array(a)) => Some(a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()), + _ => None, + }; + AttrSel { intrinsic, effects } +} + +fn is_standard(e: &filmcraft_project::EffectInstance) -> bool { + e.def().is_some_and(|d| !d.intrinsic) && !filmcraft_project::graphic::is_layer(e) && !e.essential +} + +fn wants_effect(sel: &AttrSel, e: &filmcraft_project::EffectInstance) -> bool { + sel.effects.as_ref().is_none_or(|ids| ids.contains(&e.effect)) +} + +fn paste_attributes(s: &mut Session, p: &Value) -> Result { + let sel = attr_sel(p); + let scale = bool_p(p, "scaleTimes").unwrap_or(true); + let src_v = s.state.clipboard.iter().find(|c| c.0 == TrackKind::Video).map(|c| c.2.clone()); + let src_a = s.state.clipboard.iter().find(|c| c.0 == TrackKind::Audio).map(|c| c.2.clone()); + let targets = with_links(s, &clips_p(s, p)); + let n = s.edit_sequence("Paste Attributes", |q, _, _| { + let mut n = 0; + for c in &targets { + let Some((tid, _)) = q.find_item(*c) else { continue }; + let kind = q.track(tid).map(|t| t.kind).unwrap_or_default(); + let Some(src) = (if kind == TrackKind::Video { src_v.as_ref() } else { src_a.as_ref() }) else { continue }; + let Some((_, dst)) = q.find_item_mut(*c) else { continue }; + let before = dst.clone(); + for id in &sel.intrinsic { + let Some(e) = src.effect(id) else { continue }; + let mut e = e.clone(); + retime_effect(&mut e, src, &before, scale); + match dst.effects.iter_mut().find(|x| x.effect == *id) { + Some(slot) => *slot = e, + None => dst.effects.push(e), + } + } + for e in src.effects.iter().filter(|e| is_standard(e) && wants_effect(&sel, e)) { + let mut e = e.clone(); + retime_effect(&mut e, src, &before, scale); + dst.effects.push(e); + } + if *dst != before { + n += 1; + } + } + Ok(n) + })?; + Ok(json!({"clips": n})) +} + +fn remove_attributes(s: &mut Session, p: &Value) -> Result { + let sel = attr_sel(p); + let targets = with_links(s, &clips_p(s, p)); + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let frame = (seq.settings.width, seq.settings.height); + let sizes: BTreeMap = + targets.iter().filter_map(|c| seq.find_item(*c).map(|(_, i)| (*c, filmcraft_render::source_size(&s.project, i.item).unwrap_or(frame)))).collect(); + let n = s.edit_sequence("Remove Attributes", |q, _, _| { + let mut n = 0; + for c in &targets { + let Some((_, it)) = q.find_item_mut(*c) else { continue }; + let before = it.clone(); + for id in &sel.intrinsic { + if let Some(slot) = it.effects.iter_mut().find(|x| x.effect == *id) + && let Some(def) = slot.def() + { + let mut fresh = def.instance(); + filmcraft_project::resolve_auto_points(&mut fresh, frame, sizes.get(c).copied().unwrap_or(frame)); + *slot = fresh; + } + } + it.effects.retain(|e| !(is_standard(e) && wants_effect(&sel, e))); + if *it != before { + n += 1; + } + } + Ok(n) + })?; + Ok(json!({"clips": n})) +} + +// --------------------------------------------------------------------------------------------- +// Clip: subclips, Modify +// --------------------------------------------------------------------------------------------- + +/// What Make Subclip would subclip: (parent media item, media range, name, label). +fn subclip_plan(s: &Session, p: &Value) -> Result<(ItemId, TimeRange, String, Label)> { + Ok(if let Some(c) = clip_p_or_selection(s, p) { + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let (_, it) = q.find_item(c).ok_or_else(|| bad("clip.makeSubclip", "no such clip"))?; + let (root, _, _) = media_root(&s.project, it.item).ok_or_else(|| bad("clip.makeSubclip", "only media clips can be subclipped"))?; + (root, TimeRange::from_bounds(it.source_in, it.source_out()), it.name.clone(), it.label) + } else { + let item = item_p(p, "item").or_else(|| subclip_source(s)).ok_or_else(|| bad("clip.makeSubclip", "open a clip in the Source monitor"))?; + let (root, m, sub) = media_root(&s.project, item).ok_or_else(|| bad("clip.makeSubclip", "only media clips can be subclipped"))?; + let pi = s.project.item(item).expect("resolved"); + let rate = m.frame_rate(); + let full = sub.unwrap_or(TimeRange::new(Tick::ZERO, if m.info.duration.0 > 0 { m.info.duration } else { s.project.settings.default_still_duration })); + let range = if sub.is_some() { + full + } else { + let a = m.mark_in.unwrap_or(full.start); + let b = m.mark_out.map(|o| o + rate.frame_duration()).unwrap_or(full.end()); + TimeRange::from_bounds(a, b) + }; + (root, range, pi.name.clone(), pi.label) + }) +} + +/// The Make Subclip dialog's defaults: `{name, startFrame, endFrame, fps}` (media frames). +pub fn subclip_defaults(s: &Session, p: &Value) -> Option { + let (parent, range, base, _) = subclip_plan(s, p).ok()?; + let rate = item_rate(&s.project, parent); + Some(json!({"name": format!("{base}.Subclip"), "startFrame": rate.frame_at(range.start), "endFrame": rate.frame_at(range.end()), "fps": rate.as_f64()})) +} + +fn make_subclip(s: &mut Session, p: &Value) -> Result { + let (parent, range, base, label) = subclip_plan(s, p)?; + let rate = item_rate(&s.project, parent); + let a = p + .get("start") + .and_then(Value::as_i64) + .map(Tick) + .or_else(|| p.get("startFrame").and_then(Value::as_i64).map(|f| rate.tick_of(f))) + .unwrap_or(range.start); + let b = + p.get("end").and_then(Value::as_i64).map(Tick).or_else(|| p.get("endFrame").and_then(Value::as_i64).map(|f| rate.tick_of(f))).unwrap_or(range.end()); + if b <= a || a < Tick::ZERO { + return Err(bad("clip.makeSubclip", "the subclip's end must be after its start")); + } + let restrict = bool_p(p, "restrictTrims").unwrap_or(true); + let name = str_p(p, "name").map(str::to_string).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("{base}.Subclip")); + let bin = s.project.root.parent_of(parent).filter(|b| *b != s.project.root.id); + let id = s.edit("Make Subclip", |pr, st| { + let id = pr.add_item(&name, label, ItemKind::Subclip { parent, range: TimeRange::from_bounds(a, b), restrict_trims: restrict }, bin); + st.project_selection = vec![id]; + Ok(id) + })?; + Ok(json!({"item": id.0})) +} + +/// `clip` param, else the timeline selection when nothing is loaded in the Source monitor and no +/// `item` is given. +fn clip_p_or_selection(s: &Session, p: &Value) -> Option { + if let Some(c) = crate::commands::clip_p(p, "clip") { + return Some(c); + } + if item_p(p, "item").is_some() || subclip_source(s).is_some() { + return None; + } + s.state.selection.first().copied() +} + +fn edit_subclip(s: &mut Session, p: &Value) -> Result { + let item = item_p(p, "item") + .or_else(|| s.state.project_selection.iter().copied().find(|i| s.project.item(*i).is_some_and(|it| matches!(it.kind, ItemKind::Subclip { .. })))) + .ok_or_else(|| bad("clip.editSubclip", "select a subclip"))?; + let Some(ItemKind::Subclip { parent, range, restrict_trims }) = s.project.item(item).map(|i| i.kind.clone()) else { + return Err(bad("clip.editSubclip", "not a subclip")); + }; + let rate = item_rate(&s.project, parent); + let a = p + .get("start") + .and_then(Value::as_i64) + .map(Tick) + .or_else(|| p.get("startFrame").and_then(Value::as_i64).map(|f| rate.tick_of(f))) + .unwrap_or(range.start); + let b = + p.get("end").and_then(Value::as_i64).map(Tick).or_else(|| p.get("endFrame").and_then(Value::as_i64).map(|f| rate.tick_of(f))).unwrap_or(range.end()); + if b <= a || a < Tick::ZERO { + return Err(bad("clip.editSubclip", "the subclip's end must be after its start")); + } + let restrict = bool_p(p, "restrictTrims").unwrap_or(restrict_trims); + let convert = bool_p(p, "convertToMaster").unwrap_or(false); + let parent_media = s.project.item(parent).and_then(|i| i.as_media()).cloned(); + s.edit("Edit Subclip", |pr, _| { + let it = pr.item_mut(item).ok_or_else(|| bad("clip.editSubclip", "no such item"))?; + if convert { + let mut m = parent_media.ok_or_else(|| bad("clip.editSubclip", "the subclip's media is gone"))?; + m.mark_in = None; + m.mark_out = None; + m.markers.clear(); + it.kind = ItemKind::Media(m); + } else { + it.kind = ItemKind::Subclip { parent, range: TimeRange::from_bounds(a, b), restrict_trims: restrict }; + } + Ok(()) + })?; + if convert { + s.media.remove(item); + } + Ok(json!({"item": item.0, "converted": convert})) +} + +fn parse_format(f: &str) -> Option { + match f.to_ascii_lowercase().as_str() { + "mono" => Some(AudioChannels::Mono), + "stereo" => Some(AudioChannels::Stereo), + "5.1" | "surround51" | "surround" => Some(AudioChannels::Surround51), + "adaptive" => Some(AudioChannels::Adaptive), + _ => None, + } +} + +fn audio_channels(s: &mut Session, p: &Value) -> Result { + let items: Vec = + items_p(s, p).into_iter().filter(|i| s.project.item(*i).and_then(|it| it.as_media()).is_some_and(|m| m.info.has_audio())).collect(); + if items.is_empty() || p.get("channels").is_some() { + // timeline audio clips: which source channels each plays + let clips = match p.get("clips").and_then(Value::as_array) { + Some(a) if a.iter().all(Value::is_u64) => a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect(), + _ => selected_audio_clips(s), + }; + let chans: Vec = + p.get("channels").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|c| c as u16)).collect()).unwrap_or_default(); + if clips.is_empty() { + return Err(bad("clip.audioChannels", "select a clip with audio")); + } + s.edit_sequence("Modify Audio Channels", |q, _, _| { + for c in &clips { + if let Some((_, it)) = q.find_item_mut(*c) { + it.source_channels = chans.clone(); + } + } + Ok(()) + })?; + return Ok(json!({"clips": clips.iter().map(|c| c.0).collect::>(), "channels": chans})); + } + let format = match str_p(p, "format") { + Some(f) => Some(parse_format(f).ok_or_else(|| bad("clip.audioChannels", "format is mono, stereo, 5.1 or adaptive"))?), + None => None, + }; + let custom: Option>> = p + .get("clips") + .and_then(Value::as_array) + .map(|a| a.iter().map(|c| c.as_array().map(|x| x.iter().filter_map(|v| v.as_u64().map(|n| n as u16)).collect()).unwrap_or_default()).collect()); + let mut out = Vec::new(); + let mut maps = Vec::new(); + for i in &items { + let m = s.project.item(*i).and_then(|it| it.as_media()).expect("filtered"); + let n = m.info.audio.as_ref().map_or(2, |a| a.channels) as u16; + let fmt = format.unwrap_or_else(|| m.interpret.audio_channels.as_ref().map_or(AudioChannels::Stereo, |a| a.format)); + let map = match &custom { + Some(c) => { + if c.is_empty() || c.iter().any(|x| x.is_empty() || x.iter().any(|ch| *ch >= n)) { + return Err(bad("clip.audioChannels", format!("each clip needs source channels between 0 and {}", n - 1))); + } + AudioChannelMap { format: fmt, clips: c.clone() } + } + None => AudioChannelMap::for_format(fmt, n), + }; + out.push(json!({"item": i.0, "format": format!("{:?}", map.format), "clips": map.clips})); + maps.push((*i, map)); + } + s.edit("Modify Audio Channels", |pr, _| { + for (i, map) in maps { + if let Some(m) = pr.item_mut(i).and_then(|it| it.as_media_mut()) { + // the default stereo pair is no override + m.interpret.audio_channels = if map == AudioChannelMap::for_format(AudioChannels::Stereo, 2) { None } else { Some(map) }; + } + } + Ok(()) + })?; + Ok(json!({"items": out})) +} + +fn modify_timecode(s: &mut Session, p: &Value) -> Result { + let item = item_p(p, "item") + .or_else(|| s.state.project_selection.iter().copied().find(|i| s.project.item(*i).and_then(|it| it.as_media()).is_some())) + .ok_or_else(|| bad("clip.modifyTimecode", "select a clip"))?; + let m = s.project.item(item).and_then(|i| i.as_media()).ok_or_else(|| bad("clip.modifyTimecode", "not a media clip"))?; + let rate = m.frame_rate(); + let reset = bool_p(p, "reset").unwrap_or(false); + let frame = if let Some(tc) = str_p(p, "timecode") { + Some(parse_timecode(tc, rate, false, 0).map_err(|e| bad("clip.modifyTimecode", format!("timecode: {e:?}")))?) + } else { + p.get("frame").and_then(Value::as_i64) + }; + let tape = str_p(p, "tapeName").map(str::to_string); + if frame.is_none() && tape.is_none() && !reset { + return Err(bad("clip.modifyTimecode", "need `timecode`, `frame`, `tapeName` or `reset`")); + } + if frame.is_some_and(|f| f < 0) { + return Err(bad("clip.modifyTimecode", "timecode must not be negative")); + } + const FILE_TC: &str = "File Timecode"; + let r = s.edit("Modify Timecode", |pr, _| { + let it = pr.item_mut(item).ok_or_else(|| bad("clip.modifyTimecode", "no such item"))?; + let cur = it.as_media().and_then(|m| m.info.start_timecode); + if reset { + // back to the file's own timecode ("none" = the file had none) + if let Some(orig) = it.metadata.remove(FILE_TC) + && let Some(m) = it.as_media_mut() + { + m.info.start_timecode = orig.parse::().ok(); + } + } else if let Some(f) = frame { + it.metadata.entry(FILE_TC.to_string()).or_insert_with(|| cur.map(|c| c.to_string()).unwrap_or_else(|| "none".into())); + if let Some(m) = it.as_media_mut() { + m.info.start_timecode = Some(f); + } + } + if let Some(t) = &tape { + if t.is_empty() { + it.metadata.remove("Tape Name"); + } else { + it.metadata.insert("Tape Name".into(), t.clone()); + } + } + Ok(it.as_media().and_then(|m| m.info.start_timecode)) + })?; + Ok(json!({"item": item.0, "startTimecode": r})) +} + +// --------------------------------------------------------------------------------------------- +// Clip: Video Options +// --------------------------------------------------------------------------------------------- + +fn video_clips(s: &Session, p: &Value) -> Vec { + let Some(q) = s.active_sequence() else { return Vec::new() }; + clips_p(s, p).into_iter().filter(|c| q.video_tracks.iter().any(|t| t.item(*c).is_some())).collect() +} + +fn frame_hold_options(s: &mut Session, p: &Value) -> Result { + let clips = video_clips(s, p); + if clips.is_empty() { + return Err(bad("clip.frameHoldOptions", "select a video clip")); + } + let enabled = bool_p(p, "enabled").unwrap_or(true); + let hold_filters = bool_p(p, "holdFilters").unwrap_or(false); + let mode = str_p(p, "holdOn").unwrap_or("in").to_string(); + let ph = s.playhead(); + let seq_t = time_p(s, p, ""); + let tc = str_p(p, "timecode").map(str::to_string); + let rates: BTreeMap = { + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + clips + .iter() + .filter_map(|c| { + let (_, it) = q.find_item(*c)?; + let start_tc = media_root(&s.project, it.item).and_then(|(_, m, _)| m.info.start_timecode).unwrap_or(0); + Some((*c, (item_rate(&s.project, it.item), start_tc))) + }) + .collect() + }; + let raw_time = p.get("time").and_then(Value::as_i64).map(Tick); + let held = s.edit_sequence("Frame Hold Options", |q, _, _| { + let mut out = Vec::new(); + for c in &clips { + let Some((_, it)) = q.find_item_mut(*c) else { continue }; + if !enabled { + it.frame_hold = None; + it.hold_filters = false; + continue; + } + let last = it.end() - Tick(1); + let unheld = |it: &TrackItem, t: Tick| it.moving_source_time_at(t.clamp(it.start, last)); + let h = match mode.as_str() { + "in" => unheld(it, it.start), + "out" => unheld(it, last), + "playhead" => unheld(it, ph), + "sequenceTime" => unheld(it, seq_t.ok_or_else(|| bad("clip.frameHoldOptions", "sequenceTime needs `time`/`timecode`"))?), + "sourceTimecode" => { + let (rate, start_tc) = rates[c]; + match (&tc, raw_time) { + (Some(tc), _) => { + let f = parse_timecode(tc, rate, false, 0).map_err(|e| bad("clip.frameHoldOptions", format!("timecode: {e:?}")))?; + rate.tick_of(f - start_tc) + } + (None, Some(t)) => t, + (None, None) => return Err(bad("clip.frameHoldOptions", "sourceTimecode needs `timecode` or `time` (media ticks)")), + } + } + m => return Err(bad("clip.frameHoldOptions", format!("unknown holdOn `{m}`"))), + }; + let rate = rates.get(c).map(|r| r.0).unwrap_or_default(); + let h = rate.snap(h.max(Tick::ZERO)); + it.frame_hold = Some(h); + it.hold_filters = hold_filters; + out.push(json!({"clip": c.0, "hold": h.0})); + } + Ok(out) + })?; + Ok(json!({"clips": held})) +} + +/// Add Frame Hold: the selected clips (or the targeted video clip under the playhead) hold the +/// frame at the playhead from there to their end; the clip is split at the playhead first. +pub(crate) fn add_frame_hold(s: &mut Session, p: &Value) -> Result { + let t = time_p(s, p, "").unwrap_or(s.playhead()); + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let mut clips: Vec = video_clips(s, p).into_iter().filter(|c| q.find_item(*c).is_some_and(|(_, i)| i.start <= t && t < i.end())).collect(); + if clips.is_empty() && p.get("clips").is_none() && p.get("clip").is_none() { + let tg = s.targeting().targeted; + clips = q.video_tracks.iter().filter(|tr| tg.contains(&tr.id) && !tr.locked).filter_map(|tr| tr.item_at(t).map(|i| i.id)).take(1).collect(); + } + if clips.is_empty() { + return Err(EngineError::Other("place the playhead over a selected video clip".into())); + } + let held = s.edit_sequence("Add Frame Hold", |q, ctx, st| { + let mut out = Vec::new(); + for c in &clips { + let Some((_, it)) = q.find_item(*c) else { continue }; + let hold = it.moving_source_time_at(t); + let target = if it.start < t { edit::razor_items(q, &[*c], t, ctx).first().copied() } else { Some(*c) }; + if let Some(id) = target + && let Some((_, r)) = q.find_item_mut(id) + { + r.frame_hold = Some(hold); + out.push(id); + } + } + st.selection = out.clone(); + Ok(out) + })?; + Ok(json!({"clips": held.iter().map(|c| c.0).collect::>()})) +} + +fn insert_frame_hold_segment(s: &mut Session, p: &Value) -> Result { + let t = time_p(s, p, "").unwrap_or(s.playhead()); + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let rate = q.settings.frame_rate; + let pick = |c: &ClipId| q.video_tracks.iter().find_map(|tr| tr.item(*c).filter(|i| i.start <= t && t < i.end()).map(|i| (tr.id, i.clone()))); + let mut hit = crate::commands::clip_p(p, "clip").and_then(|c| pick(&c)).or_else(|| s.state.selection.iter().find_map(pick)); + if hit.is_none() && crate::commands::clip_p(p, "clip").is_none() { + let tg = s.targeting().targeted; + hit = q.video_tracks.iter().filter(|tr| tg.contains(&tr.id) && !tr.locked).find_map(|tr| tr.item_at(t).map(|i| (tr.id, i.clone()))); + } + let (track, it) = hit.ok_or_else(|| EngineError::Other("place the playhead over a video clip".into()))?; + let secs = f64_p(p, "seconds").unwrap_or(2.0); + let dur = rate.snap_nearest(Tick((secs * TICKS_PER_SECOND as f64).round() as i64)).max(rate.frame_duration()); + let hold = it.moving_source_time_at(t); + let id = s.edit_sequence("Insert Frame Hold Segment", |q, ctx, st| { + let mut seg = it.clone(); + seg.id = ClipId(ctx.alloc()); + seg.start = t; + seg.duration = dur; + seg.source_in = hold; + seg.frame_hold = Some(hold); + seg.link = None; + seg.markers.clear(); + let ids = edit::insert(q, vec![(track, seg)], ctx)?; + st.selection = ids.clone(); + Ok(ids[0]) + })?; + Ok(json!({"clip": id.0, "duration": dur.0})) +} + +fn field_options(s: &mut Session, p: &Value) -> Result { + let clips = video_clips(s, p); + if clips.is_empty() { + return Err(bad("clip.fieldOptions", "select a video clip")); + } + let processing = match str_p(p, "processing").unwrap_or("none") { + "none" => FieldProcessing::None, + "alwaysDeinterlace" => FieldProcessing::AlwaysDeinterlace, + "flickerRemoval" => FieldProcessing::FlickerRemoval, + x => return Err(bad("clip.fieldOptions", format!("unknown processing `{x}`"))), + }; + let fo = FieldOptions { reverse_field_dominance: bool_p(p, "reverseFieldDominance").unwrap_or(false), processing }; + s.edit_sequence("Field Options", |q, _, _| { + for c in &clips { + if let Some((_, it)) = q.find_item_mut(*c) { + it.field_options = (fo != FieldOptions::default()).then_some(fo); + } + } + Ok(()) + })?; + Ok(json!({"clips": clips.len()})) +} + +fn set_interpolation(s: &mut Session, p: &Value, m: TimeInterpolation) -> Result { + let clips = video_clips(s, p); + if clips.is_empty() { + return Err(bad("clip.setTimeInterpolation", "select a video clip")); + } + s.edit_sequence(&format!("Time Interpolation: {}", m.label()), |q, _, _| { + for c in &clips { + if let Some((_, it)) = q.find_item_mut(*c) { + it.time_interpolation = m; + } + } + Ok(()) + })?; + Ok(json!({"mode": m.name(), "clips": clips.len()})) +} + +/// Fit to frame (the whole clip visible) / Fill frame (the frame covered): sets Motion ▸ Scale +/// and centres the clip. +fn fit_fill(s: &mut Session, p: &Value, fill: bool) -> Result { + let clips = video_clips(s, p); + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let (fw, fh) = (q.settings.width as f64, q.settings.height as f64); + let sizes: Vec<(ClipId, (u32, u32))> = + clips.iter().filter_map(|c| q.find_item(*c).and_then(|(_, i)| filmcraft_render::source_size(&s.project, i.item)).map(|sz| (*c, sz))).collect(); + if sizes.is_empty() { + return Err(bad(if fill { "clip.fillFrame" } else { "clip.fitToFrame" }, "select a video clip")); + } + let label = if fill { "Fill Frame" } else { "Fit to Frame" }; + let out = s.edit_sequence(label, |q, _, _| { + let mut out = Vec::new(); + for (c, (w, h)) in &sizes { + let Some((_, it)) = q.find_item_mut(*c) else { continue }; + let (sx, sy) = (fw / (*w).max(1) as f64, fh / (*h).max(1) as f64); + let pct = (if fill { sx.max(sy) } else { sx.min(sy) }) * 100.0; + it.scale_to_frame = false; + let Some(m) = it.effect_mut("motion") else { continue }; + for (k, v) in [ + ("scale", ParamValue::Float(pct)), + ("uniform_scale", ParamValue::Bool(true)), + ("position", ParamValue::Vec2(filmcraft_geom::Vec2::new(fw / 2.0, fh / 2.0))), + ("anchor", ParamValue::Vec2(filmcraft_geom::Vec2::new(*w as f64 / 2.0, *h as f64 / 2.0))), + ] { + match m.param_mut(k) { + Some(prm) => { + prm.keyframes.clear(); + prm.value = v; + } + None => { + m.params.insert(k.to_string(), filmcraft_project::Param::new(v)); + } + } + } + out.push(json!({"clip": c.0, "scale": pct})); + } + Ok(out) + })?; + Ok(json!({"clips": out})) +} + +// --------------------------------------------------------------------------------------------- +// Clip: Audio Options +// --------------------------------------------------------------------------------------------- + +fn breakout_to_mono(s: &mut Session, p: &Value) -> Result { + let items: Vec = + items_p(s, p).into_iter().filter(|i| s.project.item(*i).and_then(|it| it.as_media()).is_some_and(|m| m.info.has_audio())).collect(); + if items.is_empty() { + return Err(bad("clip.breakoutToMono", "select a clip with audio in the Project panel")); + } + let made = s.edit("Breakout to Mono", |pr, st| { + let mut made = Vec::new(); + for i in &items { + let Some(it) = pr.item(*i).cloned() else { continue }; + let Some(m) = it.as_media() else { continue }; + let n = m.info.audio.as_ref().map_or(1, |a| a.channels).max(1) as u16; + let bin = pr.root.parent_of(*i).filter(|b| *b != pr.root.id); + for c in 0..n { + let suffix = match (n, c) { + (2, 0) => "Left".to_string(), + (2, 1) => "Right".to_string(), + _ => format!("Ch {}", c + 1), + }; + let mut mm = m.clone(); + mm.interpret.audio_channels = Some(AudioChannelMap { format: AudioChannels::Mono, clips: vec![vec![c]] }); + mm.mark_in = None; + mm.mark_out = None; + mm.markers.clear(); + mm.proxy = None; + let id = pr.add_item(&format!("{} {suffix}", it.name), it.label, ItemKind::Media(mm), bin); + made.push(id); + } + } + st.project_selection = made.clone(); + Ok(made) + })?; + Ok(json!({"items": made.iter().map(|i| i.0).collect::>()})) +} + +fn extract_audio(s: &mut Session, p: &Value) -> Result { + let items: Vec = match p.get("items") { + Some(_) => items_p(s, p), + None => extract_items(s), + }; + if items.is_empty() { + return Err(bad("clip.extractAudio", "select a clip with audio")); + } + let n0 = s.history.undo.len(); + let mut out = Vec::new(); + for item in items { + let (root, m, sub) = media_root(&s.project, item).ok_or_else(|| bad("clip.extractAudio", "not a media clip"))?; + let a = m.info.audio.clone().ok_or_else(|| bad("clip.extractAudio", "the clip has no audio"))?; + let range = sub.unwrap_or(TimeRange::new(Tick::ZERO, m.info.duration)); + let dir = match str_p(p, "dir") { + Some(d) => d.to_string(), + None => s + .path + .as_deref() + .and_then(|x| std::path::Path::new(x).parent()) + .map(|d| d.to_string_lossy().into_owned()) + .or_else(|| match &m.media { + MediaRef::File { path } => std::path::Path::new(path).parent().map(|d| d.to_string_lossy().into_owned()), + _ => None, + }) + .ok_or_else(|| EngineError::Other("save the project first: extracted audio is written next to it".into()))?, + }; + let pi = s.project.item(item).expect("resolved"); + let name = pi.name.clone(); + let stem = std::path::Path::new(&name).file_stem().map(|x| x.to_string_lossy().into_owned()).unwrap_or_else(|| name.clone()); + let src = s.media.full_res_source(&s.project, root, &*s.services).ok_or_else(|| EngineError::Other(format!("{name}: media is offline")))?; + let sr = a.sample_rate.max(8000); + let start = range.start.to_units_floor(sr as i64); + let frames = (range.end().to_units_floor(sr as i64) - start).max(0) as usize; + let buf = src.audio(start, frames, sr).map_err(|e| EngineError::Other(format!("{name}: {e}")))?; + let ch = buf.channel_count().max(1); + let mut inter = Vec::with_capacity(frames * ch); + for i in 0..frames { + for c in 0..ch { + inter.push(buf.channels.get(c).and_then(|x| x.get(i)).copied().unwrap_or(0.0)); + } + } + let bytes = filmcraft_media::wav::write_wav16(&inter, ch as u16, sr); + let mut path = std::path::Path::new(&dir).join(format!("{stem} Audio Extracted.wav")); + let mut k = 2; + while s.services.file_size(&path.to_string_lossy()).is_ok() { + path = std::path::Path::new(&dir).join(format!("{stem} Audio Extracted {k}.wav")); + k += 1; + } + let path_s = path.to_string_lossy().into_owned(); + s.services.write_file(&path_s, &bytes).map_err(|e| EngineError::Other(format!("{path_s}: {e}")))?; + let bin = s.project.root.parent_of(item).filter(|b| *b != s.project.root.id); + let id = crate::commands::import_bytes(s, &path_s, bytes.into(), bin)?; + out.push(json!({"item": id.0, "path": path_s, "from": item.0})); + } + collapse_history(s, n0, "Extract Audio"); + Ok(json!({"extracted": out})) +} + +// --------------------------------------------------------------------------------------------- +// Clip: Replace With Clip +// --------------------------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Replace { + Source, + MatchFrame, + Bin, +} + +fn replace_with(s: &mut Session, p: &Value, how: Replace) -> Result { + let cmd = match how { + Replace::Source => "clip.replaceFromSource", + Replace::MatchFrame => "clip.replaceFromSourceMatchFrame", + Replace::Bin => "clip.replaceFromBin", + }; + let new_item = match how { + Replace::Bin => item_p(p, "item").or_else(|| s.state.project_selection.iter().copied().find(|i| is_clip_item(&s.project, *i))), + _ => s.state.source_item, + } + .ok_or_else(|| bad(cmd, "no replacement clip"))?; + let pi = s.project.item(new_item).ok_or_else(|| bad(cmd, "no such item"))?.clone(); + let (has_v, has_a) = (pi.has_video(), pi.has_audio()); + // where the replacement starts in its media: Source In (or the subclip / item start) + let in_point = match how { + Replace::Bin => item_range(&s.project, new_item).map(|r| r.start).unwrap_or_default(), + _ => source_range(s).map(|(_, r)| r.start).unwrap_or_default(), + }; + let src_ph = s.state.source_playhead; + let ph = s.playhead(); + let targets = with_links(s, &clips_p(s, p)); + let src_channels = pi.as_media().and_then(|m| m.interpret.audio_channels.as_ref()).and_then(|a| a.clips.first().cloned()).unwrap_or_default(); + let replaced = s.edit_sequence("Replace With Clip", |q, _, _| { + let mut out = Vec::new(); + for c in &targets { + let Some((tid, _)) = q.find_item(*c) else { continue }; + let kind = q.track(tid).map(|t| t.kind).unwrap_or_default(); + if (kind == TrackKind::Video && !has_v) || (kind == TrackKind::Audio && !has_a) { + continue; + } + let Some((_, it)) = q.find_item_mut(*c) else { continue }; + let src_in = match how { + // the Source monitor frame lines up with the sequence playhead + Replace::MatchFrame => src_ph - Tick(((ph - it.start).0 as f64 * it.speed.abs()).round() as i64), + _ => in_point, + }; + if src_in < Tick::ZERO { + return Err(EngineError::Other("not enough media before the Source monitor playhead to match the frame".into())); + } + it.item = new_item; + it.name = pi.name.clone(); + it.label = pi.label; + it.source_in = src_in; + it.frame_hold = None; + it.multicam = None; + if kind == TrackKind::Audio { + it.source_channels = src_channels.clone(); + } + out.push(c.0); + } + if out.is_empty() { + return Err(EngineError::Other("the replacement has no video or audio for the selected clips".into())); + } + Ok(out) + })?; + Ok(json!({"clips": replaced, "item": new_item.0})) +} diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs new file mode 100644 index 00000000..a0d5e3b4 --- /dev/null +++ b/crates/engine/src/clip_ops_tests.rs @@ -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 { + 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::>().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 = 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 = 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 = 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 = 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 = 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::() / 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"); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 00580c8f..9db12448 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -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> = 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 = 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 { })?; Ok(json!({"items": ids.iter().map(|i| i.0).collect::>()})) }), - 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 { "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 { 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 { }; 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 { v.extend(crate::masks::commands()); v.extend(crate::presets::commands()); v.extend(crate::transcript::commands()); - // Labels as individual commands (Edit ▸ Label ▸ ) - for l in Label::ALL { - let _ = l; - } + // Edit ▸ Label ▸ , 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 } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4379cbed..aa392e95 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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 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 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, } } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 469a4827..42e86f5f 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -48,7 +48,7 @@ pub type Migration = fn(Value) -> Result; /// `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 { 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 { + 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] diff --git a/crates/format/tests/fixtures.rs b/crates/format/tests/fixtures.rs index 252e6cf8..5505f0fd 100644 --- a/crates/format/tests/fixtures.rs +++ b/crates/format/tests/fixtures.rs @@ -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; diff --git a/crates/format/tests/fixtures/v8-subclip.fcproj b/crates/format/tests/fixtures/v8-subclip.fcproj new file mode 100644 index 00000000..f8a67d31 --- /dev/null +++ b/crates/format/tests/fixtures/v8-subclip.fcproj @@ -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 + } +} diff --git a/crates/interchange/src/common.rs b/crates/interchange/src/common.rs index e886b3b0..b5144284 100644 --- a/crates/interchange/src/common.rs +++ b/crates/interchange/src/common.rs @@ -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(), } } diff --git a/crates/project/src/essential.rs b/crates/project/src/essential.rs index 9fd2bb1e..f2b87609 100644 --- a/crates/project/src/essential.rs +++ b/crates/project/src/essential.rs @@ -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(), } } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 64082749..93f4b807 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -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, + /// 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, +} + +/// 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>, +} + +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, + /// 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, + /// 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, +} + +/// 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::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, }) } diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index d05d59e7..0191c0a5 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -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() }; diff --git a/crates/render/src/image.rs b/crates/render/src/image.rs index e1c185e1..7c525c4f 100644 --- a/crates/render/src/image.rs +++ b/crates/render/src/image.rs @@ -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; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e31d1a67..f44e422e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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; diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index bdaf1f7b..79c25f1a 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -143,7 +143,9 @@ fn push_item( extra_opacity: f32, out: &mut Vec, ) { - 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 }); diff --git a/crates/render/src/preview.rs b/crates/render/src/preview.rs index a7e9dfc9..d41d5548 100644 --- a/crates/render/src/preview.rs +++ b/crates/render/src/preview.rs @@ -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); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 53ed7938..9e39f018 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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" => { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 807a4452..cb5096bd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -151,6 +151,9 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val } return r; } + if let Some(r) = crate::panels::clip_dialogs::route(app, id, ¶ms) { + return r; + } if let Some(r) = crate::panels::media_dialogs::route(app, id, ¶ms) { if let Err(e) = &r { app.ui.status = e.clone(); @@ -464,27 +467,7 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { if mine.is_empty() { ui.add_enabled(false, egui::Button::new("(empty)")); } - let mut subs: Vec<&str> = Vec::new(); - for it in &mine { - if it.path.len() > 1 { - let sub = it.path[1].as_str(); - if !subs.contains(&sub) { - subs.push(sub); - ui.menu_button(sub, |ui| { - ui.set_min_width(220.0); - for s in mine.iter().filter(|x| x.path.get(1).map(String::as_str) == Some(sub)) { - if menu_entry(ui, s) { - clicked = Some(s.id.clone()); - ui.close(); - } - } - }); - } - } else if menu_entry(ui, it) { - clicked = Some(it.id.clone()); - ui.close(); - } - } + menu_level(ui, &mine, 1, &mut clicked); }); } }); @@ -493,6 +476,28 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { } } +/// One menu level: items whose path ends here, and a submenu (at its first item's position) for +/// each deeper path segment, recursively (e.g. Clip ▸ Video Options ▸ Time Interpolation). +fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option) { + 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() }; diff --git a/crates/ui-egui/src/panels/clip_dialogs.rs b/crates/ui-egui/src/panels/clip_dialogs.rs new file mode 100644 index 00000000..f3f0fda0 --- /dev/null +++ b/crates/ui-egui/src/panels/clip_dialogs.rs @@ -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 (`

` = the dialog prefix below, `` = a parameter): +//! - every dialog: `

.ok`, `

.cancel`, and `

.` for each control; +//! - Paste Attributes `pasteAttributes.*`: `motion`, `opacity`, `timeRemapping`, `volume`, +//! `channelVolume`, `panner`, `effects`, `effect.`, `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.`, `count`, +//! `clip..ch.` (n, c 0-based); +//! - Timecode `timecode.*`: `timecode`, `tapeName`, `reset`; +//! - Frame Hold Options `frameHold.*`: `enabled`, `holdOn.`, +//! `timecode`, `holdFilters`; +//! - Field Options `fieldOptions.*`: `reverseFieldDominance`, `processing.`; +//! - 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, r: &egui::Response, label: impl Into) { + 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> { + 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::>()}); + 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, 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 = 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> = 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 = 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 +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 2763cbdd..217c3353 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -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); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0bad2b55..f959783c 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4eda7e15..ba71b96e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -432,6 +432,23 @@ pub struct UiState { /// Open guide dialog (Add Guide / Save Guides as Template / Manage Guides). #[serde(default)] pub guide_dialog: Option, + /// 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, +} + +/// 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, } } } diff --git a/crates/ui-egui/tests/clip_dialogs_ui.rs b/crates/ui-egui/tests/clip_dialogs_ui.rs new file mode 100644 index 00000000..e0242402 --- /dev/null +++ b/crates/ui-egui/tests/clip_dialogs_ui.rs @@ -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, +} + +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()); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0061b76b..b4ad1381 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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 | diff --git a/docs/project-files.md b/docs/project-files.md index 56df29e5..4d1424d4 100644 --- a/docs/project-files.md +++ b/docs/project-files.md @@ -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.