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:
1 parent
3fdaa813e0
commit
d795b9a25e
10 files changed
+212
-22
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user