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>
This commit is contained in:
OorjitSethiandCodex authored and Oorjit Sethi committed 2026-10-11 20:13:57 -05:00
1 parent 3fdaa813e0
commit d795b9a25e
10 files changed
+212 -22

No files matched your search

+2
View File
@@ -344,6 +344,7 @@ pub struct FilmcraftApp {
pub shortcut_editor: panels::shortcuts_dialog::EditorState,
pub toast: Option<(String, f64)>,
pub tl: panels::timeline::TlState,
pub(crate) transcript_cache: panels::transcript_cache::TranscriptCache,
/// Commands from outside the UI (native menu bar), invoked on the UI thread.
pub command_inbox: Option<Receiver<String>>,
/// A V key press (or a Ctrl+V paste) reached the app and its release hasn't yet.
@@ -568,6 +569,7 @@ impl FilmcraftApp {
toast: None,
graphics_notice: None,
tl: Default::default(),
transcript_cache: Default::default(),
command_inbox: None,
v_down: false,
stale_clipboard_media: None,
+4 -1
View File
@@ -99,7 +99,10 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
crate::dock::placeholder(ui, rect, &t, tl!("(no clip selected)"));
return;
};
let Some(seq) = app.session.active_sequence().cloned() else {
// Keep the immutable project snapshot alive while UI commands mutate the session.
// Cloning a sequence here copies every clip and effect on every redraw.
let project = app.session.project.clone();
let Some(seq) = app.session.state.active_sequence.and_then(|id| project.sequence(id)) else {
crate::dock::placeholder(ui, rect, &t, tl!("(no sequences)"));
return;
};
+4 -1
View File
@@ -61,7 +61,10 @@ pub const MARKER_FILTER: [filmcraft_project::Label; 7] = {
/// `markers.filter.<label>`), then the sequence markers (`markers.row.<id>`; click = go to it).
pub fn markers(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let Some(seq) = app.session.active_sequence().cloned() else {
// Keep the immutable project snapshot alive while UI commands mutate the session.
// Cloning a sequence here copies every clip and effect on every redraw.
let project = app.session.project.clone();
let Some(seq) = app.session.state.active_sequence.and_then(|id| project.sequence(id)) else {
crate::dock::placeholder(ui, rect, &t, tl!("(no sequence)"));
return;
};
+10 -4
View File
@@ -207,12 +207,15 @@ fn targets(seq: &Sequence, id: TrackId) -> Vec<&Track> {
pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let Some(seq) = app.session.active_sequence().cloned() else {
// Keep the immutable project snapshot alive while UI commands mutate the session.
// Cloning a sequence here copies every clip and effect on every redraw.
let project = app.session.project.clone();
let Some(seq) = app.session.state.active_sequence.and_then(|id| project.sequence(id)) else {
crate::dock::placeholder(ui, rect, &t, tl!("(no sequence)"));
return;
};
let meters = poll_meters(app, ui);
let mut cx = Ctx { t, seq: &seq, now: app.session.playhead(), recording: app.session.mixrec.active(), acts: Vec::new() };
let mut cx = Ctx { t, seq, now: app.session.playhead(), recording: app.session.mixrec.active(), acts: Vec::new() };
let footer = Rect::from_min_max(pos2(rect.min.x, rect.max.y - 34.0), rect.max);
let body = Rect::from_min_max(pos2(rect.min.x, rect.min.y + 2.0), pos2(rect.max.x, footer.min.y));
// effects / sends disclosure at the left edge
@@ -250,7 +253,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.ctx(),
|ui| {
for id in seq.audio_tracks.iter().chain(&seq.submix_tracks).map(|t| t.id) {
let label = strip_label(&seq, id);
let label = strip_label(seq, id);
let name = seq.mix_track(id).map(|t| t.name.clone()).unwrap_or_default();
let mut shown = !app.ui.mixer_hidden.contains(&id.0);
let r = ui.checkbox(&mut shown, format!("{label} {name}"));
@@ -888,7 +891,10 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, cx: &mut Ctx)
/// Volume and Panner). With the keyframe button on, changes add keyframes at the playhead.
pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let Some(seq) = app.session.active_sequence().cloned() else {
// Keep the immutable project snapshot alive while UI commands mutate the session.
// Cloning a sequence here copies every clip and effect on every redraw.
let project = app.session.project.clone();
let Some(seq) = app.session.state.active_sequence.and_then(|id| project.sequence(id)) else {
crate::dock::placeholder(ui, rect, &t, tl!("(no sequence)"));
return;
};
+2
View File
@@ -344,3 +344,5 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
ui.ctx().data_mut(|d| d.remove::<(PanelKind, egui::Pos2)>(id));
}
}
pub(crate) mod transcript_cache;
+8 -6
View File
@@ -74,7 +74,8 @@ fn tool_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, icon: Icon, i
fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let Some(seq) = app.session.active_sequence().cloned() else {
let project = app.session.project.clone();
let Some(seq) = app.session.state.active_sequence.and_then(|id| project.sequence(id)) else {
crate::dock::placeholder(ui, rect, &t, tl!("Open a sequence to work with captions"));
return;
};
@@ -304,7 +305,8 @@ fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
return;
}
let mut actions: Vec<(String, Value)> = Vec::new();
let words = filmcraft_engine::transcript::sequence_words(&app.session);
let content = app.transcript_cache.content(&app.session);
let words = &content.words;
if words.is_empty() {
let c = rect.center();
icons::paint(ui.painter(), Rect::from_center_size(c - vec2(0.0, 70.0), vec2(40.0, 40.0)), Icon::Captions, t.text_dim);
@@ -335,7 +337,7 @@ fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(bar.min, vec2(sw, 24.0))));
let sresp = crate::widgets::search_field(&mut child, &mut app.ui.transcript_search, tl!("Search"), sw, &t);
app.auto.add("text.transcript.search", sresp.rect, "Search transcript");
let hits: Vec<std::ops::Range<usize>> = filmcraft_edit::transcript::search(&words, &app.ui.transcript_search);
let highlights = app.transcript_cache.highlights(&app.ui.transcript_search);
let mut x = bar.min.x + sw + 10.0;
let range = sel.map(|(a, b)| json!({"from": a.min(b), "to": a.max(b)}));
let tools: [(Icon, &str, &str, bool, &str, Value); 4] = [
@@ -362,8 +364,8 @@ fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let list = Rect::from_min_max(pos2(rect.min.x + 6.0, bar.max.y + 8.0), pos2(rect.max.x - 6.0, rect.max.y - 4.0));
ui.painter().rect_filled(list, 3.0, t.app_bg);
let ph = app.session.playhead();
let current = filmcraft_edit::transcript::word_at(&words, ph);
let paras = filmcraft_edit::transcript::paragraphs(&words, filmcraft_time::Tick::from_seconds_f64(1.5));
let current = filmcraft_edit::transcript::word_at(words, ph);
let paras = &content.paragraphs;
let rate = app.session.sequence_rate();
let df = app.session.active_sequence().is_some_and(|q| q.settings.drop_frame);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(list.shrink(6.0)).id_salt("transcript-list"));
@@ -379,7 +381,7 @@ fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
for i in pr.clone() {
let w = &words[i];
let in_sel = sa.is_some_and(|a| i >= a) && sb.is_some_and(|b| i <= b);
let hit = hits.iter().any(|h| h.contains(&i));
let hit = highlights.get(i).copied().unwrap_or(false);
let mut text = egui::RichText::new(&w.text).size(13.0).color(if Some(i) == current { t.hot_text } else { t.text });
if in_sel {
text = text.background_color(t.row_selected);
+13 -10
View File
@@ -273,7 +273,10 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
return;
};
// A damaged project can name an active sequence that no longer exists.
let Some(seq) = app.session.active_sequence().cloned() else {
// Keep the immutable project snapshot alive while UI commands mutate the session.
// Cloning a sequence here copies every clip and effect on every redraw.
let project = app.session.project.clone();
let Some(seq) = app.session.state.active_sequence.and_then(|id| project.sequence(id)) else {
empty_state(app, ui, rect);
return;
};
@@ -446,7 +449,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
// clips whose media changes in the preview (a trimmed or rolled edge): drawn from the edited
// item, so the waveform and thumbnail stay where the media is instead of squeezing (#374)
let mut edited: HashMap<ClipId, TrackItem> = HashMap::new();
preview_drag(app, &seq, &mut previews, &mut edited);
preview_drag(app, seq, &mut previews, &mut edited);
// Option-drag copies: the clips stay where they are and their copies follow the pointer
let copying = matches!(app.tl.drag, Some(Drag::Move { copy: true, .. }));
let copies: Vec<(ClipId, (Tick, Tick, Option<TrackId>))> = if copying { previews.drain().collect() } else { Vec::new() };
@@ -535,7 +538,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
// selected edit points (trim mode): red brackets for ripple, yellow for roll / regular trim
super::trim_monitor::paint_edit_points(app, &p, &seq, tr, r, &layout);
super::trim_monitor::paint_edit_points(app, &p, seq, tr, r, &layout);
// transitions (one being dragged where the drag puts it; the selected ones outlined)
for trn in &tr.transitions {
let (start, end) = match &app.tl.drag {
@@ -556,7 +559,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// ---- track headers
draw_headers(app, ui, &seq, &rows, rect, vclip, aclip, &t);
draw_headers(app, ui, seq, &rows, rect, vclip, aclip, &t);
// divider between video and audio
let div = Rect::from_min_max(pos2(rect.min.x, split_y), pos2(content.max.x, split_y + DIVIDER_H));
painter.rect_filled(div, 0.0, t.app_bg);
@@ -583,11 +586,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
// ---- caption tracks
if cap_n > 0 {
painter.rect_filled(Rect::from_min_max(pos2(rect.min.x, caption_area.max.y), pos2(content.max.x, caption_area.max.y + DIVIDER_H)), 0.0, t.app_bg);
super::timeline_captions::paint(app, ui, &seq, caption_area, &layout, &t);
super::timeline_captions::paint(app, ui, seq, caption_area, &layout, &t);
}
// ---- top block: timecode + toggles, ruler
draw_top(app, ui, rect, &seq, &layout, &t, seq_id);
draw_top(app, ui, rect, seq, &layout, &t, seq_id);
// ---- playhead
let ph = app.session.playhead();
@@ -636,12 +639,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
);
// ---- interaction
interact(app, ui, &seq, &layout, rect);
super::timeline_volume::interact(app, ui, &seq, &layout, aclip);
interact(app, ui, seq, &layout, rect);
super::timeline_volume::interact(app, ui, seq, &layout, aclip);
// track keyframes (drawn and edited on top of the clips)
super::timeline_automation::show(app, ui, &seq, &layout, aclip);
super::timeline_automation::show(app, ui, seq, &layout, aclip);
if cap_n > 0 {
super::timeline_captions::interact(app, ui, &seq, caption_area, &layout);
super::timeline_captions::interact(app, ui, seq, caption_area, &layout);
}
let _ = (visible, TICKS_PER_SECOND);
}
@@ -0,0 +1,106 @@
//! Derived transcript data survives playhead movement and UI redraws, but never project edits.
use std::ops::Range;
use std::sync::{Arc, Weak};
use filmcraft_edit::transcript::{SeqWord, paragraphs, search};
use filmcraft_engine::Session;
use filmcraft_project::{ItemId, Project};
use filmcraft_time::Tick;
#[derive(Default)]
pub(crate) struct Content {
pub words: Vec<SeqWord>,
pub paragraphs: Vec<Range<usize>>,
}
#[derive(Default)]
pub(crate) struct TranscriptCache {
// A weak key neither retains a closed project nor allows an allocation address to be reused.
project: Weak<Project>,
view: Option<ItemId>,
content: Arc<Content>,
query: Option<String>,
highlights: Arc<Vec<bool>>,
}
impl TranscriptCache {
pub fn content(&mut self, session: &Session) -> Arc<Content> {
let view = session.state.active_sequence;
let project = Arc::downgrade(&session.project);
if !self.project.ptr_eq(&project) || self.view != view {
let words = filmcraft_engine::transcript::sequence_words(session);
let paragraphs = paragraphs(&words, Tick::from_seconds_f64(1.5));
self.content = Arc::new(Content { words, paragraphs });
self.project = project;
self.view = view;
self.query = None;
}
self.content.clone()
}
pub fn highlights(&mut self, query: &str) -> Arc<Vec<bool>> {
if self.query.as_deref() != Some(query) {
let mut flags = vec![false; self.content.words.len()];
// Search ranges can overlap. Mark their union in linear time rather than checking
// every match for every word on every redraw.
let mut marked = 0;
for hit in search(&self.content.words, query) {
let start = hit.start.max(marked);
if let Some(slice) = flags.get_mut(start..hit.end) {
slice.fill(true);
}
marked = marked.max(hit.end);
}
self.highlights = Arc::new(flags);
self.query = Some(query.to_owned());
}
self.highlights.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn cached_sequence_refreshes_after_edit_undo_and_project_replacement() {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let clip = s.active_sequence().unwrap().audio_tracks[0].items[0].clone();
let transcript = |middle: &str| {
json!({"words": [
{"text": "hello", "start": clip.source_in.0, "end": clip.source_in.0 + 100},
{"text": middle, "start": clip.source_in.0 + 100, "end": clip.source_in.0 + 200},
{"text": "hello", "start": clip.source_in.0 + 200, "end": clip.source_in.0 + 300}]})
};
s.execute("transcript.set", json!({"item": clip.item.0, "transcript": transcript("hello")})).unwrap();
let mut cache = TranscriptCache::default();
let first = cache.content(&s);
assert_eq!(&*cache.highlights("hello hello"), &[true, true, true]);
assert_eq!(&*cache.highlights("missing"), &[false, false, false]);
s.set_playhead(Tick(150));
assert!(Arc::ptr_eq(&first, &cache.content(&s)));
s.execute("transcript.set", json!({"item": clip.item.0, "transcript": transcript("world")})).unwrap();
assert_eq!(cache.content(&s).words[1].text, "world");
assert_eq!(&*cache.highlights("hello hello"), &[false, false, false]);
s.undo();
assert_eq!(cache.content(&s).words[1].text, "hello");
s.execute("file.openDemoProject", json!({})).unwrap();
assert!(cache.content(&s).words.is_empty());
assert!(cache.highlights("hello").is_empty());
}
#[test]
fn changing_sequence_invalidates_content_without_a_project_edit() {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let mut cache = TranscriptCache::default();
let first = cache.content(&s);
s.state.active_sequence = None;
let empty = cache.content(&s);
assert!(!Arc::ptr_eq(&first, &empty));
assert!(empty.words.is_empty());
}
}
+42
View File
@@ -12,6 +12,48 @@ 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>,
+21
View File
@@ -4,6 +4,27 @@
and `.md` (options and sections: [testing.md](testing.md) §5). Agents read live counters with
`perf.stats` ([control-protocol.md](control-protocol.md)).
## Interactive builds and redraw work
For interactive editing, build with `cargo build --release -p filmcraft --features whisper` and
launch `target/release/filmcraft`. The development profile optimizes dependencies and codec hot
loops, but still builds the editor at optimization level 1; it is not the release performance
baseline. Keep the same build profile when comparing code changes.
Timeline, Effect Controls, Markers, Captions and mixer drawing borrow a sequence from a shared
immutable project snapshot instead of deep-copying its clips and effects on every redraw. Commands
can still replace the session's project during the pass; the snapshot stays valid until drawing
finishes. Transcript words and paragraphs are cached by project snapshot and active sequence, and
search highlights by query. Playhead movement reuses them; project edits, undo/redo and view changes
refresh them. Highlight lookup is constant work per word, including searches with many matches.
To measure a long transcript, run
`cargo test -p filmcraft-ui-egui --test transcript_ui long_transcript_redraw_benchmark -- --ignored --nocapture`
alone. It reports median and p95 redraw time for 10,000 words with an empty query and a query matching
every word. It measures UI update work, not native display presentation, and deliberately has no
machine-dependent timing assertion. The transcript still lays out every word; list virtualization
is future work.
**Read the numbers with the load average.** These results come from a shared Apple M4 Pro
(14 cores, 48 GB, macOS 15 / Darwin 24.6) with parallel agent builds running: load average
**75–200** during every run below. Wall-clock columns (fps, shown/dropped frames, seek latency)