mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
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:
+2
-1
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user