Files
filmcraft/crates/engine/src/multicam_tests.rs
T
Brandon ThomasandClaude Opus 5.5 c025c42470 M3.13: multicam paging beyond 16 angles, view options, Edit Cameras thumbnails; subclip trim limits and Source monitor
Multi-Camera view: automatic / 2x2 / 3x3 / 4x4 grid layouts paged when the
angles don't fit (page arrows, keys 1-9 pick cameras on the shown page),
Multi-Camera Selection Top Down, Show Multi-Camera Preview Monitor (grid
fills the monitor when off), Auto-Adjust Multi-Camera Playback Quality
(lower cell decode scale while playing), Transmit Multi-Camera View (stored,
no device yet), Edit Cameras dialog with a thumbnail per angle. Only the
shown page's angles are decoded, at the cell scale. Engine commands
multicam.gridLayout/page/nextPage/prevPage/selectionTopDown/
showPreviewMonitor/autoAdjustQuality/transmitView/grid.

Subclips: restricted subclips limit trims, rolls, slides and slips on both
sides (EditCtx::media_start); the Source monitor shows a subclip's range
(or the whole master with the range as In/Out when unrestricted), opens at
its In and shows the master clip's markers inside it (source.inspect);
Convert to Master Clip keeps the range as In/Out and the inherited markers;
Make / Edit Subclip and Convert to Master Clip in the Project panel menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:42:21 -04:00

