Files
filmcraft/crates/ui-egui/tests/transcript_ui.rs
OorjitSethiandCodex d795b9a25e PERF-01: isolate editor snapshot and transcript redraw optimizations
Split from #728 on current main. Full validation is recorded in the draft PR; the trim and mask-modifier failures reproduce on unchanged main.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-11 20:13:57 -05:00

155 lines
6.6 KiB
Rust

//! Headless UI test of the Text panel's Transcript tab: the Transcribe button, the word view,
//! clicking words (In/Out from the selection) and extracting the selected text.
//!
//! Set `FILMCRAFT_UI_SNAPSHOT_DIR=<dir>` to also render the window offscreen with wgpu and write
//! `transcript-*.png` there; without it no GPU is needed.
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::control::ControlRequest;
use serde_json::{Value, json};
/// Reproducible redraw cost for a long sequence transcript, with and without many search hits.
/// Run alone with `--ignored --nocapture`; timings are observations, not CI thresholds.
#[test]
#[ignore = "manual transcript performance measurement"]
fn long_transcript_redraw_benchmark() {
let mut d = Driver::demo();
let item = d.harness.state().session.active_sequence().unwrap().audio_tracks[0].items[0].item;
let words: Vec<Value> = (0..10_000)
.map(|i| {
json!({"text": "hello", "start": filmcraft_time::Tick::from_seconds_f64(i as f64 * 0.4).0,
"end": filmcraft_time::Tick::from_seconds_f64(i as f64 * 0.4 + 0.3).0, "speaker": (i / 40) % 2})
})
.collect();
d.ok(
"engine.execute",
json!({"command": "transcript.set", "params": {"item": item.0,
"transcript": {"language": "en", "speakers": [{"name": "First"}, {"name": "Second"}], "words": words}}}),
);
d.ok("ui.set", json!({"workspace": "Captions and Graphics"}));
d.harness.state_mut().ui.text_tab = "Transcript".into();
// Extend only the synthetic fixture clip so all 10,000 words are actually laid out.
let session = &mut d.harness.state_mut().session;
let id = session.state.active_sequence.unwrap();
let seq = std::sync::Arc::make_mut(&mut session.project).sequence_mut(id).unwrap();
seq.audio_tracks[0].items.truncate(1);
seq.audio_tracks[0].items[0].source_in = filmcraft_time::Tick::ZERO;
seq.audio_tracks[0].items[0].duration = filmcraft_time::Tick::from_seconds_f64(4000.0);
assert_eq!(filmcraft_engine::transcript::sequence_words(session).len(), 10_000);
for query in ["", "hello"] {
d.harness.state_mut().ui.transcript_search = query.into();
d.frames(5);
let mut times = Vec::new();
for _ in 0..40 {
let start = std::time::Instant::now();
d.frames(1);
times.push(start.elapsed().as_secs_f64() * 1000.0);
}
times.sort_by(f64::total_cmp);
println!("transcript words=10000 query={query:?} p50_ms={:.3} p95_ms={:.3}", times[20], times[38]);
}
}
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
snapshots: Option<std::path::PathBuf>,
}
impl Driver {
fn demo() -> Self {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
let (tx, rx) = channel();
let app = FilmcraftApp::new(session).with_control(rx);
let snapshots = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").map(std::path::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();
}
}
fn ok(&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() {
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
return v["result"].clone();
}
}
panic!("no reply to {method} {params}");
}
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 snapshot(&mut self, name: &str) {
let Some(dir) = self.snapshots.clone() else { return };
self.frames(2);
match self.harness.render() {
Ok(img) => {
std::fs::create_dir_all(&dir).unwrap();
img.save(dir.join(format!("transcript-{name}.png"))).unwrap();
}
Err(e) => eprintln!("snapshot {name} skipped: {e}"),
}
}
}
#[test]
fn transcript_tab_selects_and_extracts_words() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"workspace": "Captions and Graphics"}));
d.frames(2);
d.ok("ui.click", json!({"id": "text.tab.Transcript"}));
d.frames(3);
assert_eq!(d.ids("text.transcript.generate"), vec!["text.transcript.generate".to_string()], "empty state offers Transcribe");
// bring a transcript for the first A1 clip's media (as an agent without a speech model would)
let mut probe = Session::default();
probe.execute("file.openDemoProject", json!({})).unwrap();
let a = probe.active_sequence().unwrap().audio_tracks[0].items[0].clone();
let tk = |s: f64| a.source_in.0 + (s * filmcraft_time::TICKS_PER_SECOND as f64) as i64;
let words: Vec<Value> = ["Hello", "um", "there", "friend."]
.iter()
.enumerate()
.map(|(i, w)| json!({"text": w, "start": tk(0.5 + i as f64 * 0.5), "end": tk(0.9 + i as f64 * 0.5), "speaker": 0}))
.collect();
d.ok("engine.execute", json!({"command": "transcript.set", "params": {"item": a.item.0, "transcript": {"language": "en", "words": words}}}));
d.frames(3);
let ids = d.ids("text.transcript.word.");
assert_eq!(ids.len(), 4, "{ids:?}");
d.snapshot("words");
d.ok("ui.click", json!({"id": "text.transcript.word.1"}));
d.frames(3);
d.ok("ui.click", json!({"id": "text.transcript.extract"}));
d.frames(3);
let r = d.ok("engine.execute", json!({"command": "transcript.inspect", "params": {}}));
let left: Vec<&str> = r["words"].as_array().unwrap().iter().filter_map(|w| w["text"].as_str()).collect();
assert_eq!(left, ["Hello", "there", "friend."]);
assert_eq!(d.ids("text.transcript.word.").len(), 3);
}