M12.3: Multi-Camera view (angle grid at reduced resolution as one prefetched frame job, click / Ctrl-click to switch, live recording while playing with 1–9, Record toggle 0, Shift+0 view toggle), Create Multi-Camera / Synchronize / Merge Clips dialogs, [MCn] clip labels and angle thumbnails; headless UI tests with snapshots; grid perf test; ROADMAP

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 11:36:00 -04:00
parent 650a266352
commit 1db60f8edc
12 changed files with 801 additions and 6 deletions
+2 -1
View File
@@ -31,7 +31,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
| M0 | Skeleton + visual shell | ✅ | Workspace, 20 crates, dock/workspaces, Premiere 26 look, native menus, control channel, MCP, xtask gates (layers, wasm) | — | — |
| M1 | Media I/O | ✅ | MP4/MOV demux+mux, WAV, stills, MJPEG, symphonia audio (MP3/FLAC/ALAC/Vorbis), GOP seek + frame cache, import | Media Browser polish | 2 |
| M2 | H.264 decoder | ✅ | Own decoder, bit-exact on 37+ streams, 500–600 fps 1080p | — | — |
| M3 | Editing core | 🟡 | Edit algebra (insert/overwrite/razor/lift/extract/ripple/roll/slip/slide/rate-stretch/nest/paste), tools, markers, trim mode + Trim Monitor + dynamic J/K/L trimming, Keyboard Shortcuts editor with FilmCraft/Premiere/FCP/Avid presets | Multicam, 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.
+5 -2
View File
@@ -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")
})
+4
View File
@@ -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<MediaPool>, services: &Arc<dyn Services>, 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() },
+3
View File
@@ -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) {
+8
View File
@@ -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, &params) {
if let Err(e) = &r {
app.ui.status = e.clone();
}
return r;
}
if let Some(r) = crate::panels::media_dialogs::route(app, id, &params) {
if let Err(e) = &r {
app.ui.status = e.clone();
+1
View File
@@ -6,6 +6,7 @@ use crate::{Dialog, FilmcraftApp};
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
crate::panels::media_dialogs::show(app, ctx);
crate::panels::color_dialogs::show(app, ctx);
crate::panels::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) {
+1
View File
@@ -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;
+19 -1
View File
@@ -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)
+381
View File
@@ -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>` (n = 1-based camera
//! in grid order), `program.multicam.record`.
//! - Synchronize: `sync.method.<in|out|timecode|marker|audio>`, `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.<camera1|all|switch>`,
//! `mcam.cameraNames.<clip|track|metadata>`, `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<Result<Value, String>> {
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<u64> = 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<String>, r: &egui::Response, label: impl Into<String>) {
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<String> = 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(),
}
}
}
+16 -1
View File
@@ -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());
}
+60 -1
View File
@@ -220,11 +220,14 @@ pub struct MonitorView {
pub zoom: Option<f32>,
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<ColorDialog>,
/// Synchronize / Merge Clips / Create Multi-Camera Source Sequence dialog (open when Some).
#[serde(default)]
pub sync_dialog: Option<SyncDraft>,
/// 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<u64>,
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,
}
}
}
+301
View File
@@ -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=<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<u64>) {
let dir = tmp_dir("multicam");
let scenes = [DemoScene::OceanSunset, DemoScene::CityNight, DemoScene::Aurora, DemoScene::Forest];
let files: Vec<PathBuf> = (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::<Vec<_>>()})).unwrap();
let items: Vec<u64> = 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<ControlRequest>,
snapshots: Option<PathBuf>,
}
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<String> {
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<u32> {
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<u64> = 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());
}