diff --git a/crates/engine/src/scopes.rs b/crates/engine/src/scopes.rs
--- a/crates/engine/src/scopes.rs
+++ b/crates/engine/src/scopes.rs
@@ -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 {
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -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,
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -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>>,
diff --git a/crates/ui-egui/src/panels/scopes.rs b/crates/ui-egui/src/panels/scopes.rs
--- a/crates/ui-egui/src/panels/scopes.rs
+++ b/crates/ui-egui/src/panels/scopes.rs
@@ -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);
diff --git a/crates/ui-egui/src/scope_worker.rs b/crates/ui-egui/src/scope_worker.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/scope_worker.rs
@@ -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");
+    }
+}
diff --git a/crates/ui-egui/tests/it/color_ui.rs b/crates/ui-egui/tests/it/color_ui.rs
--- a/crates/ui-egui/tests/it/color_ui.rs
+++ b/crates/ui-egui/tests/it/color_ui.rs
@@ -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]
diff --git a/docs/architecture.md b/docs/architecture.md
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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
diff --git a/docs/performance.md b/docs/performance.md
--- a/docs/performance.md
+++ b/docs/performance.md
@@ -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
