mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-26 11:06:58 -06:00
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>
155 lines
6.6 KiB
Rust
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);
|
|
}
|