From 1db60f8edcea5bbeb2c8a86fd78f5d73444267fd Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 11:36:00 -0400 Subject: [PATCH] =?UTF-8?q?M12.3:=20Multi-Camera=20view=20(angle=20grid=20?= =?UTF-8?q?at=20reduced=20resolution=20as=20one=20prefetched=20frame=20job?= =?UTF-8?q?,=20click=20/=20Ctrl-click=20to=20switch,=20live=20recording=20?= =?UTF-8?q?while=20playing=20with=201=E2=80=939,=20Record=20toggle=200,=20?= =?UTF-8?q?Shift+0=20view=20toggle),=20Create=20Multi-Camera=20/=20Synchro?= =?UTF-8?q?nize=20/=20Merge=20Clips=20dialogs,=20[MCn]=20clip=20labels=20a?= =?UTF-8?q?nd=20angle=20thumbnails;=20headless=20UI=20tests=20with=20snaps?= =?UTF-8?q?hots;=20grid=20perf=20test;=20ROADMAP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 +- crates/engine/src/multicam_tests.rs | 7 +- crates/ui-egui/src/frames.rs | 4 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/menus.rs | 8 + crates/ui-egui/src/panels/dialogs.rs | 1 + crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/monitor.rs | 20 +- crates/ui-egui/src/panels/multicam.rs | 381 ++++++++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 17 +- crates/ui-egui/src/state.rs | 61 ++++- crates/ui-egui/tests/multicam_ui.rs | 301 ++++++++++++++++++++ 12 files changed, 801 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/src/panels/multicam.rs create mode 100644 crates/ui-egui/tests/multicam_ui.rs diff --git a/ROADMAP.md b/ROADMAP.md index c979d906..e5b2f255 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, 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; 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 | | M4 | Playback | 🟡 | Audio-clock master, prefetch, J/K/L, dropped-frame stats, playback resolution | Render previews, render bar, 4K/8K tuning | 8–12 | | M5 | Effects, keyframes, GPU | 🟡 | ~60 CPU effects, 30 transitions, keyframes + value/velocity graphs, wgpu compositor | Full ~150-effect catalogue, WGSL parity for all, masks + tracking, adjustment layers, presets, Warp Stabilizer, Morph Cut | 30–45 | | 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 | @@ -48,6 +48,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining ## Log +- **2026-10-01 (M12 multicam):** Synchronize / Merge Clips / Create Multi-Camera Source Sequence (sync by In, Out, timecode ± hours, clip markers, or audio: GCC-PHAT cross-correlation, offsets recovered to the sample on noisy multi-mic recordings), multi-camera clips (angle render, Switch Audio), Program monitor Multi-Camera view with live switching (keys 1–9, one undo step per pass), Ctrl+1–9 cuts, Enable / Flatten / Edit Cameras, project schema v7. Fixed: nested sequence audio was silent. - **2026-10-01 (evening):** M11 done: offline media with our own slate and the Link Media dialog (fingerprint-checked relink, folder remap, search), proxies (create in background / attach / toggle; export stays full-res) with ingest settings, Project Manager (collect, consolidate + transcode). Fixed: GOP cache deadlock when a rayon decoder inside a parallel export re-entered the same source. - **2026-10-01 (M7.4):** Essential Sound panel: Dialogue/Music/SFX/Ambience types, Loudness auto-match (BS.1770, exact to the target), Repair (noise, rumble, hum, new DeEsser and spectral DeReverb), Clarity (dynamics, EQ presets, Enhance Speech DSP chain), Creative reverb / stereo width, ducking that writes Volume keyframes, presets; all `essentialSound.*` commands, effects visible and keyframable in Effect Controls. - **2026-10-01 (night):** M8 colour: camera log curves and gamuts from published specs, colour-managed pipeline (PQ/HLG working spaces, Interpret Footage, BT.2390 tone mapping), HDR export signalling verified with ffprobe, LUTs (.cube/.3dl, tetrahedral CPU + WGSL, project library, Lumetri Input LUT / Look), Colour Match, Lumetri section bypass, HDR scopes. diff --git a/crates/engine/src/multicam_tests.rs b/crates/engine/src/multicam_tests.rs index 1d0e0075..66c13d1a 100644 --- a/crates/engine/src/multicam_tests.rs +++ b/crates/engine/src/multicam_tests.rs @@ -633,8 +633,11 @@ fn perf_grid_of_four_1080p_angles() { .iter() .map(|src| { filmcraft_testkit::fixtures::generate(&dir.join(format!("perf-{src}.mp4")), |tmp| { - let v = format!("{src}=s=1920x1080:r=24:d=4"); - ff(&ffmpeg, &["-f", "lavfi", "-i", &v, "-c:v", "libx264", "-preset", "veryfast", "-g", "48", "-pix_fmt", "yuv420p", &tmp.to_string_lossy()]) + let v = format!("{src}=s=1920x1080:r=24"); + ff( + &ffmpeg, + &["-f", "lavfi", "-i", &v, "-t", "4", "-c:v", "libx264", "-preset", "veryfast", "-g", "48", "-pix_fmt", "yuv420p", &tmp.to_string_lossy()], + ) }) .expect("fixture") }) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 99874419..fcdc471a 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -26,6 +26,9 @@ pub enum Target { Item(ItemId), /// A GPU frame plan of a sequence (decoded layers + transforms), composited on the GPU. SequencePlan(ItemId), + /// The Multi-Camera view's angle grid of a multi-camera source at its time (all shown angles, + /// each at the job's scale). + MulticamGrid(ItemId), } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -707,6 +710,7 @@ fn render_job(job: &Job, pool: &Arc, services: &Arc, pr let img = match job.key.target { Target::Sequence(seq) | Target::SequencePlan(seq) => Some(filmcraft_render::render_sequence(&job.project, seq, job.time, opts, &provider)), Target::Item(item) => filmcraft_render::render_item(&job.project, item, job.time, job.scale, &provider), + Target::MulticamGrid(item) => filmcraft_render::multicam::render_grid(&job.project, item, job.time, job.scale, &provider).map(|(img, _)| img), }; let rgba = match img { Some(img) => Rgba { w: img.w, h: img.h, px: img.over_black_rgba8() }, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 530fdd64..fd786563 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -338,6 +338,8 @@ impl FilmcraftApp { let t = self.session.playhead(); let _ = self.session.execute("mixer.recordStart", json!({"time": t.0})); } + // Multi-Camera view: playing records live cuts (keys 1–9 / clicking angles) + panels::multicam::on_play(self); } pub fn stop(&mut self) { @@ -355,6 +357,7 @@ impl FilmcraftApp { self.ui.status = e.to_string(); } } + panels::multicam::on_stop(self); } fn start_audio(&mut self) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index af8e71f5..490d8c6f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -39,6 +39,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("view.playbackRes.quarter", "1/4", ["View", "Playback Resolution"], None), uic!("view.playbackRes.eighth", "1/8", ["View", "Playback Resolution"], None), uic!("view.safeMargins", "Safe Margins", ["View"], None), + uic!("multicam.toggleView", "Multi-Camera View", ["View"], Some("Shift+0")), + uic!("multicam.recordToggle", "Multi-Camera Record On/Off Toggle", [], Some("0")), uic!("view.theme.dark", "Darkest", ["View", "Appearance"], None), uic!("view.theme.medium", "Medium", ["View", "Appearance"], None), uic!("view.theme.light", "Light", ["View", "Appearance"], None), @@ -100,6 +102,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val if let Some(r) = crate::panels::trim_monitor::route_transport(app, ctx, id) { return r; } + if let Some(r) = crate::panels::multicam::route(app, id, ¶ms) { + if let Err(e) = &r { + app.ui.status = e.clone(); + } + return r; + } if let Some(r) = crate::panels::media_dialogs::route(app, id, ¶ms) { if let Err(e) = &r { app.ui.status = e.clone(); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index eed562c7..cdd657c4 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::multicam::show_dialog(app, ctx); let Some(d) = app.dialog else { return }; if let Some(still_open) = crate::panels::file_dialogs::show(app, ctx, d) { if !still_open && app.dialog == Some(d) { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 1e8f0fde..390a9deb 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -16,6 +16,7 @@ pub mod meters; pub mod misc; pub mod mixer; pub mod monitor; +pub mod multicam; pub mod project; pub mod shortcuts_dialog; pub mod text; diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index 69e3a6cf..1c563609 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -66,6 +66,17 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) } }; let prefix = if which == Which::Program { "program" } else { "source" }; + // Multi-Camera view: the angle grid on the left, the program on the right + let multicam = which == Which::Program && app.ui.program.multicam; + let (grid_area, video_area) = if multicam { + let mid = video_area.center().x; + (Some(Rect::from_min_max(video_area.min, pos2(mid - 2.0, video_area.max.y))), Rect::from_min_max(pos2(mid + 2.0, video_area.min.y), video_area.max)) + } else { + (None, video_area) + }; + if let Some(g) = grid_area { + crate::panels::multicam::grid(app, ui, g); + } ui.painter().rect_filled(video_area, 0.0, t.panel_bg); // ---- picture let has_video = frame_size.0 > 0; @@ -202,7 +213,14 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) if which == Which::Program { ui.separator(); ui.checkbox(&mut app.ui.show_scopes, "Lumetri Scopes"); + ui.checkbox(&mut app.ui.program.multicam, "Multi-Camera"); ui.checkbox(&mut app.playback.looping, "Loop"); + ui.separator(); + let mut follows = app.session.state.multicam_audio_follows_video; + if ui.checkbox(&mut follows, "Multi-Camera Audio Follows Video").changed() { + let _ = app.session.execute("multicam.audioFollowsVideo", json!({"enabled": follows})); + } + ui.checkbox(&mut app.ui.multicam_record, "Multi-Camera Record"); } }); @@ -215,7 +233,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) transport(app, ui, row3, which); } -fn quantize_scale(s: f32) -> f32 { +pub fn quantize_scale(s: f32) -> f32 { // Buckets keep the cache effective while resizing the panel. let buckets = [1.0 / 32.0, 1.0 / 16.0, 1.0 / 8.0, 0.1875, 0.25, 0.375, 0.5, 0.75, 1.0]; *buckets.iter().find(|b| **b >= s - 1e-4).unwrap_or(&1.0) diff --git a/crates/ui-egui/src/panels/multicam.rs b/crates/ui-egui/src/panels/multicam.rs new file mode 100644 index 00000000..51a0c933 --- /dev/null +++ b/crates/ui-egui/src/panels/multicam.rs @@ -0,0 +1,381 @@ +//! Multi-camera UI: the Program monitor's Multi-Camera view (angle grid next to the program) and +//! the Synchronize, Merge Clips and Create Multi-Camera Source Sequence dialogs. +//! +//! The grid is one frame job ([`Target::MulticamGrid`]): every shown angle rendered at the grid +//! cell's size (reduced resolution) and tiled, prefetched like the program while playing. +//! Clicking an angle (or keys 1–9) cuts to it while recording (playing with Multi-Camera Record +//! on) and switches the clip at the playhead when stopped; Ctrl/⌘-click switches the video only. +//! +//! Automation ids: +//! - Multi-Camera view: `program.multicam.grid`, `program.multicam.angle.` (n = 1-based camera +//! in grid order), `program.multicam.record`. +//! - Synchronize: `sync.method.`, `sync.ignoreHours`, `sync.marker`, +//! `sync.offset`, `sync.track`, `sync.ok`, `sync.cancel`. +//! - Merge Clips: `merge.name`, `merge.method.<…>`, `merge.ignoreHours`, `merge.marker`, +//! `merge.offset`, `merge.removeVideoAudio`, `merge.ok`, `merge.cancel`. +//! - Create Multi-Camera Source Sequence: `mcam.name`, `mcam.method.<…>`, `mcam.ignoreHours`, +//! `mcam.marker`, `mcam.offset`, `mcam.audio.`, +//! `mcam.cameraNames.`, `mcam.processedBin`, `mcam.ok`, `mcam.cancel`. + +use egui::{Align2, Color32, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2}; +use filmcraft_project::ItemId; +use filmcraft_time::Tick; +use serde_json::{Value, json}; + +use crate::FilmcraftApp; +use crate::frames::{FrameKey, Target}; +use crate::state::SyncDraft; +use crate::theme::Tokens; + +type Elems = Vec<(String, Rect, String)>; + +/// Menu / shortcut entry points: the three dialogs (without params) and the view toggles. +pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option> { + match id { + "multicam.toggleView" => { + app.ui.program.multicam = params.get("enabled").and_then(Value::as_bool).unwrap_or(!app.ui.program.multicam); + return Some(Ok(json!({"multicam": app.ui.program.multicam}))); + } + "multicam.recordToggle" => { + app.ui.multicam_record = params.get("enabled").and_then(Value::as_bool).unwrap_or(!app.ui.multicam_record); + app.ui.status = format!("Multi-Camera Record {}", if app.ui.multicam_record { "on" } else { "off" }); + return Some(Ok(json!({"record": app.ui.multicam_record}))); + } + _ => {} + } + if !params.as_object().is_none_or(|m| m.is_empty()) { + return None; + } + let kind = match id { + "clip.synchronize" => "synchronize", + "clip.mergeClips" => "merge", + "clip.createMulticam" => "multicam", + _ => return None, + }; + if let Err(e) = filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session)) { + return Some(Err(e)); + } + let items: Vec = app.session.state.project_selection.iter().map(|i| i.0).collect(); + let first = app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).map(|i| i.name.clone()).unwrap_or_default(); + let name = match kind { + "merge" => format!("{first} - Merged"), + "multicam" => format!("{first} Multicam"), + _ => String::new(), + }; + app.ui.sync_dialog = Some(SyncDraft { kind: kind.into(), items, name, ..Default::default() }); + Some(Ok(json!({"dialog": kind}))) +} + +fn push(elems: &mut Elems, id: impl Into, r: &egui::Response, label: impl Into) { + elems.push((id.into(), r.rect, label.into())); +} + +const METHODS: [(&str, &str); 5] = [("in", "In Points"), ("out", "Out Points"), ("timecode", "Timecode"), ("marker", "Clip Marker"), ("audio", "Audio")]; + +/// Draw the open Synchronize / Merge Clips / Create Multi-Camera dialog. +pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { + let Some(mut d) = app.ui.sync_dialog.clone() else { return }; + let (title, pre) = match d.kind.as_str() { + "merge" => ("Merge Clips", "merge"), + "multicam" => ("Create Multi-Camera Source Sequence", "mcam"), + _ => ("Synchronize Clips", "sync"), + }; + let mut elems: Elems = Vec::new(); + let mut action: Option<&str> = None; + let accent = app.tokens.accent; + let names: Vec = d.items.iter().filter_map(|i| app.session.project.item(ItemId(*i)).map(|it| it.name.clone())).collect(); + egui::Window::new(title).collapsible(false).resizable(false).default_width(440.0).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { + if pre != "sync" { + ui.horizontal(|ui| { + ui.label(if pre == "merge" { "Clip Name:" } else { "Sequence Name:" }); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); + push(&mut elems, format!("{pre}.name"), &r, "name"); + }); + ui.label(RichText::new(format!("{} clip(s): {}", names.len(), names.join(", "))).weak().small()); + ui.add_space(4.0); + } else { + let n = app.session.state.selection.len(); + ui.label(RichText::new(format!("{n} clip(s) selected; the clip on the reference track stays put.")).weak().small()); + } + ui.group(|ui| { + ui.set_min_width(400.0); + ui.label(RichText::new("Synchronize Point").strong()); + for (m, label) in METHODS { + ui.horizontal(|ui| { + let r = ui.radio(d.method == m, label); + push(&mut elems, format!("{pre}.method.{m}"), &r, label); + if r.clicked() { + d.method = m.into(); + } + match m { + "timecode" => { + let r = ui.add_enabled(d.method == "timecode", egui::Checkbox::new(&mut d.ignore_hours, "Ignore Hours")); + push(&mut elems, format!("{pre}.ignoreHours"), &r, "Ignore Hours"); + } + "marker" => { + let r = + ui.add_enabled(d.method == "marker", egui::TextEdit::singleline(&mut d.marker).hint_text("first marker").desired_width(140.0)); + push(&mut elems, format!("{pre}.marker"), &r, "marker name"); + } + _ => {} + } + }); + } + ui.horizontal(|ui| { + ui.label(if pre == "merge" { "Offset Audio by:" } else { "Offset:" }); + let r = ui.add(egui::DragValue::new(&mut d.offset).range(-100_000..=100_000).suffix(" frames")); + push(&mut elems, format!("{pre}.offset"), &r, "offset"); + }); + }); + match pre { + "sync" => { + ui.horizontal(|ui| { + ui.label("Reference track:"); + let r = ui.add(egui::TextEdit::singleline(&mut d.track).hint_text("lowest (V1…)").desired_width(90.0)); + push(&mut elems, "sync.track", &r, "reference track"); + }); + } + "merge" => { + let r = ui.checkbox(&mut d.remove_video_audio, "Remove Audio from AV Clip"); + push(&mut elems, "merge.removeVideoAudio", &r, "Remove Audio from AV Clip"); + } + _ => { + ui.group(|ui| { + ui.set_min_width(400.0); + ui.label(RichText::new("Audio").strong()); + ui.horizontal(|ui| { + for (a, label) in [("camera1", "Camera 1"), ("all", "All Cameras"), ("switch", "Switch Audio")] { + let r = ui.radio(d.audio == a, label); + push(&mut elems, format!("mcam.audio.{a}"), &r, label); + if r.clicked() { + d.audio = a.into(); + } + } + }); + ui.label(RichText::new("Camera Names").strong()); + ui.horizontal(|ui| { + for (a, label) in [("clip", "Clip Names"), ("track", "Enumerate Cameras"), ("metadata", "Camera Angle Metadata")] { + let r = ui.radio(d.camera_names == a, label); + push(&mut elems, format!("mcam.cameraNames.{a}"), &r, label); + if r.clicked() { + d.camera_names = a.into(); + } + } + }); + }); + let r = ui.checkbox(&mut d.processed_bin, "Move source clips to Processed Clips bin"); + push(&mut elems, "mcam.processedBin", &r, "Move source clips to Processed Clips bin"); + } + } + if !d.message.is_empty() { + ui.colored_label(Color32::from_rgb(0xe0, 0x8a, 0x6a), &d.message); + } + 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.add(egui::Button::new(RichText::new("OK").color(Color32::WHITE)).fill(accent)); + 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.sync_dialog = None; + return; + } + Some("ok") => { + let mut p = json!({"method": d.method, "offset": d.offset, "ignoreHours": d.ignore_hours}); + if !d.marker.is_empty() { + p["marker"] = json!(d.marker); + } + let cmd = match pre { + "merge" => { + p["items"] = json!(d.items); + p["removeVideoAudio"] = json!(d.remove_video_audio); + if !d.name.is_empty() { + p["name"] = json!(d.name); + } + "clip.mergeClips" + } + "mcam" => { + p["items"] = json!(d.items); + p["audio"] = json!(d.audio); + p["cameraNames"] = json!(d.camera_names); + p["processedBin"] = json!(d.processed_bin); + if !d.name.is_empty() { + p["name"] = json!(d.name); + } + "clip.createMulticam" + } + _ => { + if !d.track.is_empty() { + p["track"] = json!(d.track); + } + "clip.synchronize" + } + }; + match app.session.execute(cmd, p) { + Ok(v) => { + app.ui.status = match pre { + "sync" => format!("Synchronized {} clip group(s)", v["moved"]), + "merge" => "Merged clip created".into(), + _ => format!("Multi-camera source sequence with {} camera(s)", v["cameras"].as_array().map_or(0, Vec::len)), + }; + app.ui.sync_dialog = None; + return; + } + Err(e) => d.message = e.to_string(), + } + } + _ => {} + } + app.ui.sync_dialog = Some(d); +} + +/// A revision for grid frames: changes when the multi-camera source (or its cameras' media) +/// changes, not with every edit of the cut (cuts recorded live must not throw away the grid). +fn grid_revision(app: &FilmcraftApp, src: ItemId) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + if let Some(q) = app.session.project.sequence(src) { + serde_json::to_vec(q).unwrap_or_default().hash(&mut h); + for t in &q.video_tracks { + for it in &t.items { + app.item_revision(it.item).hash(&mut h); + } + } + } + h.finish() +} + +/// The angle grid of the Multi-Camera view in `area`. +pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + ui.painter().rect_filled(area, 0.0, t.panel_bg); + app.auto.add("program.multicam.grid", area, "Multi-Camera"); + let playhead = app.session.playhead(); + let info = filmcraft_engine::multicam::inspect_at(&app.session, playhead); + let Some(src) = info["source"].as_u64().map(ItemId) else { + ui.painter().text(area.center(), Align2::CENTER_CENTER, "No multi-camera clip at the playhead", Tokens::ui(12.0), t.text_dim); + return; + }; + let Some(q) = app.session.project.sequence(src) else { return }; + let cams = q.cameras(); + let shown = cams.shown_angles(); + let (cols, rows) = filmcraft_render::multicam::grid_dims(shown.len().max(1)); + let (sw, sh) = (q.settings.width as f32, q.settings.height as f32); + let rate = q.settings.frame_rate; + let pic = crate::panels::monitor::fit(area.shrink(2.0), sw * cols as f32, sh * rows as f32); + let (cw, ch) = (pic.width() / cols as f32, pic.height() / rows as f32); + // decode and composite at the cell's size (never more than the playback resolution) + let cell_px = cw * ctx.pixels_per_point(); + let scale = crate::panels::monitor::quantize_scale(app.ui.program.res.scale().min((cell_px / sw).max(1.0 / 32.0))); + let mt = Tick(info["sourceTime"].as_i64().unwrap_or(0)); + let frame = rate.frame_at(mt); + let key = FrameKey { target: Target::MulticamGrid(src), frame, size: (scale * 1000.0) as u32, revision: grid_revision(app, src) }; + let project = app.session.project.clone(); + if app.playback.playing { + app.frames.schedule_playback(key, rate, scale, &project, app.playback.speed, app.playback.preroll.is_some()); + } else { + app.frames.request(key, rate.tick_of(frame), scale, &project, 0); + } + let tex = match app.frames.get(&key) { + Some(img) => Some(app.texture_for(&ctx, "monitor-multicam", key, &img)), + None => match app.frames.nearest(key.target, frame, key.size, key.revision, 6) { + Some(img) => Some(app.texture_for(&ctx, "monitor-multicam", FrameKey { frame: frame - 1, ..key }, &img)), + None => app.texture_existing("monitor-multicam").map(|(id, _)| id), + }, + }; + ui.painter().rect_filled(pic, 0.0, t.monitor_bg); + if let Some(tex) = tex { + ui.painter().image(tex, pic, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + let current = info["angle"].as_u64().map(|a| a as usize); + let recording = app.session.mcrec.active(); + let mut click: Option<(usize, bool)> = None; + for (k, &angle) in shown.iter().enumerate() { + let cell = Rect::from_min_size(pic.min + vec2((k % cols) as f32 * cw, (k / cols) as f32 * ch), vec2(cw, ch)); + let name = cams.cameras.get(angle).map(|c| c.name.clone()).unwrap_or_default(); + let resp = ui.interact(cell, egui::Id::new(("multicam-angle", k)), Sense::click()).on_hover_text(format!("Camera {} — {name}", k + 1)); + app.auto.add(&format!("program.multicam.angle.{}", k + 1), cell, &name); + if resp.clicked() { + // modifiers held now, or carried by the click event (synthetic input) + let video_only = ui.input(|i| { + let ev = i.events.iter().any(|e| matches!(e, egui::Event::PointerButton { modifiers: m, .. } if m.ctrl || m.command)); + i.modifiers.ctrl || i.modifiers.command || ev + }); + click = Some((k + 1, video_only)); + } + if resp.hovered() { + ui.painter().rect_stroke(cell.shrink(1.0), 0.0, Stroke::new(1.0, Color32::from_white_alpha(90)), StrokeKind::Inside); + } + if current == Some(angle) { + // the active angle: yellow, red while recording + let col = if recording { Color32::from_rgb(0xe0, 0x3a, 0x3a) } else { Color32::from_rgb(0xf2, 0xc9, 0x4c) }; + ui.painter().rect_stroke(cell.shrink(1.5), 0.0, Stroke::new(3.0, col), StrokeKind::Inside); + } + let label = format!("{} {name}", k + 1); + let galley = ui.painter().layout_no_wrap(label, Tokens::ui(11.0), Color32::WHITE); + let lr = Rect::from_min_size(cell.left_bottom() + vec2(6.0, -galley.size().y - 8.0), galley.size() + vec2(8.0, 4.0)); + ui.painter().rect_filled(lr, 3.0, Color32::from_black_alpha(160)); + ui.painter().galley(lr.min + vec2(4.0, 2.0), galley, Color32::WHITE); + } + // record indicator + let rr = Rect::from_min_size(area.min + vec2(8.0, 8.0), vec2(64.0, 18.0)); + let rec_on = app.ui.multicam_record; + let resp = ui.interact(rr, egui::Id::new("multicam-record"), Sense::click()).on_hover_text("Multi-Camera Record On/Off (0)"); + app.auto.add("program.multicam.record", rr, "Multi-Camera Record"); + let dot = if recording { + Color32::from_rgb(0xe0, 0x3a, 0x3a) + } else if rec_on { + Color32::from_rgb(0x9a, 0x3a, 0x3a) + } else { + t.text_faint + }; + ui.painter().rect_filled(rr, 3.0, Color32::from_black_alpha(140)); + ui.painter().circle_filled(rr.left_center() + vec2(9.0, 0.0), 4.5, dot); + ui.painter().text(rr.left_center() + vec2(18.0, 0.0), Align2::LEFT_CENTER, if recording { "REC" } else { "Record" }, Tokens::ui(10.5), Color32::WHITE); + if resp.clicked() { + app.ui.multicam_record = !app.ui.multicam_record; + } + if let Some((n, video_only)) = click + && let Err(e) = app.session.execute("multicam.cut", json!({"camera": n, "videoOnly": video_only})) + { + app.ui.status = e.to_string(); + } +} + +/// Start a recording pass when playback starts in the Multi-Camera view. +pub fn on_play(app: &mut FilmcraftApp) { + if app.ui.program.multicam && app.ui.multicam_record && !app.session.mcrec.active() && (app.playback.speed - 1.0).abs() < 1e-9 { + let t = app.session.playhead(); + if filmcraft_engine::multicam::multicam_clip_at(&app.session, t).is_some() { + let _ = app.session.execute("multicam.recordStart", json!({"time": t.0})); + } + } +} + +/// End the recording pass (one undo step) when playback stops. +pub fn on_stop(app: &mut FilmcraftApp) { + if app.session.mcrec.active() { + let t = app.session.playhead(); + match app.session.execute("multicam.recordStop", json!({"time": t.0})) { + Ok(v) if v["cuts"].as_u64().unwrap_or(0) > 0 => app.ui.status = format!("Recorded {} multi-camera cut(s)", v["cuts"]), + Ok(_) => {} + Err(e) => app.ui.status = e.to_string(), + } + } +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index da05c234..018cb6da 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -428,7 +428,18 @@ fn draw_clip( if let Some(aspect) = aspect { let tw = (th.height() * aspect).min(th.width()); let mt = rate.snap(it.source_in); - if let Some((tex, _)) = app.thumbnail(ctx, it.item, mt, 160) { + // a multi-camera clip shows its angle's clip + let (thumb_item, mt) = app + .session + .project + .sequence(it.item) + .and_then(|q| { + let ti = q.angle_video_track_index(it.multicam_angle(q)?)?; + let inner = q.video_tracks[ti].item_at(mt)?; + Some((inner.item, inner.source_time_at(mt))) + }) + .unwrap_or((it.item, mt)); + if let Some((tex, _)) = app.thumbnail(ctx, thumb_item, mt, 160) { let r = Rect::from_min_size(th.min, vec2(tw, th.height())); let cp = p.with_clip_rect(th.intersect(p.clip_rect())); cp.image(tex, r, Rect::from_min_max(pos2(0.0, 0.0), pos2((tw / (th.height() * aspect)).min(1.0), 1.0)), Color32::WHITE); @@ -443,6 +454,10 @@ fn draw_clip( // name band if w > 24.0 { let mut label = it.name.clone(); + if let Some(m) = it.multicam.filter(|m| m.enabled) { + let cam = app.session.project.sequence(it.item).and_then(|q| q.cameras().cameras.get(m.angle as usize).map(|c| c.name.clone())); + label = format!("[MC{}] {}", m.angle + 1, cam.unwrap_or(label)); + } if (it.speed - 1.0).abs() > 1e-6 || it.reverse { label = format!("{label} [{}%]", ((if it.reverse { -1.0 } else { 1.0 }) * it.speed * 100.0).round()); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 927395ef..6ad38540 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -220,11 +220,14 @@ pub struct MonitorView { pub zoom: Option, pub safe_margins: bool, pub show_transport: bool, + /// Program monitor display mode Multi-Camera (angle grid + program). + #[serde(default)] + pub multicam: bool, } impl Default for MonitorView { fn default() -> Self { - Self { res: PlaybackRes::Half, zoom: None, safe_margins: false, show_transport: true } + Self { res: PlaybackRes::Half, zoom: None, safe_margins: false, show_transport: true, multicam: false } } } @@ -310,6 +313,60 @@ pub struct UiState { /// Open colour dialog (Interpret Footage ▸ Color Management, Sequence ▸ Color Management). #[serde(default)] pub color_dialog: Option, + /// Synchronize / Merge Clips / Create Multi-Camera Source Sequence dialog (open when Some). + #[serde(default)] + pub sync_dialog: Option, + /// Multi-Camera Record On/Off (key 0): playing in the Multi-Camera view records cuts. + #[serde(default = "yes")] + pub multicam_record: bool, +} + +/// Clip ▸ Synchronize…, Merge Clips… and Create Multi-Camera Source Sequence… share one draft; +/// `kind` says which dialog it is (`synchronize`, `merge`, `multicam`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct SyncDraft { + pub kind: String, + /// Project items (Merge Clips, Create Multi-Camera). + pub items: Vec, + pub name: String, + /// `in` | `out` | `timecode` | `marker` | `audio` + pub method: String, + pub ignore_hours: bool, + /// Clip marker name to sync on (empty = the first marker). + pub marker: String, + /// Frames added to every clip but the reference. + pub offset: i64, + /// Synchronize: reference track (`V1`, `A2`…; empty = the lowest track). + pub track: String, + /// Create Multi-Camera: `camera1` | `all` | `switch`. + pub audio: String, + /// Create Multi-Camera: `clip` | `track` | `metadata`. + pub camera_names: String, + pub processed_bin: bool, + /// Merge Clips: drop the video clip's own audio. + pub remove_video_audio: bool, + pub message: String, +} + +impl Default for SyncDraft { + fn default() -> Self { + Self { + kind: "synchronize".into(), + items: vec![], + name: String::new(), + method: "audio".into(), + ignore_hours: false, + marker: String::new(), + offset: 0, + track: String::new(), + audio: "camera1".into(), + camera_names: "clip".into(), + processed_bin: true, + remove_video_audio: false, + message: String::new(), + } + } } /// File ▸ Link Media… (shown automatically when a project opens with missing media). @@ -481,6 +538,8 @@ impl Default for UiState { project_manager: None, make_offline: None, color_dialog: None, + sync_dialog: None, + multicam_record: true, } } } diff --git a/crates/ui-egui/tests/multicam_ui.rs b/crates/ui-egui/tests/multicam_ui.rs new file mode 100644 index 00000000..28c6e1c3 --- /dev/null +++ b/crates/ui-egui/tests/multicam_ui.rs @@ -0,0 +1,301 @@ +//! Headless UI tests of multi-camera editing: the Create Multi-Camera Source Sequence, Synchronize +//! and Merge Clips dialogs, the Program monitor's Multi-Camera view (angle grid, clicking an +//! angle, Ctrl-click for video only) and live switching with the 1–9 keys while playing. +//! +//! Set `FILMCRAFT_UI_SNAPSHOT_DIR=` to also render the window offscreen with wgpu and write PNGs +//! there (`multicam-*.png`). + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_engine::project::{ItemKind, Label, MediaClip, MediaRef, Project, SequenceSettings, TrackKind}; +use filmcraft_media::generators::GeneratorSource; +use filmcraft_media::{DemoScene, Generator, MediaSource}; +use filmcraft_time::{FrameRate, Tick, TimeRange}; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +const W: u32 = 320; +const H: u32 = 180; + +fn tmp_dir(name: &str) -> PathBuf { + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let d = std::env::temp_dir().join(format!("filmcraft-ui-{name}-{}-{nanos}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A 24 fps ProRes movie of a demo scene (our own encoder). +fn make_movie(path: &Path, scene: DemoScene, frames: i64) { + let rate = FrameRate::FPS_24; + let dur = rate.tick_of(frames); + let src = GeneratorSource::new(Generator::Demo(scene), W, H, rate, dur); + let clip = MediaClip { + media: MediaRef::Generator(Generator::Demo(scene)), + info: src.info().clone(), + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }; + let mut p = Project::new("fixture"); + let item = p.add_item("scene", Label::Iris, ItemKind::Media(clip), None); + let seq = p.new_sequence("s", SequenceSettings { width: W, height: H, frame_rate: rate, ..Default::default() }, 1, 0, None); + let mut v = p.make_track_item(item, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, dur), rate).unwrap(); + for e in &mut v.effects { + filmcraft_engine::project::resolve_auto_points(e, (W, H), (W, H)); + } + p.sequence_mut(seq).unwrap().video_tracks[0].items.push(v); + let settings = filmcraft_engine::export::ExportSettings { + format: filmcraft_engine::export::Format::ProRes, + path: path.to_string_lossy().into_owned(), + include_audio: false, + ..Default::default() + }; + let shared: filmcraft_media::SharedSource = Arc::new(src); + let provider = move |id| (id == item).then(|| shared.clone()); + filmcraft_engine::export::export(&Arc::new(p), seq, &settings, &provider, &Default::default()).unwrap(); +} + +/// Four cameras imported into a session with an empty 320×180 sequence "Cut". +fn session() -> (Session, Vec) { + let dir = tmp_dir("multicam"); + let scenes = [DemoScene::OceanSunset, DemoScene::CityNight, DemoScene::Aurora, DemoScene::Forest]; + let files: Vec = (0..4).map(|i| dir.join(format!("Cam {}.mov", (b'A' + i as u8) as char))).collect(); + for (f, sc) in files.iter().zip(scenes) { + make_movie(f, sc, 96); + } + let mut s = Session::default(); + let r = s.execute("file.import", json!({"paths": files.iter().map(|f| f.to_string_lossy()).collect::>()})).unwrap(); + let items: Vec = r["items"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + let mut p = (*s.project).clone(); + let seq = p.new_sequence("Cut", SequenceSettings { width: W, height: H, frame_rate: FrameRate::FPS_24, ..Default::default() }, 2, 2, None); + s.project = Arc::new(p); + s.state.active_sequence = Some(seq); + s.state.open_sequences = vec![seq]; + (s, items) +} + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, + snapshots: Option, +} + +impl Driver { + fn new(session: Session) -> Self { + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let snapshots = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").map(PathBuf::from); + let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000); + if snapshots.is_some() { + b = b.wgpu(); + } + let harness = b.build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, snapshots }; + d.frames(4); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + /// Run frames (with short sleeps so frame workers finish) until `done`. + fn until(&mut self, what: &str, mut done: impl FnMut(&mut Self) -> bool) { + for _ in 0..600 { + if done(self) { + return; + } + self.frames(1); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + panic!("timed out waiting for {what}"); + } + + 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 click(&mut self, id: &str) { + self.ok("ui.click", json!({"id": id})); + self.frames(3); + } + + fn ids(&mut self, prefix: &str) -> Vec { + let v = self.ok("ui.elements", json!({"prefix": prefix})); + v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect() + } + + fn app(&mut self) -> &mut FilmcraftApp { + self.harness.state_mut() + } + + /// Angles of the clips on V1 / A1 of the active sequence. + fn angles(&mut self, audio: bool) -> Vec { + let q = self.app().session.active_sequence().unwrap().clone(); + let t = if audio { &q.audio_tracks[0] } else { &q.video_tracks[0] }; + t.items.iter().map(|i| i.multicam.map_or(99, |m| m.angle)).collect() + } + + fn snapshot(&mut self, name: &str) { + let Some(dir) = self.snapshots.clone() else { return }; + self.frames(3); + let img = match self.harness.render() { + Ok(i) => i, + Err(e) => { + eprintln!("snapshot {name} skipped: {e}"); + return; + } + }; + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{name}.png")); + img.save(&path).unwrap(); + eprintln!("snapshot: {}", path.display()); + } +} + +#[test] +fn create_multicam_dialog_view_and_live_switching() { + let (s, items) = session(); + let mut d = Driver::new(s); + // Create Multi-Camera Source Sequence from the Project panel selection + d.exec("project.select", json!({"items": items})); + d.ok("ui.menu.invoke", json!({"id": "clip.createMulticam"})); + d.frames(3); + assert_eq!(d.app().ui.sync_dialog.as_ref().map(|x| x.kind.clone()).as_deref(), Some("multicam")); + for id in ["mcam.name", "mcam.method.audio", "mcam.method.timecode", "mcam.audio.switch", "mcam.cameraNames.track", "mcam.processedBin", "mcam.ok"] { + assert!(!d.ids(id).is_empty(), "{id}"); + } + d.click("mcam.method.in"); + d.click("mcam.audio.switch"); + d.snapshot("multicam-create-dialog"); + d.click("mcam.ok"); + assert!(d.app().ui.sync_dialog.is_none(), "dialog closed: {}", d.app().ui.status); + let src = d.app().session.state.project_selection[0]; + let mc = d.app().session.project.sequence(src).unwrap().multicam.clone().unwrap(); + assert_eq!(mc.cameras.len(), 4); + assert_eq!(mc.cameras[0].name, "Cam A.mov"); + // edit it into the sequence, open the Multi-Camera view + d.exec("source.open", json!({"item": src.0})); + d.exec("playhead.set", json!({"frame": 0})); + d.exec("source.overwrite", json!({})); + d.exec("playhead.set", json!({"frame": 12})); + d.ok("ui.menu.invoke", json!({"id": "multicam.toggleView"})); + d.frames(2); + assert!(d.app().ui.program.multicam); + let angles = d.ids("program.multicam.angle."); + assert_eq!(angles.len(), 4, "{angles:?}"); + d.until("grid frame", |d| { + let k = d.app().frames.queue_len(); + k == 0 + }); + d.frames(6); + d.snapshot("multicam-view"); + // clicking an angle switches the clip at the playhead; audio follows video when asked + d.exec("multicam.audioFollowsVideo", json!({"enabled": true})); + d.click("program.multicam.angle.2"); + assert_eq!(d.angles(false), [1]); + assert_eq!(d.angles(true), [1], "audio followed"); + // Ctrl-click: video only + d.ok("ui.click", json!({"id": "program.multicam.angle.3", "modifiers": {"ctrl": true}})); + d.frames(3); + assert_eq!((d.angles(false), d.angles(true)), (vec![2], vec![1])); + d.until("program frame", |d| d.app().frames.queue_len() == 0); + d.frames(6); + d.snapshot("multicam-view-camera3"); + d.exec("playhead.set", json!({"frame": 0})); + d.click("program.multicam.angle.1"); + // live switching: play, press 2 then 4, stop → one undo step with the recorded cuts + let undo_before = d.app().session.history.undo.len(); + d.ok("ui.playback", json!({"action": "play"})); + d.until("recording", |d| d.app().session.mcrec.active()); + d.until("playhead past 0.3 s", |d| d.app().session.playhead() > FrameRate::FPS_24.tick_of(7)); + d.ok("ui.key", json!({"key": "2"})); + d.frames(2); + d.until("playhead past 0.8 s", |d| d.app().session.playhead() > FrameRate::FPS_24.tick_of(19)); + d.ok("ui.key", json!({"key": "4"})); + d.frames(4); + d.snapshot("multicam-recording"); + d.until("playhead past 1.2 s", |d| d.app().session.playhead() > FrameRate::FPS_24.tick_of(29)); + d.ok("ui.playback", json!({"action": "stop"})); + d.frames(2); + assert!(!d.app().session.mcrec.active()); + let v = d.angles(false); + assert_eq!(&v[..3], [0, 1, 3], "{v:?}"); + assert_eq!(d.angles(true)[..3], [0, 1, 3], "audio followed the recording"); + assert_eq!(d.app().session.history.undo.len(), undo_before + 1, "one undo step per pass"); + d.frames(4); + d.snapshot("multicam-after-recording"); + d.exec("edit.undo", json!({})); + assert_eq!(d.angles(false), [0]); +} + +#[test] +fn synchronize_and_merge_dialogs() { + let (s, items) = session(); + let mut d = Driver::new(s); + // two cameras on V1 and V2 at different times + d.exec("source.open", json!({"item": items[0]})); + d.exec("playhead.set", json!({"frame": 0})); + d.exec("source.overwrite", json!({})); + d.exec("timeline.setTargeting", json!({"track": "V2", "sourcePatch": true})); + d.exec("timeline.setTargeting", json!({"track": "A2", "sourcePatch": true})); + d.exec("source.open", json!({"item": items[1]})); + d.exec("playhead.set", json!({"frame": 30})); + d.exec("source.overwrite", json!({})); + assert_eq!(d.app().session.active_sequence().unwrap().video_tracks[1].items.len(), 1); + let all: Vec = d.app().session.active_sequence().unwrap().all_tracks().flat_map(|t| t.items.iter().map(|i| i.id.0)).collect(); + d.exec("timeline.select", json!({"clips": all})); + d.ok("ui.menu.invoke", json!({"id": "clip.synchronize"})); + d.frames(3); + assert_eq!(d.app().ui.sync_dialog.as_ref().map(|x| x.kind.clone()).as_deref(), Some("synchronize")); + d.click("sync.method.in"); + d.snapshot("multicam-sync-dialog"); + d.click("sync.ok"); + assert!(d.app().ui.sync_dialog.is_none(), "{}", d.app().ui.status); + let q = d.app().session.active_sequence().unwrap().clone(); + assert_eq!(q.video_tracks[0].items[0].start, q.video_tracks[1].items[0].start, "In points aligned"); + // Merge Clips needs audio clips: the dialog explains what is wrong + d.exec("project.select", json!({"items": [items[0], items[1]]})); + d.ok("ui.menu.invoke", json!({"id": "clip.mergeClips"})); + d.frames(3); + assert!(!d.ids("merge.removeVideoAudio").is_empty()); + d.snapshot("multicam-merge-dialog"); + d.click("merge.ok"); + let msg = d.app().ui.sync_dialog.as_ref().map(|x| x.message.clone()).unwrap_or_default(); + assert!(msg.contains("one video clip"), "{msg}"); + d.click("merge.cancel"); + assert!(d.app().ui.sync_dialog.is_none()); +}