840 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Synchronisation and multi-camera editing end to end.
//!
//! Fixtures (ffmpeg, `target/fixtures/multicam/`): one "event" (pink noise gated into bursts, so
//! it is not periodic) recorded by three devices that started at different times — two cameras
//! (MJPEG + PCM `.mov` with QuickTime timecode) and an external recorder (`.wav`) — each with its
//! own gain and independent noise. The true offsets are known to the sample.
//!
//! The editing tests use movies made with our own encoders (no ffmpeg).
use std::path::{Path, PathBuf};
use std::process::Command;
use filmcraft_media::DemoScene;
use filmcraft_project::{ItemId, Marker, MarkerId, MarkerKind, MulticamAudio, Sequence, TrackItem};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
use serde_json::{Value, json};
use crate::Session;
use crate::media_test_util::{make_movie, psnr, tmp_dir};
const SR: i64 = 48_000;
/// Event samples at which each recording starts (camera A, camera B, recorder).
const START_A: i64 = 96_000;
const START_B: i64 = 96_000 + 121_234;
const START_REC: i64 = 48_000;
const SAMPLE: Tick = Tick(TICKS_PER_SECOND / SR);
fn ff(ffmpeg: &Path, args: &[&str]) -> bool {
Command::new(ffmpeg).args(["-v", "error", "-y"]).args(args).status().map(|s| s.success()).unwrap_or(false)
}
/// The event as heard by one device: starts at event sample `start`, `secs` long, `gain`, white
/// noise of amplitude `noise` (seed `seed`).
fn mic_filter(start: i64, secs: f64, gain: f64, noise: f64, seed: u32) -> String {
let len = (secs * SR as f64) as i64;
format!(
"anoisesrc=d=60:c=pink:r=48000:a=0.35:s=7,volume='if(lt(mod(t*2.7\\,1)\\,0.55)\\,1\\,0.08)':eval=frame,atrim=start_sample={start},asetpts=N/SR/TB,volume={gain}[s];\
anoisesrc=d=60:c=white:r=48000:a={noise}:s={seed}[n];[s][n]amix=inputs=2:normalize=0:duration=first,atrim=end_sample={len}[a]"
)
}
/// (camera A .mov, camera B .mov, recorder .wav), or None without ffmpeg.
fn fixtures() -> Option<(PathBuf, PathBuf, PathBuf)> {
let ffmpeg = filmcraft_testkit::ffmpeg_or_skip("multicam fixtures")?;
let dir = filmcraft_testkit::fixtures_dir("multicam");
let cam = |name: &str, src: &str, start: i64, secs: f64, gain: f64, noise: f64, seed: u32, tc: &str| {
filmcraft_testkit::fixtures::generate(&dir.join(name), |tmp| {
let t = tmp.to_string_lossy();
let video = format!("{src}=s=320x180:r=24:d={secs}");
ff(
&ffmpeg,
&[
"-f",
"lavfi",
"-i",
&video,
"-filter_complex",
&mic_filter(start, secs, gain, noise, seed),
"-map",
"0:v",
"-map",
"[a]",
"-c:v",
"mjpeg",
"-q:v",
"8",
"-c:a",
"pcm_s16le",
"-ar",
"48000",
"-ac",
"1",
"-timecode",
tc,
"-shortest",
&t,
],
)
})
};
let a = cam("camA.mov", "testsrc2", START_A, 12.0, 1.0, 0.02, 11, "01:00:00:00")?;
let b = cam("camB.mov", "smptebars", START_B, 10.0, 0.25, 0.05, 12, "01:00:02:12")?;
let rec = filmcraft_testkit::fixtures::generate(&dir.join("recorder.wav"), |tmp| {
let t = tmp.to_string_lossy();
ff(
&ffmpeg,
&[
"-f",
"lavfi",
"-i",
"anullsrc=r=48000",
"-filter_complex",
&mic_filter(START_REC, 16.0, 1.5, 0.003, 13),
"-map",
"[a]",
"-c:a",
"pcm_s16le",
"-ac",
"1",
&t,
],
)
})?;
Some((a, b, rec))
}
fn import(s: &mut Session, files: &[&Path]) -> Vec<ItemId> {
let paths: Vec<String> = files.iter().map(|p| p.to_string_lossy().into_owned()).collect();
let r = s.execute("file.import", json!({"paths": paths})).unwrap();
r["items"].as_array().unwrap().iter().map(|v| ItemId(v.as_u64().unwrap())).collect()
}
fn ids(v: &[ItemId]) -> Value {
json!(v.iter().map(|i| i.0).collect::<Vec<_>>())
}
/// Run a Project-panel command on `items` (selected first, as in the UI).
fn on_items(s: &mut Session, cmd: &str, items: &[ItemId], mut p: Value) -> crate::Result<Value> {
s.execute("project.select", json!({"items": ids(items)})).unwrap();
p["items"] = ids(items);
s.execute(cmd, p)
}
/// Timeline time at which event sample `m` plays in clip `ti` (whose media starts at event sample
/// `start`).
fn event_time(ti: &TrackItem, start: i64, m: i64) -> Tick {
let media = Tick::from_units(m - start, SR);
ti.start + (media - ti.source_in)
}
fn find_clip(q: &Sequence, item: ItemId, video: bool) -> &TrackItem {
let tracks = if video { &q.video_tracks } else { &q.audio_tracks };
tracks.iter().flat_map(|t| t.items.iter()).find(|i| i.item == item).unwrap_or_else(|| panic!("no clip of {item:?}"))
}
#[test]
fn audio_sync_multicam_sample_accurate() {
let Some((a, b, rec)) = fixtures() else { return };
let mut s = Session::default();
let items = import(&mut s, &[&a, &b, &rec]);
let t0 = std::time::Instant::now();
let r =
on_items(&mut s, "clip.createMulticam", &items, json!({"method": "audio", "audio": "switch", "name": "Interview MC", "processedBin": true})).unwrap();
let ms = t0.elapsed().as_secs_f64() * 1000.0;
let seq_id = ItemId(r["sequence"].as_u64().unwrap());
let q = s.project.sequence(seq_id).unwrap().clone();
// structure: two cameras with video, an audio-only recorder, switching audio
let mc = q.multicam.as_ref().unwrap();
assert_eq!(mc.cameras.len(), 3);
assert_eq!(q.video_tracks.len(), 2);
assert_eq!(q.audio_tracks.len(), 3);
assert_eq!(mc.audio, MulticamAudio::SwitchAudio);
assert_eq!(mc.cameras[0].name, "camA.mov");
assert!(mc.cameras[2].video_track.is_none() && mc.cameras[2].audio_tracks.len() == 1, "the recorder is an audio-only source");
assert_eq!(q.video_tracks[0].name, "camA.mov");
assert_eq!(s.project.item(seq_id).unwrap().type_label(), "Multi-Camera Source Sequence");
// processed clips moved into their bin
let bin = s.project.root.children.iter().find_map(|c| match c {
filmcraft_project::BinEntry::Bin(b) if b.name == "Processed Clips" => Some(b.clone()),
_ => None,
});
assert_eq!(bin.map(|b| b.children.len()), Some(3));
// every recording plays event sample m at the same timeline time (±1 sample)
let clips = [(find_clip(&q, items[0], false), START_A), (find_clip(&q, items[1], false), START_B), (find_clip(&q, items[2], false), START_REC)];
let m = 300_000;
let t_ref = event_time(clips[0].0, clips[0].1, m);
let mut worst = Tick::ZERO;
for (c, start) in &clips[1..] {
let d = (event_time(c, *start, m) - t_ref).abs();
worst = worst.max(d);
}
// video clips sit on frame boundaries and keep the same alignment as their audio
for (k, item) in items[..2].iter().enumerate() {
let v = find_clip(&q, *item, true);
assert_eq!(v.start, q.settings.frame_rate.snap(v.start), "frame-aligned");
assert_eq!((v.start, v.source_in), (clips[k].0.start, clips[k].0.source_in));
}
let report = &r["clips"];
eprintln!(
"audio sync: worst error {:.2} samples over 3 recordings; B lag {} (truth {}), recorder lag {} (truth {}); {ms:.0} ms",
worst.0 as f64 / SAMPLE.0 as f64,
report[1]["audio"]["lagSamples"],
START_B - START_A,
report[2]["audio"]["lagSamples"],
START_REC - START_A
);
assert!(worst <= SAMPLE, "worst {} samples", worst.0 as f64 / SAMPLE.0 as f64);
assert!(report[1]["audio"]["reliable"].as_bool().unwrap() && report[2]["audio"]["reliable"].as_bool().unwrap(), "{report}");
// undo removes the sequence and restores the bins
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.sequence(seq_id).is_none());
}
#[test]
fn timecode_marker_and_in_point_sync_within_one_frame() {
let Some((a, b, _)) = fixtures() else { return };
let mut s = Session::default();
let items = import(&mut s, &[&a, &b]);
let truth = Tick::from_units(START_B - START_A, SR);
let rate = FrameRate::FPS_24;
let offset_of = |s: &Session, r: &Value| -> Tick {
let q = s.project.sequence(ItemId(r["sequence"].as_u64().unwrap())).unwrap();
let (va, vb) = (find_clip(q, items[0], true), find_clip(q, items[1], true));
// timeline time of media 0 of each camera; B starts `truth` after A in the event
(vb.start - vb.source_in) - (va.start - va.source_in)
};
// timecode: A 01:00:00:00, B 01:00:02:12 = 2.5 s later (the true offset is 2.5257 s)
let r = on_items(&mut s, "clip.createMulticam", &items, json!({"method": "timecode"})).unwrap();
let off = offset_of(&s, &r);
assert_eq!(off, rate.tick_of(60), "timecode offset is exactly 60 frames");
assert!((off - truth).abs() <= rate.frame_duration(), "within a frame of the truth");
// ignoring hours gives the same result here
let r = on_items(&mut s, "clip.createMulticam", &items, json!({"method": "timecode", "ignoreHours": true})).unwrap();
assert_eq!(offset_of(&s, &r), rate.tick_of(60));
// clip markers: the same event moment marked in both clips (frame accuracy)
let event_frame_a = 72; // 3 s into camera A
let at_b = rate.tick_of(event_frame_a) - truth;
let mut p = (*s.project).clone();
for (it, t) in [(items[0], rate.tick_of(event_frame_a)), (items[1], rate.snap(at_b))] {
let m = p.item_mut(it).unwrap().as_media_mut().unwrap();
m.markers.push(Marker {
id: MarkerId(900 + it.0),
start: t,
duration: Tick::ZERO,
name: "clap".into(),
comment: String::new(),
kind: MarkerKind::Comment,
color: filmcraft_project::Label::Rose,
});
}
s.project = std::sync::Arc::new(p);
let r = on_items(&mut s, "clip.createMulticam", &items, json!({"method": "marker", "marker": "clap"})).unwrap();
let off = offset_of(&s, &r);
assert!((off - truth).abs() <= rate.frame_duration(), "marker sync {} vs {}", off.0, truth.0);
// In points: A's In at 2.5 s, B's at 0 → B starts 2.5 s after A
let mut p = (*s.project).clone();
p.item_mut(items[0]).unwrap().as_media_mut().unwrap().mark_in = Some(rate.tick_of(60));
s.project = std::sync::Arc::new(p);
let r = on_items(&mut s, "clip.createMulticam", &items, json!({"method": "in"})).unwrap();
assert_eq!(offset_of(&s, &r), rate.tick_of(60));
// Out points: A ends at 12 s, B at 10 s, both Outs unset → ends aligned: B media 0 at 2 s
let r = on_items(&mut s, "clip.createMulticam", &items, json!({"method": "out"})).unwrap();
assert_eq!(offset_of(&s, &r), rate.tick_of(48));
// a missing marker is an error, not a guess
assert!(on_items(&mut s, "clip.createMulticam", &items, json!({"method": "marker", "marker": "nope"})).is_err());
}
#[test]
fn merge_clips_by_audio() {
let Some((a, _, rec)) = fixtures() else { return };
let mut s = Session::default();
let items = import(&mut s, &[&a, &rec]);
let r = on_items(&mut s, "clip.mergeClips", &items, json!({"method": "audio", "removeVideoAudio": true})).unwrap();
let id = ItemId(r["item"].as_u64().unwrap());
let q = s.project.sequence(id).unwrap();
assert_eq!(s.project.item(id).unwrap().type_label(), "Merged Clip");
assert_eq!(s.project.item(id).unwrap().name, "camA.mov - Merged");
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (1, 1), "camera audio removed");
let v = find_clip(q, items[0], true);
let ar = find_clip(q, items[1], false);
assert_eq!(v.link, ar.link, "merged parts are linked");
// the recorder's event sample m plays with the camera's
let m = 400_000;
let d = (event_time(ar, START_REC, m) - (v.start + (Tick::from_units(m - START_A, SR) - v.source_in))).abs();
assert!(d <= SAMPLE, "merge offset error {} samples", d.0 as f64 / SAMPLE.0 as f64);
assert!(on_items(&mut s, "clip.mergeClips", &items[..1], json!({})).is_err(), "needs audio clips");
}
#[test]
fn timeline_synchronize_by_audio_and_timecode() {
let Some((a, b, rec)) = fixtures() else { return };
let mut s = Session::default();
let items = import(&mut s, &[&a, &b, &rec]);
let mut p = (*s.project).clone();
let rate = FrameRate::FPS_24;
let seq = p.new_sequence("Edit", filmcraft_project::SequenceSettings { width: 320, height: 180, frame_rate: rate, ..Default::default() }, 2, 3, None);
// camera B on V2/A2 at 20 s, recorder on A3 at 1 s, camera A on V1/A1 at 5 s
let place = |p: &mut filmcraft_project::Project, it: ItemId, at: i64, v: Option<usize>, a: usize| {
let d = p.item(it).unwrap().duration();
let range = filmcraft_time::TimeRange::new(Tick::ZERO, d);
let link = p.alloc_id();
if let Some(vt) = v {
let mut vi = p.make_track_item(it, filmcraft_project::TrackKind::Video, rate.tick_of(at), range, rate).unwrap();
vi.link = Some(link);
p.sequence_mut(seq).unwrap().video_tracks[vt].items.push(vi);
}
let mut ai = p.make_track_item(it, filmcraft_project::TrackKind::Audio, rate.tick_of(at), range, rate).unwrap();
ai.link = Some(link);
p.sequence_mut(seq).unwrap().audio_tracks[a].items.push(ai);
};
place(&mut p, items[0], 120, Some(0), 0);
place(&mut p, items[1], 480, Some(1), 1);
place(&mut p, items[2], 24, None, 2);
s.project = std::sync::Arc::new(p);
s.state.active_sequence = Some(seq);
let all: Vec<u64> = s.project.sequence(seq).unwrap().all_tracks().flat_map(|t| t.items.iter().map(|i| i.id.0)).collect();
s.execute("timeline.select", json!({"clips": all})).unwrap();
let r = s.execute("clip.synchronize", json!({"method": "audio"})).unwrap();
assert_eq!(r["moved"], 2, "{r}");
let q = s.active_sequence().unwrap().clone();
let ca = find_clip(&q, items[0], false);
assert_eq!(ca.start, rate.tick_of(120), "the reference (V1) stays put");
let m = 350_000;
for (it, start) in [(items[1], START_B), (items[2], START_REC)] {
let c = find_clip(&q, it, false);
let d = (event_time(c, start, m) - event_time(ca, START_A, m)).abs();
assert!(d <= SAMPLE, "timeline sync error {} samples", d.0 as f64 / SAMPLE.0 as f64);
assert_eq!(c.start, rate.snap(c.start), "moved clips stay on frames");
}
let vb = find_clip(&q, items[1], true);
let ab = find_clip(&q, items[1], false);
assert_eq!((vb.start, vb.source_in), (ab.start, ab.source_in), "linked partners moved together");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(find_clip(s.active_sequence().unwrap(), items[1], false).start, rate.tick_of(480));
// timecode: B lands exactly 60 frames after A
s.execute("timeline.select", json!({"clips": all})).unwrap();
let r = s.execute("clip.synchronize", json!({"method": "timecode", "track": "V1"})).unwrap();
assert!(r["moved"].as_u64().unwrap() >= 1);
let q = s.active_sequence().unwrap();
assert_eq!(find_clip(q, items[1], true).start - find_clip(q, items[0], true).start, rate.tick_of(60));
}
// ------------------------------------------------------------------ editing (our own media)
const W: u32 = 160;
const H: u32 = 90;
/// Three 4-second cameras (different scenes, own audio), a multi-camera source sequence by In
/// points, and a sequence holding one multi-camera clip on V1/A1. Returns (session, camera items,
/// source sequence, edit sequence).
fn mc_session(audio: &str) -> (Session, Vec<ItemId>, ItemId, ItemId) {
let dir = tmp_dir("mc-edit");
let files: Vec<PathBuf> = ["a.mov", "b.mov", "c.mov"].iter().map(|f| dir.join(f)).collect();
for (f, sc) in files.iter().zip([DemoScene::OceanSunset, DemoScene::CityNight, DemoScene::Aurora]) {
make_movie(f, sc, W, H, 96);
}
let mut s = Session::default();
let refs: Vec<&Path> = files.iter().map(|p| p.as_path()).collect();
let items = import(&mut s, &refs);
let r = on_items(&mut s, "clip.createMulticam", &items, json!({"method": "in", "audio": audio, "cameraNames": "track"})).unwrap();
let src = ItemId(r["sequence"].as_u64().unwrap());
let mut p = (*s.project).clone();
let edit =
p.new_sequence("Cut", filmcraft_project::SequenceSettings { width: W, height: H, frame_rate: FrameRate::FPS_24, ..Default::default() }, 2, 2, None);
s.project = std::sync::Arc::new(p);
s.state.active_sequence = Some(edit);
s.execute("source.open", json!({"item": src.0})).unwrap();
s.execute("source.overwrite", json!({})).unwrap();
s.set_playhead(Tick::ZERO);
(s, items, src, edit)
}
fn v1(s: &Session) -> Vec<(i64, u32)> {
let q = s.active_sequence().unwrap();
let rate = q.settings.frame_rate;
q.video_tracks[0].items.iter().map(|i| (rate.frame_at(i.start), i.multicam.unwrap().angle)).collect()
}
fn a1(s: &Session) -> Vec<(i64, u32)> {
let q = s.active_sequence().unwrap();
let rate = q.settings.frame_rate;
q.audio_tracks[0].items.iter().map(|i| (rate.frame_at(i.start), i.multicam.unwrap().angle)).collect()
}
fn frame(s: &mut Session, f: i64) -> Vec<u8> {
let t = s.sequence_rate().tick_of(f);
s.set_playhead(t);
s.render_program(1.0).unwrap().over_black_rgba8()
}
/// The frame of camera `item` at its media frame `f`, rendered alone.
fn camera_frame(s: &Session, item: ItemId, f: i64) -> Vec<u8> {
let provider = s.media.provider(s.project.clone(), s.services.clone());
filmcraft_render::render_item(&s.project, item, FrameRate::FPS_24.tick_of(f), 1.0, &provider).unwrap().over_black_rgba8()
}
#[test]
fn multicam_clip_structure_and_render() {
let (mut s, items, src, _) = mc_session("camera1");
let q = s.project.sequence(src).unwrap();
let mc = q.multicam.as_ref().unwrap();
assert_eq!(mc.cameras.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["Camera 1", "Camera 2", "Camera 3"]);
assert_eq!(mc.audio, MulticamAudio::Camera1);
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (3, 3));
// edited in as an enabled multi-camera clip on angle 0 (video and audio)
assert_eq!(v1(&s), [(0, 0)]);
assert_eq!(a1(&s), [(0, 0)]);
let inspect = s.execute("multicam.inspect", json!({"frame": 10})).unwrap();
assert_eq!(inspect["source"], src.0);
assert_eq!(inspect["shown"], json!([0, 1, 2]));
// the clip shows angle 0 only (not the composite, whose top track is camera 3)
assert!(psnr(&frame(&mut s, 10), &camera_frame(&s, items[0], 10)) > 40.0);
s.execute("multicam.switchAngle", json!({"camera": 3})).unwrap();
assert_eq!(v1(&s), [(0, 2)]);
assert!(psnr(&frame(&mut s, 10), &camera_frame(&s, items[2], 10)) > 40.0);
// disabling multi-camera shows the plain nest (top track = camera 3 here, so pick camera 2)
s.execute("multicam.switchAngle", json!({"camera": 2})).unwrap();
let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id;
s.execute("timeline.select", json!({"clips": [clip.0]})).unwrap();
s.execute("clip.multicamEnable", json!({"enabled": false})).unwrap();
assert!(psnr(&frame(&mut s, 10), &camera_frame(&s, items[2], 10)) > 40.0, "plain nest: the top track");
s.execute("clip.multicamEnable", json!({})).unwrap();
assert!(psnr(&frame(&mut s, 10), &camera_frame(&s, items[1], 10)) > 40.0, "re-enabled, angle remembered");
// the GPU plan draws the angle's media frame directly (one layer, no CPU pass) and matches
let provider = s.media.provider(s.project.clone(), s.services.clone());
let edit_id = s.state.active_sequence.unwrap();
let t10 = FrameRate::FPS_24.tick_of(10);
let plan = filmcraft_render::plan::plan_frame(&s.project, edit_id, t10, filmcraft_render::RenderOptions::default(), &provider);
match &plan {
filmcraft_render::plan::FramePlan::Layers { layers, .. } => {
assert_eq!(layers.len(), 1);
assert!(!matches!(layers[0].frame.data, filmcraft_frame::PixelData::RgbaF32(_)), "a decoded frame, not a CPU render");
}
_ => panic!("expected layers"),
}
let via_plan = filmcraft_render::plan::execute_cpu(&plan);
s.set_playhead(t10);
let reference = s.render_program(1.0).unwrap();
assert!(psnr(&via_plan.over_black_rgba8(), &reference.over_black_rgba8()) > 40.0);
// grid of the angles
let provider = s.media.provider(s.project.clone(), s.services.clone());
let (grid, angles) = filmcraft_render::multicam::render_grid(&s.project, src, Tick::ZERO, 0.25, &provider).unwrap();
assert_eq!(angles, [0, 1, 2]);
assert_eq!((grid.w, grid.h), (2 * 40, 2 * 23));
}
#[test]
fn switch_audio_follows_video() {
let (mut s, _, src, _) = mc_session("switch");
// audio follows video off (Premiere's default): only the picture switches
s.execute("multicam.switchAngle", json!({"camera": 2, "frame": 5})).unwrap();
assert_eq!((v1(&s), a1(&s)), (vec![(0, 1)], vec![(0, 0)]));
s.execute("multicam.audioFollowsVideo", json!({"enabled": true})).unwrap();
s.execute("multicam.switchAngle", json!({"camera": 3})).unwrap();
assert_eq!((v1(&s), a1(&s)), (vec![(0, 2)], vec![(0, 2)]));
// video only (Ctrl-click) leaves the audio alone; audio only switches just the audio
s.execute("multicam.switchAngle", json!({"camera": 1, "videoOnly": true})).unwrap();
assert_eq!((v1(&s), a1(&s)), (vec![(0, 0)], vec![(0, 2)]));
s.execute("multicam.switchAngle", json!({"camera": 2, "audioOnly": true})).unwrap();
assert_eq!((v1(&s), a1(&s)), (vec![(0, 0)], vec![(0, 1)]));
// switching audio plays the selected angle's track of the source
let q = s.project.sequence(src).unwrap();
let on = q.multicam.as_ref().unwrap().audible_tracks(Some(1));
assert_eq!(on, q.multicam.as_ref().unwrap().cameras[1].audio_tracks);
let provider = s.media.provider(s.project.clone(), s.services.clone());
let edit_seq = s.active_sequence().unwrap().clone();
let mix = filmcraft_render::audio::mix_sequence(&s.project, &edit_seq, 24_000, 4_800, &provider);
let only_b = filmcraft_render::audio::mix_sequence(&s.project, &q.with_angle_audio(Some(1)), 24_000, 4_800, &provider);
let err = mix.channels[0].iter().zip(&only_b.channels[0]).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(err < 1e-4, "multi-camera audio = camera 2's track ({err})");
assert!(mix.channels[0].iter().any(|v| v.abs() > 1e-3), "audible");
// undo steps back one switch at a time
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(a1(&s), [(0, 2)]);
}
/// Live switching from a simulated playback clock: play 0 → 84 at 24 fps, press 2 at frame 24,
/// 3 at frame 48, 3 again at 60 (no new edit), 1 at 72; stop at 84.
#[test]
fn live_switch_recording_from_playback_clock() {
let (mut s, items, _, _) = mc_session("switch");
s.execute("multicam.audioFollowsVideo", json!({"enabled": true})).unwrap();
let undo_before = s.history.undo.len();
let presses: [(i64, u64); 4] = [(24, 2), (48, 3), (60, 3), (72, 1)];
let rate = s.sequence_rate();
s.execute("multicam.recordStart", json!({"frame": 0})).unwrap();
for f in 0..=84i64 {
// the clock: one tick per frame
s.set_playhead(rate.tick_of(f));
if let Some((_, cam)) = presses.iter().find(|(pf, _)| *pf == f) {
s.execute("multicam.cut", json!({"camera": cam})).unwrap();
// the cut is live: the program shows the new angle from this frame on
let want = items[*cam as usize - 1];
assert!(psnr(&frame(&mut s, f + 2), &camera_frame(&s, want, f + 2)) > 40.0, "live cut at {f}");
s.set_playhead(rate.tick_of(f));
}
}
let r = s.execute("multicam.recordStop", json!({"frame": 84})).unwrap();
assert_eq!(r["cuts"], 4);
// 0–24 cam 1, 24–48 cam 2, 48–72 cam 3 (the second press of 3 adds no edit), 72–96 cam 1
// (healed with the untouched rest of the clip after the stop point)
let expect = vec![(0, 0), (24, 1), (48, 2), (72, 0)];
assert_eq!(v1(&s), expect);
assert_eq!(a1(&s), expect, "audio followed the video");
assert_eq!(s.history.undo.len(), undo_before + 1, "one undo step for the whole pass");
assert_eq!(s.history.undo.last().unwrap().0, "Record Multi-Camera");
// pieces are continuous in source time
let q = s.active_sequence().unwrap();
for w in q.video_tracks[0].items.windows(2) {
assert_eq!(w[0].source_out(), w[1].source_in);
assert!(w[0].link.is_some());
}
// a second pass changes only its own range and is its own undo step
s.execute("multicam.recordStart", json!({"frame": 30})).unwrap();
s.execute("multicam.cut", json!({"camera": 3, "frame": 30})).unwrap();
s.execute("multicam.recordStop", json!({"frame": 40})).unwrap();
assert_eq!(v1(&s), vec![(0, 0), (24, 1), (30, 2), (40, 1), (48, 2), (72, 0)]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v1(&s), expect);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v1(&s), vec![(0, 0)]);
// stopped: a key press switches the clip at the playhead instead of cutting
s.execute("multicam.cut", json!({"camera": 2, "frame": 10})).unwrap();
assert_eq!(v1(&s), vec![(0, 1)]);
}
#[test]
fn flatten_replaces_with_camera_clips() {
let (mut s, items, _, _) = mc_session("switch");
s.execute("multicam.audioFollowsVideo", json!({"enabled": true})).unwrap();
s.execute("multicam.recordStart", json!({"frame": 0})).unwrap();
s.execute("multicam.cut", json!({"camera": 2, "frame": 30})).unwrap();
s.execute("multicam.cut", json!({"camera": 3, "frame": 60})).unwrap();
s.execute("multicam.recordStop", json!({"frame": 96})).unwrap();
let before: Vec<Vec<u8>> = [5, 35, 70].iter().map(|f| frame(&mut s, *f)).collect();
let all: Vec<u64> = s.active_sequence().unwrap().all_tracks().flat_map(|t| t.items.iter().map(|i| i.id.0)).collect();
s.execute("timeline.select", json!({"clips": all})).unwrap();
let r = s.execute("clip.multicamFlatten", json!({})).unwrap();
assert_eq!(r["flattened"], 6);
let q = s.active_sequence().unwrap().clone();
let v: Vec<(ItemId, i64, i64)> = q.video_tracks[0].items.iter().map(|i| (i.item, i.start.0, i.source_in.0)).collect();
let rate = q.settings.frame_rate;
assert_eq!(v, vec![(items[0], 0, 0), (items[1], rate.tick_of(30).0, rate.tick_of(30).0), (items[2], rate.tick_of(60).0, rate.tick_of(60).0)]);
assert!(q.all_tracks().flat_map(|t| t.items.iter()).all(|i| i.multicam.is_none()));
let a: Vec<ItemId> = q.audio_tracks[0].items.iter().map(|i| i.item).collect();
assert_eq!(a, vec![items[0], items[1], items[2]], "audio flattened to the switched cameras");
for (i, f) in [5, 35, 70].iter().enumerate() {
assert!(psnr(&before[i], &frame(&mut s, *f)) > 45.0, "flattened frame {f} looks the same");
}
// linked camera video/audio stay linked
assert_eq!(q.video_tracks[0].items[1].link, q.audio_tracks[0].items[1].link);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v1(&s).len(), 3);
}
#[test]
fn edit_cameras_and_save_load() {
let (mut s, _, src, _) = mc_session("all");
s.execute("multicam.editCameras", json!({"sequence": src.0, "cameras": [{"angle": 1, "name": "Wide", "enabled": false}], "audio": "switch"})).unwrap();
let mc = s.project.sequence(src).unwrap().multicam.clone().unwrap();
assert_eq!((mc.cameras[1].name.as_str(), mc.cameras[1].enabled, mc.audio), ("Wide", false, MulticamAudio::SwitchAudio));
assert_eq!(mc.shown_angles(), [0, 2]);
// keys pick from the shown cameras: camera 2 is angle 2 now
s.execute("multicam.switchAngle", json!({"camera": 2})).unwrap();
assert_eq!(v1(&s), [(0, 2)]);
assert!(s.execute("multicam.switchAngle", json!({"camera": 3})).is_err());
let dir = tmp_dir("mc-save");
let path = dir.join("mc.fcproj").to_string_lossy().into_owned();
s.execute("file.save", json!({"path": path})).unwrap();
let bytes = std::fs::read(&path).unwrap();
let doc: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(doc["schema_version"], filmcraft_format::SCHEMA_VERSION);
let mut t = Session::default();
t.execute("file.open", json!({"path": path})).unwrap();
assert_eq!(*t.project, *s.project, "multi-camera data survives save/load");
assert_eq!(t.project.sequence(src).unwrap().multicam.as_ref().unwrap().cameras[1].name, "Wide");
}
#[test]
fn any_nest_can_be_multicam() {
// a plain nested sequence with two video tracks: angles are its tracks
let (mut s, items, _, _) = mc_session("camera1");
let mut p = (*s.project).clone();
let rate = FrameRate::FPS_24;
let nest = p.new_sequence("Nest", filmcraft_project::SequenceSettings { width: W, height: H, frame_rate: rate, ..Default::default() }, 2, 0, None);
for (k, it) in items[..2].iter().enumerate() {
let mut v = p
.make_track_item(*it, filmcraft_project::TrackKind::Video, Tick::ZERO, filmcraft_time::TimeRange::new(Tick::ZERO, rate.tick_of(48)), rate)
.unwrap();
for e in &mut v.effects {
filmcraft_project::resolve_auto_points(e, (W, H), (W, H));
}
p.sequence_mut(nest).unwrap().video_tracks[k].items.push(v);
}
let edit = s.state.active_sequence.unwrap();
let mut v =
p.make_track_item(nest, filmcraft_project::TrackKind::Video, Tick::ZERO, filmcraft_time::TimeRange::new(Tick::ZERO, rate.tick_of(48)), rate).unwrap();
assert!(v.multicam.is_none(), "plain nests start as plain nests");
for e in &mut v.effects {
filmcraft_project::resolve_auto_points(e, (W, H), (W, H));
}
let clip = v.id;
let q = p.sequence_mut(edit).unwrap();
q.video_tracks[1].items.push(v);
s.project = std::sync::Arc::new(p);
assert!(psnr(&frame(&mut s, 3), &camera_frame(&s, items[1], 3)) > 40.0, "nest: top track");
s.execute("timeline.select", json!({"clips": [clip.0]})).unwrap();
s.execute("clip.multicamEnable", json!({})).unwrap();
assert!(psnr(&frame(&mut s, 3), &camera_frame(&s, items[0], 3)) > 40.0, "angle 0 = track V1 of the nest");
s.execute("multicam.switchAngle", json!({"clips": [clip.0], "angle": 1})).unwrap();
assert!(psnr(&frame(&mut s, 3), &camera_frame(&s, items[1], 3)) > 40.0);
}
#[test]
fn camera_keys_cut_and_select() {
let (mut s, _, _, _) = mc_session("camera1");
// Ctrl+2 at frame 30: an edit there, camera 2 after it
s.set_playhead(FrameRate::FPS_24.tick_of(30));
s.execute("multicam.cutToCamera2", json!({})).unwrap();
assert_eq!(v1(&s), [(0, 0), (30, 1)]);
// Ctrl+3 at the existing edit: no new edit, the right piece switches
s.execute("multicam.cutToCamera3", json!({})).unwrap();
assert_eq!(v1(&s), [(0, 0), (30, 2)]);
// 2 while stopped switches the clip under the playhead
s.set_playhead(FrameRate::FPS_24.tick_of(10));
s.execute("multicam.selectCamera2", json!({})).unwrap();
assert_eq!(v1(&s), [(0, 1), (30, 2)]);
// the keys are bound by default
let keys: Vec<(String, String)> =
s.shortcuts.bindings.iter().filter(|b| b.command.starts_with("multicam.")).map(|b| (b.command.clone(), b.keys.clone())).collect();
assert!(keys.contains(&("multicam.selectCamera1".into(), "1".into())), "{keys:?}");
assert!(keys.contains(&("multicam.cutToCamera9".into(), "Ctrl+9".into())), "{keys:?}");
}
/// Colour of camera `k` (0-based) in the many-angle tests: distinct reds and greens.
fn cam_rgb(k: usize) -> [u8; 3] {
[(k * 12) as u8, (250 - k * 12) as u8, 128]
}
/// `n` colour-matte cameras (64×36, 4 s) in a multi-camera source sequence, edited into a sequence
/// on V1/A1. Returns (session, source sequence).
fn many_angles(n: usize) -> (Session, ItemId) {
let mut s = Session::default();
let mut items = Vec::new();
for k in 0..n {
let [r, g, b] = cam_rgb(k);
let hex = format!("#{r:02x}{g:02x}{b:02x}");
let r = s.execute("file.newColorMatte", json!({"color": hex, "seconds": 4.0, "width": 64, "height": 36, "name": format!("Cam {}", k + 1)})).unwrap();
items.push(ItemId(r["item"].as_u64().unwrap()));
}
let r = on_items(&mut s, "clip.createMulticam", &items, json!({"method": "in", "cameraNames": "clip"})).unwrap();
let src = ItemId(r["sequence"].as_u64().unwrap());
let mut p = (*s.project).clone();
let edit =
p.new_sequence("Cut", filmcraft_project::SequenceSettings { width: 64, height: 36, frame_rate: FrameRate::FPS_24, ..Default::default() }, 2, 2, None);
s.project = std::sync::Arc::new(p);
s.state.active_sequence = Some(edit);
s.execute("source.open", json!({"item": src.0})).unwrap();
s.execute("source.overwrite", json!({})).unwrap();
s.set_playhead(FrameRate::FPS_24.tick_of(10));
(s, src)
}
/// A media source that records the scale of every frame request.
struct Recording {
inner: filmcraft_media::SharedSource,
scales: std::sync::Arc<std::sync::Mutex<Vec<f32>>>,
}
impl filmcraft_media::MediaSource for Recording {
fn info(&self) -> &filmcraft_media::MediaInfo {
self.inner.info()
}
fn video_frame(&self, req: filmcraft_media::FrameRequest) -> filmcraft_media::Result<std::sync::Arc<filmcraft_frame::VideoFrame>> {
self.scales.lock().unwrap().push(req.scale);
self.inner.video_frame(req)
}
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<filmcraft_frame::AudioBuffer> {
self.inner.audio(start, frames, sample_rate)
}
}
#[test]
fn more_than_sixteen_angles_page_and_switch() {
let (mut s, src) = many_angles(20);
assert_eq!(s.project.sequence(src).unwrap().multicam.as_ref().unwrap().cameras.len(), 20);
// automatic layout: 4×4 pages, two of them
let g = s.execute("multicam.grid", json!({})).unwrap();
assert_eq!((g["cols"].as_u64(), g["rows"].as_u64(), g["pages"].as_u64(), g["page"].as_u64()), (Some(4), Some(4), Some(2), Some(0)));
assert_eq!(g["cells"].as_array().unwrap().len(), 16);
assert_eq!(g["cells"][0]["active"], true, "angle 0 is on");
// page 2 holds cameras 17–20
s.execute("multicam.nextPage", json!({})).unwrap();
let g = s.execute("multicam.grid", json!({})).unwrap();
let cams: Vec<u64> = g["cells"].as_array().unwrap().iter().map(|c| c["camera"].as_u64().unwrap()).collect();
assert_eq!((g["page"].as_u64(), cams), (Some(1), vec![17, 18, 19, 20]));
assert_eq!(g["cells"][3]["name"], "Cam 20");
// past the last page clamps
assert_eq!(s.execute("multicam.nextPage", json!({})).unwrap()["page"], 1);
// keys 1–9 pick cameras on the shown page: 2 → camera 18 (angle 17)
s.execute("multicam.selectCamera2", json!({})).unwrap();
assert_eq!(v1(&s), [(0, 17)]);
assert_eq!(a1(&s), [(0, 0)], "audio follows video is off");
// absolute cameras beyond 16 switch from any page; angle is 0-based
s.execute("multicam.page", json!({"page": 0})).unwrap();
s.execute("multicam.cut", json!({"camera": 20})).unwrap();
assert_eq!(v1(&s), [(0, 19)]);
// Ctrl+3 on page 1 at frame 30: an edit, camera 3 after it
s.set_playhead(FrameRate::FPS_24.tick_of(30));
s.execute("multicam.cutToCamera3", json!({})).unwrap();
assert_eq!(v1(&s), [(0, 19), (30, 2)]);
// the program shows the switched angle's colour
let px = frame(&mut s, 40);
let c = &px[(18 * 64 + 32) * 4..][..3];
assert!(c.iter().zip(cam_rgb(2)).all(|(a, b)| (*a as i32 - b as i32).abs() <= 3), "{c:?}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v1(&s), [(0, 19)]);
// fixed layouts re-page: 3×3 → three pages; 2×2 → five; switching layout resets the page
s.execute("multicam.gridLayout", json!({"layout": "3x3"})).unwrap();
assert_eq!(s.execute("multicam.page", json!({"page": 7})).unwrap(), json!({"page": 2, "pages": 3}));
let g = s.execute("multicam.grid", json!({})).unwrap();
assert_eq!(g["cells"].as_array().unwrap().len(), 2, "cameras 19 and 20");
assert_eq!(g["cells"][1]["active"], true, "camera 20 is on");
s.execute("multicam.gridLayout", json!({"layout": "2x2"})).unwrap();
let g = s.execute("multicam.grid", json!({})).unwrap();
assert_eq!((g["page"].as_u64(), g["pages"].as_u64(), g["layout"].as_str()), (Some(0), Some(5), Some("2x2")));
assert!(s.execute("multicam.gridLayout", json!({"layout": "5x5"})).is_err());
// the view settings are editor state: they survive a state round trip
let st: crate::EditorState = serde_json::from_value(serde_json::to_value(&s.state).unwrap()).unwrap();
assert_eq!(st.multicam_view.layout, Some(2));
}
#[test]
fn grid_pages_decode_only_their_angles_at_reduced_resolution() {
let (mut s, src) = many_angles(20);
let scales = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let provider = s.media.provider(s.project.clone(), s.services.clone());
let recording = {
let scales = scales.clone();
move |item: ItemId| {
use filmcraft_render::SourceProvider;
provider.source(item).map(|inner| std::sync::Arc::new(Recording { inner, scales: scales.clone() }) as filmcraft_media::SharedSource)
}
};
let t = FrameRate::FPS_24.tick_of(10);
let (img, angles) = filmcraft_render::multicam::render_grid_page(&s.project, src, t, 0.25, None, 1, &recording).unwrap();
assert_eq!(angles, [16, 17, 18, 19]);
// 4×4 cells of 16×9 (¼ of 64×36): only the page's four angles decoded, each at ¼ scale
assert_eq!((img.w, img.h), (64, 36));
let asked = scales.lock().unwrap().clone();
assert_eq!(asked.len(), 4, "{asked:?}");
assert!(asked.iter().all(|s| (*s - 0.25).abs() < 1e-6), "{asked:?}");
// cell 0 is camera 17, cell 3 camera 20; empty cells are black
let px = img.over_black_rgba8();
let at = |x: usize, y: usize| px[(y * 64 + x) * 4..][..3].to_vec();
for (cell, cam) in [(0usize, 16usize), (3, 19)] {
let c = at(cell % 4 * 16 + 8, cell / 4 * 9 + 4);
assert!(c.iter().zip(cam_rgb(cam)).all(|(a, b)| (*a as i32 - b as i32).abs() <= 3), "cell {cell}: {c:?}");
}
assert_eq!(at(8, 20), [0, 0, 0]);
// Auto-Adjust Multi-Camera Playback Quality: the view asks for a lower cell scale while playing
let q =
|s: &mut Session, playing: bool| s.execute("multicam.grid", json!({"cellPixels": 16.0, "playing": playing})).unwrap()["cellScale"].as_f64().unwrap();
assert_eq!((q(&mut s, false), q(&mut s, true)), (0.25, 0.25));
s.execute("multicam.autoAdjustQuality", json!({})).unwrap();
assert_eq!((q(&mut s, false), q(&mut s, true)), (0.25, 0.0625));
}
#[test]
fn selection_top_down_and_view_toggles() {
let (mut s, src) = many_angles(3);
// a second multi-camera clip of the same source stacked on V2
let edit = s.state.active_sequence.unwrap();
s.execute("timeline.place", json!({"item": src.0, "track": "V2", "frame": 0})).unwrap();
let ids = |s: &Session| {
let q = s.project.sequence(edit).unwrap();
(q.video_tracks[0].items[0].id.0, q.video_tracks[1].items[0].id.0)
};
let (low, high) = ids(&s);
// untargeted tracks: lowest first by default, topmost with Selection Top Down
let clip = |s: &mut Session| s.execute("multicam.inspect", json!({})).unwrap()["clip"].as_u64().unwrap();
let expect_default = clip(&mut s);
assert!(expect_default == low || expect_default == high);
s.execute("multicam.selectionTopDown", json!({"enabled": true})).unwrap();
let top = clip(&mut s);
s.execute("multicam.selectionTopDown", json!({"enabled": false})).unwrap();
let bottom = clip(&mut s);
assert_eq!((top, bottom), (high, low));
// preview monitor on by default; transmit has no device yet
assert!(s.state.multicam_view.show_preview);
assert_eq!(s.execute("multicam.showPreviewMonitor", json!({})).unwrap()["enabled"], false);
let t = s.execute("multicam.transmitView", json!({})).unwrap();
assert_eq!((t["enabled"].as_bool(), t["device"].is_null()), (Some(true), true));
assert_eq!(s.execute("multicam.audioFollowsVideo", json!({})).unwrap()["enabled"], true);
}
/// Process CPU time in seconds (user + system), for the perf test.
fn process_cpu() -> f64 {
let out = Command::new("ps").args(["-o", "cputime=", "-p", &std::process::id().to_string()]).output().ok();
let txt = out.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
// [[dd-]hh:]mm:ss(.ss)
txt.split(':').fold(0.0, |acc, p| acc * 60.0 + p.trim().parse::<f64>().unwrap_or(0.0))
}
/// Multi-Camera view cost: a 2×2 grid of four 1080p H.264 angles at ¼ scale per cell, played
/// sequentially, against the program (one angle at ½ scale). Run with
/// `cargo test --release -p filmcraft-engine --lib multicam_tests::perf -- --ignored --nocapture`.
#[test]
#[ignore]
fn perf_grid_of_four_1080p_angles() {
let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("multicam perf") else { return };
let dir = filmcraft_testkit::fixtures_dir("multicam");
let srcs = ["testsrc2", "smptehdbars", "mandelbrot", "rgbtestsrc"];
let files: Vec<PathBuf> = srcs
.iter()
.map(|src| {
filmcraft_testkit::fixtures::generate(&dir.join(format!("perf-{src}.mp4")), |tmp| {
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")
})
.collect();
// a fresh session (empty decoder caches) per measurement
let setup = || {
let mut s = Session::default();
let refs: Vec<&Path> = files.iter().map(|p| p.as_path()).collect();
let items = import(&mut s, &refs);
let r = on_items(&mut s, "clip.createMulticam", &items, json!({"method": "in"})).unwrap();
(s, ItemId(r["sequence"].as_u64().unwrap()))
};
let rate = FrameRate::FPS_24;
let frames = 72;
let (s, src) = setup();
let provider = s.media.provider(s.project.clone(), s.services.clone());
let q = s.project.sequence(src).unwrap().clone();
let t0 = std::time::Instant::now();
for f in 0..frames {
let opts = filmcraft_render::RenderOptions { scale: 0.5, ..Default::default() };
let _ = filmcraft_render::multicam::render_angle(&s.project, &q, 0, rate.tick_of(f), opts, &provider);
}
let one_ms = t0.elapsed().as_secs_f64() * 1000.0 / frames as f64;
let (s, src) = setup();
let provider = s.media.provider(s.project.clone(), s.services.clone());
let cpu0 = crate::multicam_tests::process_cpu();
let t0 = std::time::Instant::now();
for f in 0..frames {
let (g, _) = filmcraft_render::multicam::render_grid(&s.project, src, rate.tick_of(f), 0.25, &provider).unwrap();
assert_eq!((g.w, g.h), (960, 540));
}
let grid_ms = t0.elapsed().as_secs_f64() * 1000.0 / frames as f64;
let grid_cpu = (crate::multicam_tests::process_cpu() - cpu0) * 1000.0 / frames as f64;
eprintln!(
"Multi-Camera grid (4 × 1080p H.264, ¼-scale cells): {grid_ms:.1} ms/frame wall, {grid_cpu:.1} ms/frame CPU; one angle at ½ scale: {one_ms:.1} ms/frame wall (24 fps budget 41.7 ms)"
);
}