Browse / github.com/storytold/filmcraft / patch
ui: Lumetri Scopes computed off the UI thread
ui: Lumetri Scopes computed off the UI thread Independent of the other open PRs: based on `main`, it touches neither `crates/ui-egui/src/frames.rs` nor the GPU / render code of the HDR series. ## What With the Lumetri Scopes panel open, playback lagged hard: every new frame was turned into a scope signal and scope images inside the UI pass. For Rec. 2100 scopes of an HDR sequence that is a working-space render of the frame, sources decoded (filmcraft_engine::scopes::scope_signal), on every refresh while playing. - ui: a scope worker (scope_worker.rs): one background thread, work keyed per slot (a panel's signal, each scope image), the
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- zsoli69 (imported from pull request #3232, open)
- Base
10d626ccc4888e0f020f9ca314c2bc9148ded4f4- Size
- +386 −41 in 8 files
- Review
- pending
- Shared
- 2026-10-28
- Id
13e9e41270246c64274e4049
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/scopes.rs crates/ui-egui/src/app.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/panels/scopes.rs crates/ui-egui/src/scope_worker.rs crates/ui-egui/tests/it/color_ui.rs docs/architecture.md docs/performance.md
@@ -15,14 +15,27 @@ use crate::{Result, Session}; pub fn scope_signal(s: &Session, t: Tick, scale: f32, hdr: bool) -> Option<Signal> { let seq = s.state.active_sequence?; let provider = s.media.provider(s.project.clone(), s.services.clone());+ Some(scope_signal_of(&s.project, seq, &provider, t, scale, hdr))+}++/// [`scope_signal`] of sequence `seq` with its own source provider, for a caller that renders+/// away from the session (the UI's scope worker thread).+pub fn scope_signal_of(+ project: &filmcraft_project::Project,+ seq: filmcraft_project::ItemId,+ provider: &dyn filmcraft_render::SourceProvider,+ t: Tick,+ scale: f32,+ hdr: bool,+) -> Signal { if hdr { let opts = filmcraft_render::RenderOptions { scale, working_output: true, ..Default::default() };- let img = filmcraft_render::render_sequence(&s.project, seq, t, opts, &provider);- return Some(Signal::from_rgba_f32_with(img.w, img.h, &img.px, scopes::MAX_W, scopes::MAX_H, scopes::linear_to_pq));+ let img = filmcraft_render::render_sequence(project, seq, t, opts, provider);+ return Signal::from_rgba_f32_with(img.w, img.h, &img.px, scopes::MAX_W, scopes::MAX_H, scopes::linear_to_pq); } let opts = filmcraft_render::RenderOptions { scale, captions: true, ..Default::default() };- let img = filmcraft_render::render_sequence(&s.project, seq, t, opts, &provider);- Some(Signal::from_rgba8(img.w, img.h, &img.over_black_rgba8()))+ let img = filmcraft_render::render_sequence(project, seq, t, opts, provider);+ Signal::from_rgba8(img.w, img.h, &img.over_black_rgba8()) } fn levels_json(w: &scopes::Waveform, columns: usize) -> Value {@@ -40,6 +40,7 @@ impl FilmcraftApp { ui: UiState { language, ..UiState::default() }, tokens: Tokens::for_kind(ThemeKind::Dark), frames,+ scope_worker: scope_worker::ScopeWorker::new(), playback: Playback { speed: 1.0, ..Default::default() }, source_playback: Default::default(), audio: None,@@ -57,6 +57,7 @@ pub mod perf; #[cfg(not(target_arch = "wasm32"))] pub mod play_ahead; mod rtl;+pub mod scope_worker; pub mod scrub; mod shuttle; pub mod source_playback;@@ -171,6 +172,8 @@ pub struct FilmcraftApp { pub ui: UiState, pub tokens: Tokens, pub frames: Arc<FrameServer>,+ /// Lumetri Scopes signals and images, computed off the UI pass.+ pub scope_worker: scope_worker::ScopeWorker, pub playback: Playback, pub source_playback: source_playback::SourcePlayback, pub audio: Option<Box<dyn AudioOut>>,@@ -1,8 +1,10 @@ //! Lumetri Scopes: Vectorscope YUV / HLS, Histogram, Parade (RGB / YUV / RGB-White) and Waveform //! (RGB / Luma / YC / YC no Chroma) of the Program frame, in a grid when several are shown. //!-//! The maths is in `filmcraft-scopes` (count grids from the decimated frame); this module turns-//! them into textures (cached per frame and settings) and draws the graticules, labels and the+//! The maths is in `filmcraft-scopes` (count grids from the decimated frame); this module has them+//! computed on the scope worker (`crate::scope_worker`, never in the UI pass, so open scopes do not+//! stall playback), turns them into textures (cached per frame and settings) and draws the+//! graticules, labels and the //! panel chrome: the footer (colour space, wrench, Clamp Signal, 8 Bit / Float / HDR) and the //! settings menu (wrench or right-click: Presets, the scopes, Waveform / Parade Type, Colour //! Space, Brightness, Vectorscope Targets).@@ -23,6 +25,7 @@ use crate::FilmcraftApp; use crate::frames::{FrameKey, Target}; use crate::icons::{self, Icon}; use crate::panels::panel_state::{SCOPE_PRESETS, ScopesState};+use crate::scope_worker::Done; use crate::theme::Tokens; const GRATICULE: Color32 = Color32::from_rgb(0x2a, 0x2a, 0x2a);@@ -46,9 +49,20 @@ fn hash_of(v: impl Hash) -> u64 { h.finish() } +/// While playing, a new HDR scope render starts at most this often: it renders (and decodes) the+/// frame a second time, next to the Program monitor's own work.+const HDR_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100);++/// A scope signal the worker made, and whether it holds PQ code values.+struct SignalOut {+ signal: Arc<Signal>,+ pq: bool,+}+ /// The active sequence's frame `frame` for the scopes: the monitor picture (¼ resolution, from /// the frame workers at priority `prio`) or, for Rec. 2100 scopes of an HDR sequence, a small-/// working-space render. While a new frame renders the last one stays.+/// working-space render. Both are turned into a signal on the scope worker, never in the UI pass;+/// until the new frame's signal is there the last one stays. pub fn frame_signal(app: &mut FilmcraftApp, ctx: &egui::Context, slot: &str, frame: i64, st: &ScopesState, prio: u32) -> Option<ScopeFrame> { let seq_id = app.session.state.active_sequence?; let q = app.session.active_sequence()?;@@ -58,29 +72,45 @@ pub fn frame_signal(app: &mut FilmcraftApp, ctx: &egui::Context, slot: &str, fra let rate = q.settings.frame_rate; let rev = app.session.revision; let id = egui::Id::unique(("scope-signal", slot));- let cached: Option<(u64, Arc<Signal>, bool)> = ctx.data(|d| d.get_temp(id));+ let mut cached: Option<(u64, Arc<Signal>, bool)> = ctx.data(|d| d.get_temp(id)); let key = hash_of((seq_id.0, frame, rev, pq));+ // the newest signal the worker finished for this slot (while playing, an earlier frame's)+ let wslot = format!("signal-{slot}");+ if let Some((k, done)) = app.scope_worker.result(&wslot)+ && cached.as_ref().is_none_or(|c| c.0 != k)+ && let Some(out) = done.downcast_ref::<SignalOut>()+ {+ cached = Some((k, out.signal.clone(), out.pq));+ ctx.data_mut(|d| d.insert_temp(id, (k, out.signal.clone(), out.pq)));+ } if let Some((k, s, p)) = &cached && *k == key { return Some(ScopeFrame { signal: s.clone(), key, pq: *p, space }); }- let fresh = if pq {- filmcraft_engine::scopes::scope_signal(&app.session, rate.tick_of(frame), 0.125, true)+ if pq {+ let at = egui::Id::unique(("scope-signal-at", slot));+ let now = web_time::Instant::now();+ let last: Option<web_time::Instant> = ctx.data(|d| d.get_temp(at));+ if !app.playback.playing || last.is_none_or(|t| now.duration_since(t) >= HDR_INTERVAL) {+ ctx.data_mut(|d| d.insert_temp(at, now));+ let (project, pool, services, t) = (app.session.project.clone(), app.session.media.clone(), app.session.services.clone(), rate.tick_of(frame));+ app.scope_worker.submit(ctx, &wslot, key, move || {+ let provider = pool.provider(project.clone(), services);+ let signal = Arc::new(filmcraft_engine::scopes::scope_signal_of(&project, seq_id, &provider, t, 0.125, true));+ Some(Arc::new(SignalOut { signal, pq: true }) as Done)+ });+ } } else { let fkey = FrameKey { target: Target::Sequence(seq_id), frame, size: 250, revision: rev, draft: false }; let project = app.session.project.clone(); app.frames.request(fkey, rate.tick_of(frame), 0.25, &project, prio);- app.frames.get(&fkey).map(|img| Signal::from_rgba8(img.w, img.h, &img.px))- };- match fresh {- Some(s) => {- let s = Arc::new(s);- ctx.data_mut(|d| d.insert_temp(id, (key, s.clone(), pq)));- Some(ScopeFrame { signal: s, key, pq, space })+ if let Some(img) = app.frames.get(&fkey) {+ app.scope_worker+ .submit(ctx, &wslot, key, move || Some(Arc::new(SignalOut { signal: Arc::new(Signal::from_rgba8(img.w, img.h, &img.px)), pq: false }) as Done)); }- None => cached.filter(|c| c.2 == pq).map(|(k, s, p)| ScopeFrame { signal: s, key: k, pq: p, space }), }+ cached.filter(|c| c.2 == pq).map(|(k, s, p)| ScopeFrame { signal: s, key: k, pq: p, space }) } /// The Lumetri Scopes panel.@@ -312,22 +342,36 @@ fn plotted(st: &ScopesState, sf: &ScopeFrame) -> Arc<Signal> { if st.scale == Scale::Hdr && !sf.pq { Arc::new(sf.signal.map(|c| c.map(sc::sdr_to_pq))) } else { sf.signal.clone() } } -/// A texture computed from (frame, settings, scope), kept until either changes.-fn cached_texture(ctx: &egui::Context, slot: String, key: u64, make: impl FnOnce() -> (usize, usize, Vec<u8>)) -> Option<TextureHandle> {+/// A scope image the worker made: width, height, premultiplied RGBA.+type ImageOut = (usize, usize, Vec<u8>);++/// A texture computed from (frame, settings, scope), kept until either changes. `make` runs on the+/// scope worker; until its image is there the slot's last texture stays (none at first).+fn cached_texture(app: &FilmcraftApp, ctx: &egui::Context, slot: String, key: u64, make: impl FnOnce() -> ImageOut + Send + 'static) -> Option<TextureHandle> { let id = egui::Id::unique(("scope-tex", slot.clone()));- if let Some((k, tex)) = ctx.data(|d| d.get_temp::<(u64, TextureHandle)>(id))- && k == key+ let mut cached: Option<(u64, TextureHandle)> = ctx.data(|d| d.get_temp::<(u64, TextureHandle)>(id));+ if let Some((k, tex)) = &cached+ && *k == key {- return Some(tex);+ return Some(tex.clone()); }- let (w, h, px) = make();- if w == 0 || h == 0 {- return None;+ let wslot = format!("texture-{slot}");+ if let Some((k, done)) = app.scope_worker.result(&wslot)+ && cached.as_ref().is_none_or(|c| c.0 != k)+ && let Some((w, h, px)) = done.downcast_ref::<ImageOut>()+ && *w > 0+ && *h > 0+ && px.len() == w.saturating_mul(*h).saturating_mul(4)+ {+ let tex = ctx.load_texture(slot, egui::ColorImage::from_rgba_premultiplied([*w, *h], px), TextureOptions::LINEAR);+ ctx.data_mut(|d| d.insert_temp(id, (k, tex.clone())));+ if k == key {+ return Some(tex);+ }+ cached = Some((k, tex)); }- let img = egui::ColorImage::from_rgba_premultiplied([w, h], &px);- let tex = ctx.load_texture(slot, img, TextureOptions::LINEAR);- ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));- Some(tex)+ app.scope_worker.submit(ctx, &wslot, key, move || Some(Arc::new(make()) as Done));+ cached.map(|c| c.1) } fn settings_key(st: &ScopesState) -> u64 {@@ -365,14 +409,15 @@ fn draw_one(app: &mut FilmcraftApp, ui: &mut egui::Ui, cell: Rect, k: ScopeKind, if plot.width() < 8.0 || plot.height() < 8.0 { return; }- let tex = cached_texture(&ctx, slot, key, || {- let sig = plotted(st, sf);+ let (st2, sf2) = (st.clone(), sf.clone());+ let tex = cached_texture(app, &ctx, slot, key, move || {+ let sig = plotted(&st2, &sf2); if k == ScopeKind::Waveform {- let w = sc::waveform(&sig, st.waveform_type, &p);- let layers: Vec<(&sc::Grid, [f32; 3])> = w.traces.iter().map(|g| (g, trace_color(g.name, Some(st.waveform_type)))).collect();+ let w = sc::waveform(&sig, st2.waveform_type, &p);+ let layers: Vec<(&sc::Grid, [f32; 3])> = w.traces.iter().map(|g| (g, trace_color(g.name, Some(st2.waveform_type)))).collect(); sc::paint::grids(&layers, sc::paint::waveform_reference(w.per_column), gain) } else {- let w = sc::parade(&sig, st.parade_type, &p);+ let w = sc::parade(&sig, st2.parade_type, &p); parade_image(&w, gain) } });@@ -404,7 +449,8 @@ fn draw_one(app: &mut FilmcraftApp, ui: &mut egui::Ui, cell: Rect, k: ScopeKind, if plot.width() < 8.0 || plot.height() < 8.0 { return; }- let tex = cached_texture(&ctx, slot, key, || histogram_image(&sc::histogram(&plotted(st, sf), &p), gain));+ let (st2, sf2) = (st.clone(), sf.clone());+ let tex = cached_texture(app, &ctx, slot, key, move || histogram_image(&sc::histogram(&plotted(&st2, &sf2), &p), gain)); level_graticule(ui, plot, st, sf, (0.0, 1.0)); if let Some(tex) = tex { ui.painter().image(tex.id(), plot, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);@@ -418,8 +464,9 @@ fn draw_one(app: &mut FilmcraftApp, ui: &mut egui::Ui, cell: Rect, k: ScopeKind, let side = (cell.width().min(cell.height()) - 16.0).max(8.0); let sq = Rect::from_center_size(cell.center(), vec2(side, side)); let hls = k == ScopeKind::VectorscopeHls;- let tex = cached_texture(&ctx, slot, key, || {- let v = if hls { sc::vectorscope_hls(&sf.signal, &p) } else { sc::vectorscope_yuv(&sf.signal, &p) };+ let signal = sf.signal.clone();+ let tex = cached_texture(app, &ctx, slot, key, move || {+ let v = if hls { sc::vectorscope_hls(&signal, &p) } else { sc::vectorscope_yuv(&signal, &p) }; sc::paint::vectorscope(&v, WHITE, gain, true) }); vector_graticule(ui, sq, sf.space, st.targets, hls);new file mode 100644@@ -0,0 +1,246 @@+//! The Lumetri Scopes' background worker: the scope signal of a frame (for HDR scopes a+//! working-space render, sources decoded) and the scope images are computed here, never in the UI+//! pass, so an open Scopes panel cannot stall the preview.+//!+//! Work is keyed per *slot* (a scope signal or one scope image of a panel) and the newest request+//! wins: a request that has not started yet is replaced by the next one for its slot, so playback+//! never queues up scope work. The panel shows the last finished result until the new one lands+//! (the worker asks egui for a repaint then). Without threads (wasm32) a request runs at once.++use std::any::Any;+use std::collections::HashMap;+use std::sync::{Arc, Condvar, Mutex, PoisonError};++/// A finished piece of scope work, shared with the panels that show it.+pub type Done = Arc<dyn Any + Send + Sync>;++type Job = Box<dyn FnOnce() -> Option<Done> + Send>;++#[derive(Default)]+struct State {+ /// Slot → (key, job) not started yet.+ pending: HashMap<String, (u64, Job)>,+ /// Slot → the key of the job running for it.+ running: HashMap<String, u64>,+ /// Slot → the newest finished result and its key.+ done: HashMap<String, (u64, Done)>,+ /// Jobs run so far (tests).+ ran: u64,+ stop: bool,+}++struct Shared {+ state: Mutex<State>,+ cv: Condvar,+ repaint: Mutex<Option<egui::Context>>,+}++pub struct ScopeWorker {+ shared: Arc<Shared>,+}++impl Default for ScopeWorker {+ fn default() -> Self {+ Self::new()+ }+}++impl ScopeWorker {+ /// A worker with its thread (none on wasm32: requests run on submit).+ pub fn new() -> Self {+ let shared = Arc::new(Shared { state: Mutex::new(State::default()), cv: Condvar::new(), repaint: Mutex::new(None) });+ #[cfg(not(target_arch = "wasm32"))]+ {+ let sh = shared.clone();+ if let Err(e) = std::thread::Builder::new().name("filmcraft-scopes".into()).spawn(move || run(&sh)) {+ log::error!("scopes: no worker thread ({e}); scopes are computed in the UI pass");+ }+ }+ Self { shared }+ }++ fn state(&self) -> std::sync::MutexGuard<'_, State> {+ self.shared.state.lock().unwrap_or_else(PoisonError::into_inner)+ }++ /// Ask for `job`'s result under `key` in `slot`, unless that result is already there or on+ /// its way; a job for an older key that has not started is dropped. `ctx` is repainted when+ /// the result lands.+ pub fn submit(&self, ctx: &egui::Context, slot: &str, key: u64, job: impl FnOnce() -> Option<Done> + Send + 'static) {+ *self.shared.repaint.lock().unwrap_or_else(PoisonError::into_inner) = Some(ctx.clone());+ let mut st = self.state();+ if st.done.get(slot).is_some_and(|d| d.0 == key) || st.running.get(slot) == Some(&key) || st.pending.get(slot).is_some_and(|p| p.0 == key) {+ return;+ }+ st.pending.insert(slot.to_string(), (key, Box::new(job)));+ drop(st);+ if cfg!(target_arch = "wasm32") || !self.has_thread() {+ self.run_pending();+ } else {+ self.shared.cv.notify_one();+ }+ }++ /// The newest finished result of `slot` (any key) and its key.+ pub fn result(&self, slot: &str) -> Option<(u64, Done)> {+ self.state().done.get(slot).cloned()+ }++ /// Jobs run so far.+ pub fn ran(&self) -> u64 {+ self.state().ran+ }++ /// Whether work for `slot` is queued or running.+ pub fn busy(&self, slot: &str) -> bool {+ let st = self.state();+ st.pending.contains_key(slot) || st.running.contains_key(slot)+ }++ fn has_thread(&self) -> bool {+ // the worker thread holds the only other reference+ Arc::strong_count(&self.shared) > 1+ }++ /// Run the queued jobs on this thread (wasm32, or no worker thread).+ fn run_pending(&self) {+ while let Some((slot, key, job)) = take(&self.shared) {+ finish(&self.shared, slot, key, job);+ }+ }+}++impl Drop for ScopeWorker {+ fn drop(&mut self) {+ self.state().stop = true;+ self.shared.cv.notify_all();+ }+}++/// The next queued job, marked running.+fn take(sh: &Shared) -> Option<(String, u64, Job)> {+ let mut st = sh.state.lock().unwrap_or_else(PoisonError::into_inner);+ let slot = st.pending.keys().next().cloned()?;+ let (key, job) = st.pending.remove(&slot)?;+ st.running.insert(slot.clone(), key);+ Some((slot, key, job))+}++/// Run `job` (a panic is logged and leaves the slot's last result) and store what it made.+fn finish(sh: &Shared, slot: String, key: u64, job: Job) {+ let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job)).unwrap_or_else(|_| {+ log::error!("scopes: computing {slot} panicked");+ None+ });+ let mut st = sh.state.lock().unwrap_or_else(PoisonError::into_inner);+ st.running.remove(&slot);+ st.ran += 1;+ if let Some(out) = out {+ st.done.insert(slot, (key, out));+ }+ drop(st);+ if let Some(ctx) = sh.repaint.lock().unwrap_or_else(PoisonError::into_inner).as_ref() {+ ctx.request_repaint();+ }+}++#[cfg(not(target_arch = "wasm32"))]+fn run(sh: &Arc<Shared>) {+ loop {+ let next = {+ let mut st = sh.state.lock().unwrap_or_else(PoisonError::into_inner);+ loop {+ if st.stop {+ return;+ }+ if !st.pending.is_empty() {+ break;+ }+ st = sh.cv.wait(st).unwrap_or_else(PoisonError::into_inner);+ }+ drop(st);+ take(sh)+ };+ if let Some((slot, key, job)) = next {+ finish(sh, slot, key, job);+ }+ }+}++#[cfg(test)]+mod tests {+ use super::*;+ use std::sync::atomic::{AtomicUsize, Ordering};+ use std::time::{Duration, Instant};++ fn wait(w: &ScopeWorker, slot: &str, key: u64) -> Option<Done> {+ let t0 = Instant::now();+ while t0.elapsed() < Duration::from_secs(10) {+ if let Some((k, d)) = w.result(slot)+ && k == key+ {+ return Some(d);+ }+ std::thread::sleep(Duration::from_millis(2));+ }+ None+ }++ #[test]+ fn the_newest_request_of_a_slot_wins() {+ let w = ScopeWorker::new();+ let ctx = egui::Context::default();+ // hold the worker on a slow job, then queue three requests for one slot+ let (tx, rx) = std::sync::mpsc::channel::<()>();+ w.submit(&ctx, "block", 1, move || {+ let _ = rx.recv_timeout(Duration::from_secs(10));+ None+ });+ while !w.busy("block") || w.state().pending.contains_key("block") {+ std::thread::sleep(Duration::from_millis(1));+ }+ let runs = Arc::new(AtomicUsize::new(0));+ for key in [10u64, 11, 12] {+ let runs = runs.clone();+ w.submit(&ctx, "sig", key, move || {+ runs.fetch_add(1, Ordering::Relaxed);+ Some(Arc::new(key) as Done)+ });+ }+ let _ = tx.send(());+ let d = wait(&w, "sig", 12).expect("result");+ assert_eq!(d.downcast_ref::<u64>(), Some(&12));+ assert_eq!(runs.load(Ordering::Relaxed), 1, "the two older requests never ran");+ // the same key again: nothing to do+ let before = w.ran();+ w.submit(&ctx, "sig", 12, || Some(Arc::new(0u64) as Done));+ std::thread::sleep(Duration::from_millis(20));+ assert_eq!(w.ran(), before);+ }++ #[test]+ fn jobs_run_on_the_worker_thread_not_the_caller() {+ let w = ScopeWorker::new();+ let ctx = egui::Context::default();+ w.submit(&ctx, "sig", 1, || Some(Arc::new(std::thread::current().name().map(str::to_string)) as Done));+ let d = wait(&w, "sig", 1).expect("result");+ assert_eq!(d.downcast_ref::<Option<String>>(), Some(&Some("filmcraft-scopes".to_string())));+ }++ #[test]+ fn a_panicking_job_leaves_the_last_result_and_the_worker_running() {+ let w = ScopeWorker::new();+ let ctx = egui::Context::default();+ w.submit(&ctx, "img", 1, || Some(Arc::new("first") as Done));+ assert!(wait(&w, "img", 1).is_some());+ #[allow(clippy::panic)]+ w.submit(&ctx, "img", 2, || panic!("scope maths failed"));+ let t0 = Instant::now();+ while w.busy("img") && t0.elapsed() < Duration::from_secs(10) {+ std::thread::sleep(Duration::from_millis(2));+ }+ assert_eq!(w.result("img").map(|r| r.0), Some(1), "the last good result stays");+ w.submit(&ctx, "img", 3, || Some(Arc::new("third") as Done));+ assert!(wait(&w, "img", 3).is_some(), "the worker still runs");+ }+}@@ -194,8 +194,9 @@ fn interpret_footage_and_sequence_color_dialogs_and_hdr_scopes() { assert_eq!(st["workingSpace"], "rec2100-pq"); // the scopes switch to the HDR waveform (cd/m², PQ scale) d.ok("ui.panel.show", json!({"panel": "LumetriScopes"}));- d.frames(6);- assert!(d.ids("scopes.").iter().any(|i| i == "scopes.hdrWaveform"), "{:?}", d.ids("scopes."));+ // (the HDR render runs on the scope worker; the waveform appears when it is done)+ d.wait_for("scopes.hdrWaveform");+ assert!(d.app().scope_worker.ran() > 0, "the HDR scope render ran on the scope worker"); d.snapshot("color-hdr-scopes", None); // undo the working space and the interpretation d.exec("edit.undo", json!({}));@@ -236,6 +237,18 @@ impl Driver { fn app(&mut self) -> &mut FilmcraftApp { self.harness.state_mut() }++ /// Run frames until `id` is on screen (work done off the UI pass, such as the scopes).+ fn wait_for(&mut self, id: &str) {+ for _ in 0..400 {+ if self.ids(id).iter().any(|i| i == id) {+ return;+ }+ std::thread::sleep(std::time::Duration::from_millis(5));+ self.frames(1);+ }+ panic!("{id} never appeared: {:?}", self.ids(id.split('.').next().unwrap_or(id)));+ } } #[test]@@ -593,7 +593,11 @@ frame (Y'CbCr/RGB + metadata) ─► source colour space: Interpret Footage over HDR sequences are shown tone mapped. macOS EDR (extended-range `CAMetalLayer` output) is not wired up: there is no `platform` crate yet and eframe/wgpu do not expose an EDR surface, so HDR values above SDR white are never sent to the display. The scopes of an HDR sequence show the- working-space values (waveform in cd/m² on a PQ scale, BT.2020 vectorscope).+ working-space values (waveform in cd/m² on a PQ scale, BT.2020 vectorscope). The Lumetri Scopes+ panel computes its signal and images on the UI's scope worker (`crates/ui-egui/src/scope_worker.rs`),+ never in the UI pass: the newest request per scope wins and the last result stays on screen until+ the next lands. An HDR scope render (the frame rendered again, in working space) starts at most+ every 100 ms while playing. - **LUTs.** Lumetri Input LUT and Creative Look reference `lib:<id>` (the project's LUT library, `Project::luts`, embedded `.cube`/`.3dl` text) or `builtin:<id>` (code-generated camera conversions and looks). `filmcraft-gpu::GpuLut` is the WGSL tetrahedral counterpart, tested for@@ -759,6 +759,24 @@ is the ~9 % of decoder cycles above, plus a quarter / sixteenth of the texture u frame jobs wait for the decoder less (decode ms/job 212 → 67 at 1/2, 98 → 44 at 1/4 in the busy round). On an idle machine the 4K clip needs about two cores. +### Lumetri Scopes: UI pass time, 2160p PQ (`egui_kittest`)++A 4K50 HEVC Main 10 PQ clip (ffmpeg `testsrc2`, `target/fixtures`) in a Rec. 2100 PQ sequence,+the Lumetri Scopes panel showing (HDR waveform): the playhead steps one frame at a time and each UI+pass is timed (60 steps, release build, 1600×980 harness; hardware decoding is not registered in+the harness, so the clip decodes in software). Ryzen 7 5800X3D, load average ~5. Measured once,+before and after the scope worker on the same tree (the 11 PRs of the HDR / playback series+merged); a temporary test, not committed.++| | UI pass p50 | p95 | max |+|---|---|---|---|+| scopes closed | 3.4–3.6 ms | 8–11 ms | 13–29 ms |+| scopes open, before (signal and scopes in the UI pass) | 24.1 ms | 780.5 ms | 968.5 ms |+| scopes open, after (scope worker) | 3.3 ms | 10.5 ms | 19.4 ms |++Before, each new frame rendered the working-space picture (decoding the source) inside the UI+pass; after, the UI pass with the scopes open costs what it costs without them.+ ## What M4.9 changed 1. **Deblocking** (25–30 % of 4K decode time before): the edge filter runs on all 16 luma / 8