From 828ac72d6decc104bb9e3f226b9609de815a983a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 23:53:11 -0800 Subject: [PATCH 1/6] chore(quest): claim quest/m1/overlay-restart.md Quest-Claim: ec9618a013f99914 From 3ec7ce9b4c1cd8fe9cedf5cf8d16e7fdaf4240b4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 00:13:20 -0800 Subject: [PATCH 2/6] feat(scene): overlays restart on air An overlay's CSS timeline starts over each time a scene showing it goes on program, or into preview while program does not show it, as keyframes do. A new per-overlay `restart` flag, true by default, turns it off for a clock or ticker that should run on across cuts. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 ++- quest/m1/README.md | 1 - quest/m1/overlay-restart.md | 27 ----- rs/moq-scene/src/collection.rs | 26 ++++- rs/moq-scene/src/compositor.rs | 208 +++++++++++++++++++++++++++------ rs/moq-scene/src/overlay.rs | 47 +++++++- rs/moq-scene/src/source.rs | 1 + rs/moq-studio/src/ui.rs | 12 +- 8 files changed, 262 insertions(+), 74 deletions(-) delete mode 100644 quest/m1/overlay-restart.md diff --git a/README.md b/README.md index c041ca1a..9953d15b 100644 --- a/README.md +++ b/README.md @@ -302,8 +302,15 @@ property it refused: draws more. An overlay's animation starts when the overlay does: when the studio opens -it, and again whenever it is edited. One that no preview or program shows -costs nothing until it is shown again, where its animation has carried on. +it, and again whenever it is edited. It also starts over each time a scene +showing it goes on program, as an item's keyframes do, so a lower third plays +its entrance at every cut, and when a scene showing it goes into preview while +program does not show it, so the preview shows the entrance too. Preview and +program share its timeline, so a preview never restarts one program shows. +Set `restart = false` (**Restart on program**, off, in the add form) for one +that runs on across cuts, such as a clock or a ticker. One that no preview or +program shows costs nothing until it is shown again, where its animation has +carried on unless it restarts. Overlays publish no track of their own, so viewers see them only in the program. @@ -496,6 +503,7 @@ height = 600 kind = "overlay" width = 1920 # the overlay's own size, like a page's height = 1080 +restart = true # start over each time it goes on program, the default html = '''
" + ), + width: 8, + height: 8, + restart, + }; + collection.sources.insert("third".into(), overlay(0, true)); + collection.sources.insert("clock".into(), overlay(4, false)); + let fill = Rect::fill(&collection.canvas); + collection.scenes[1].items = vec![Item::new("third", fill), Item::new("clock", fill)]; + let mut studio = Studio::new(collection).await; + let half = [128, 0, 128, 255]; + let near = + |actual: [u8; 4], expected: [u8; 4]| actual.iter().zip(expected).all(|(a, e)| a.abs_diff(e) <= 2); + + // Both open here, drawn nowhere. + let start = Instant::now(); + studio.compositor.set_preview(Some("red".into())); + studio.compositor.cut(&studio.sources); + studio.render(start); + + let previewed = start + Duration::from_secs(2); + studio.compositor.set_preview(Some("blue".into())); + studio.render(previewed); + studio.render(previewed + Duration::from_millis(500)); + let preview = studio.preview(); + assert!( + near(pixel(&preview, 3, 1), half), + "in preview: {:?}", + pixel(&preview, 3, 1) + ); + assert_eq!(pixel(&preview, 3, 6), BLUE, "the clock ran on"); + + let cut = previewed + Duration::from_secs(3); + studio.compositor.cut(&studio.sources); + studio.render(cut); + assert_eq!(pixel(&studio.program(), 3, 1), RED, "on program, from the start"); + studio.render(cut + Duration::from_millis(500)); + let program = studio.program(); + assert!( + near(pixel(&program, 3, 1), half), + "on program: {:?}", + pixel(&program, 3, 1) + ); + assert_eq!(pixel(&program, 3, 6), BLUE, "the clock ran on"); + + // Into preview again, but program shows it, so it runs on in both. + studio.compositor.set_preview(Some("blue".into())); + studio.render(cut + Duration::from_millis(750)); + let quarter = [64, 0, 191, 255]; + for (output, picture) in [("program", studio.program()), ("preview", studio.preview())] { + let actual = pixel(&picture, 3, 1); + assert!(near(actual, quarter), "{output}: {actual:?}"); + } + }) + .await; + } + /// An overlay a move glides is painted on the program clock while both /// scenes are on air, and drawn where the move has it. #[tokio::test] @@ -2899,6 +3039,7 @@ mod tests { html: "".into(), width: 4, height: 4, + restart: true, }, ); let tile = |x, y| Rect { @@ -2942,6 +3083,7 @@ mod tests { html: format!(""), width: 8, height: 8, + restart: true, }; collection.sources.insert("shown".into(), overlay("lime")); collection.sources.insert("aside".into(), overlay("yellow")); diff --git a/rs/moq-scene/src/overlay.rs b/rs/moq-scene/src/overlay.rs index 7d66cebc..e4c816d5 100644 --- a/rs/moq-scene/src/overlay.rs +++ b/rs/moq-scene/src/overlay.rs @@ -565,6 +565,8 @@ enum Request { start: Instant, }, Close(u64), + /// Start an overlay's CSS timeline over at this time on the program clock. + Restart(u64, Instant), /// Paint these overlays as of this time on the program clock. Paint(Instant, Vec), /// Stop the thread the way a panic in Blitz would. @@ -629,6 +631,7 @@ impl Overlays { Overlay { id, texture, + start, requests: self.requests.clone(), } } @@ -658,6 +661,8 @@ impl Overlays { pub(crate) struct Overlay { id: u64, texture: wgpu::Texture, + /// Where its CSS timeline starts on the program clock. + start: Instant, requests: mpsc::Sender, } @@ -671,6 +676,19 @@ impl Overlay { pub fn texture(&self) -> &wgpu::Texture { &self.texture } + + /// Where its CSS timeline starts on the program clock. + pub fn start(&self) -> Instant { + self.start + } + + /// Start its CSS timeline over at `start` on the program clock, laid out + /// afresh, so the next paint shows it as of then. + pub fn restart(&mut self, start: Instant) { + self.start = start; + // A thread that stopped fails the next paint, which says so. + let _ = self.requests.send(Request::Restart(self.id, start)); + } } impl Drop for Overlay { @@ -699,13 +717,9 @@ fn run( start, } => { let size = view.texture().size(); - let mut document = document(&html, size.width, size.height); - // Stylo starts an animation at the time of the resolve that - // creates it, so this one starts every animation at the start of - // the timeline, however late the overlay is first drawn. - document.resolve(0.0); let document = Document { - document, + document: laid_out(&html, size.width, size.height), + html, paint, view, premultiply: None, @@ -717,6 +731,14 @@ fn run( Request::Close(id) => { documents.remove(&id); } + Request::Restart(id, start) => { + if let Some(document) = documents.get_mut(&id) { + let size = document.view.texture().size(); + document.document = laid_out(&document.html, size.width, size.height); + document.start = start; + document.painted = false; + } + } #[cfg(test)] Request::Panic => panic!("a test stopped the overlay thread"), Request::Paint(now, ids) => { @@ -740,6 +762,17 @@ fn run( } } +/// `html` laid out at `width` by `height`, its animations at the start of its +/// CSS timeline. +fn laid_out(html: &str, width: u32, height: u32) -> BaseDocument { + let mut document = document(html, width, height); + // Stylo starts an animation at the time of the resolve that creates it, so + // this one starts every animation at the start of the timeline, however + // late the overlay is first drawn. + document.resolve(0.0); + document +} + /// Vello's compute pipelines on the compositor's device, and the pass that /// premultiplies what they paint, compiled once and shared by every overlay. struct Painter { @@ -856,6 +889,8 @@ impl Painter { /// An overlay's document on the overlay thread, and where it paints. struct Document { document: BaseDocument, + /// What it was laid out from, to lay out again on a restart. + html: String, /// Where Vello paints, straight. paint: wgpu::TextureView, /// Where the paint lands premultiplied. diff --git a/rs/moq-scene/src/source.rs b/rs/moq-scene/src/source.rs index df93396a..f848f926 100644 --- a/rs/moq-scene/src/source.rs +++ b/rs/moq-scene/src/source.rs @@ -2648,6 +2648,7 @@ mod tests { html: html.into(), width: 640, height: 360, + restart: true, }; let mut collection = Collection::new(); collection.sources.insert("title".into(), overlay("

Live

")); diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index 188e79c5..23737f92 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -3669,8 +3669,8 @@ struct AddSource { /// A browser source's page size, or an overlay's. width: u32, height: u32, - /// Whether a media file loops, and plays from its start each time it goes - /// on program. + /// Whether a media file loops, and whether it or an overlay starts over + /// each time it goes on program. looping: bool, restart: bool, } @@ -3799,6 +3799,13 @@ impl AddSource { egui::color_picker::color_edit_button_srgba(ui, &mut self.color, egui::color_picker::Alpha::OnlyBlend); ui.end_row(); } + if self.kind == Kind::Overlay { + ui.label("Animation"); + ui.checkbox(&mut self.restart, "Restart on program").on_hover_text( + "Start its animations over each time its scene goes on program, rather than run on, as a clock should", + ); + ui.end_row(); + } if self.kind == Kind::Media { ui.label("Playback"); ui.horizontal(|ui| { @@ -3844,6 +3851,7 @@ impl AddSource { html: value, width: self.width, height: self.height, + restart: self.restart, }, Kind::Replay => Source::Replay, }) From b50a9cf3db096e5a16bd134847b54c039cd0fa25 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 00:35:31 -0800 Subject: [PATCH 3/6] fix(scene): a preview never restarts an overlay the top layer or a leaving scene shows Co-Authored-By: Claude Opus 5.5 --- rs/moq-scene/src/compositor.rs | 50 +++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/rs/moq-scene/src/compositor.rs b/rs/moq-scene/src/compositor.rs index f5174fc7..e915bf64 100644 --- a/rs/moq-scene/src/compositor.rs +++ b/rs/moq-scene/src/compositor.rs @@ -563,7 +563,8 @@ impl Compositor { /// since its timeline started: the program scene, which `program_start` /// went on at, or else the preview scene, at `preview_start`, as their /// keyframes do. Preview and program share one timeline, so a preview - /// never restarts an overlay program shows. + /// never restarts an overlay program shows, through the scene a + /// transition leaves or the top layer included. fn restart(&mut self, collection: &Collection, preview_start: Instant, program_start: Instant) { let restarts = |layer: &Layer| matches!(layer.source, Source::Overlay { restart: true, .. }); if !self @@ -573,12 +574,15 @@ impl Compositor { { return; } - let program = self.drawn(collection, &[self.program_scene.as_deref()]); - let preview = self.drawn(collection, &[self.preview_scene.as_deref()]); + let program = self.drawn(collection, self.program_scene.as_deref().map(Some)); + let leaving = self.switch.as_ref().and_then(|switch| switch.from.as_deref()); + let on_air = self.drawn(collection, [leaving.map(Some), Some(None)].into_iter().flatten()); + let preview = self.drawn(collection, self.preview_scene.as_deref().map(Some)); for (name, layer) in &mut self.layers { - let start = if program.contains(name.as_str()) { + let name = name.as_str(); + let start = if program.contains(name) { program_start - } else if preview.contains(name.as_str()) { + } else if preview.contains(name) && !on_air.contains(name) { preview_start } else { continue; @@ -640,7 +644,7 @@ impl Compositor { fn shown<'a>(&self, collection: &'a Collection) -> HashSet<&'a str> { let leaving = self.switch.as_ref().and_then(|switch| switch.from.as_deref()); let scenes = [self.preview_scene.as_deref(), self.program_scene.as_deref(), leaving]; - let mut shown = self.drawn(collection, &scenes); + let mut shown = self.drawn(collection, scenes.into_iter().flatten().map(Some).chain([None])); // Drawn through its source's filters alone. if let Some((name, _)) = self.stinger().and_then(|name| collection.sources.get_key_value(name)) { shown.insert(name); @@ -651,25 +655,23 @@ impl Compositor { video.into_iter().flat_map(|video| &video.filters), ); } - for canvas in std::iter::once(None).chain(self.extras.keys().map(Some)) { - let top = Place { - canvas: canvas.cloned(), - scene: None, - }; - drawn(collection, &mut shown, &top); - } shown } - /// The sources whose pictures `scenes` draw on any canvas, a `None` scene - /// drawing nothing: each visible item's and each mask's, through clones. - fn drawn<'a>(&self, collection: &'a Collection, scenes: &[Option<&str>]) -> HashSet<&'a str> { + /// The sources whose pictures `scenes` draw on any canvas, `None` being + /// the top layer: each visible item's and each mask's, through clones. + fn drawn<'a, 'b>( + &self, + collection: &'a Collection, + scenes: impl IntoIterator>, + ) -> HashSet<&'a str> { + let scenes: Vec> = scenes.into_iter().map(|scene| scene.map(str::to_string)).collect(); let mut shown = HashSet::new(); for canvas in std::iter::once(None).chain(self.extras.keys().map(Some)) { - for scene in scenes.iter().flatten() { + for scene in &scenes { let place = Place { canvas: canvas.cloned(), - scene: Some(scene.to_string()), + scene: scene.clone(), }; drawn(collection, &mut shown, &place); } @@ -2953,7 +2955,8 @@ mod tests { /// An overlay set to restart starts its CSS timeline over when its scene /// goes into preview while program does not show it, and when it goes on - /// program, but a preview never restarts it under program. One that does + /// program, but a preview never restarts it under program, the top layer + /// included. One that does /// not restart runs on from when it opened. #[tokio::test] #[ignore = "needs a Vulkan device; run by just rs render-sw"] @@ -3022,6 +3025,15 @@ mod tests { let actual = pixel(&picture, 3, 1); assert!(near(actual, quarter), "{output}: {actual:?}"); } + + // Nor does it restart one the top layer shows on program. + studio.collection.top = vec![Item::new("third", fill)]; + studio.compositor.set_preview(Some("red".into())); + studio.compositor.cut(&studio.sources); + studio.render(cut + Duration::from_secs(1)); + studio.compositor.set_preview(Some("blue".into())); + studio.render(cut + Duration::from_millis(1100)); + assert_eq!(pixel(&studio.program(), 3, 1), BLUE, "the top layer's ran on"); }) .await; } From f32fe198999763a45e3b25b37159109b95f93252 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 00:49:06 -0800 Subject: [PATCH 4/6] fix(scene): a preview never restarts an overlay a stinger masks on air The on-air set that guards overlays from preview restarts now comes from the same helper as the paint set, so it includes an active stinger's clip and the sources its filters mask with. Co-Authored-By: Claude Opus 5.5 --- rs/moq-scene/src/compositor.rs | 60 ++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/rs/moq-scene/src/compositor.rs b/rs/moq-scene/src/compositor.rs index e915bf64..5d9e67db 100644 --- a/rs/moq-scene/src/compositor.rs +++ b/rs/moq-scene/src/compositor.rs @@ -564,7 +564,7 @@ impl Compositor { /// went on at, or else the preview scene, at `preview_start`, as their /// keyframes do. Preview and program share one timeline, so a preview /// never restarts an overlay program shows, through the scene a - /// transition leaves or the top layer included. + /// transition leaves, the top layer, or a stinger's masks included. fn restart(&mut self, collection: &Collection, preview_start: Instant, program_start: Instant) { let restarts = |layer: &Layer| matches!(layer.source, Source::Overlay { restart: true, .. }); if !self @@ -575,8 +575,7 @@ impl Compositor { return; } let program = self.drawn(collection, self.program_scene.as_deref().map(Some)); - let leaving = self.switch.as_ref().and_then(|switch| switch.from.as_deref()); - let on_air = self.drawn(collection, [leaving.map(Some), Some(None)].into_iter().flatten()); + let on_air = self.on_air_sources(collection); let preview = self.drawn(collection, self.preview_scene.as_deref().map(Some)); for (name, layer) in &mut self.layers { let name = name.as_str(); @@ -642,9 +641,16 @@ impl Compositor { /// each visible item's, a stinger's clip under way, and each mask's, /// through clones. fn shown<'a>(&self, collection: &'a Collection) -> HashSet<&'a str> { - let leaving = self.switch.as_ref().and_then(|switch| switch.from.as_deref()); - let scenes = [self.preview_scene.as_deref(), self.program_scene.as_deref(), leaving]; - let mut shown = self.drawn(collection, scenes.into_iter().flatten().map(Some).chain([None])); + let mut shown = self.on_air_sources(collection); + shown.extend(self.drawn(collection, self.preview_scene.as_deref().map(Some))); + shown + } + + /// The sources whose pictures program draws on any canvas: each visible + /// item's in the scene on program, the scene a transition leaves, and the + /// top layer, a stinger's clip under way, and each mask's, through clones. + fn on_air_sources<'a>(&self, collection: &'a Collection) -> HashSet<&'a str> { + let mut shown = self.drawn(collection, self.on_air().map(Some).chain([None])); // Drawn through its source's filters alone. if let Some((name, _)) = self.stinger().and_then(|name| collection.sources.get_key_value(name)) { shown.insert(name); @@ -3038,6 +3044,48 @@ mod tests { .await; } + /// A preview never restarts an overlay a stinger under way masks its clip + /// with on program, though no scene on air shows it. + #[tokio::test] + #[ignore = "needs a Vulkan device; run by just rs render-sw"] + async fn a_stinger_mask_runs_on_under_a_preview() { + local(async { + let mut studio = stinging(crate::media::tests::CLIP).await; + let overlay = Source::Overlay { + html: "".into(), + width: 8, + height: 8, + restart: true, + }; + studio.collection.sources.insert("third".into(), overlay); + let fill = Rect::fill(&studio.collection.canvas); + studio.collection.scenes.push(Scene { + items: vec![Item::new("third", fill)], + ..Scene::new("third") + }); + let mask = Filter::Mask { + source: "third".into(), + mode: MaskMode::Alpha, + }; + sting(&mut studio, mask); + let drivers = studio.sources.sync(&studio.collection); + start(&studio.sources, drivers).await; + + let now = Instant::now(); + let transition = studio.collection.transition.clone(); + studio.compositor.transition(transition, &studio.sources, now); + studio.render(now); + let opened = studio.compositor.layers["third"].overlay.as_ref().unwrap().start(); + + studio.compositor.set_preview(Some("third".into())); + studio.render(now + Duration::from_millis(100)); + assert_eq!(studio.compositor.stinger(), Some("sting")); + let overlay = studio.compositor.layers["third"].overlay.as_ref().unwrap(); + assert_eq!(overlay.start(), opened, "the stinger's mask ran on"); + }) + .await; + } + /// An overlay a move glides is painted on the program clock while both /// scenes are on air, and drawn where the move has it. #[tokio::test] From 7fea4783e4122f50b2ecc0ffb4ba409bcc58b37b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 07:53:05 -0800 Subject: [PATCH 5/6] fmt Co-Authored-By: Claude Opus 5.5 --- rs/moq-scene/src/overlay.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rs/moq-scene/src/overlay.rs b/rs/moq-scene/src/overlay.rs index 1c239312..15e32c0f 100644 --- a/rs/moq-scene/src/overlay.rs +++ b/rs/moq-scene/src/overlay.rs @@ -1451,7 +1451,11 @@ mod tests { panic!("two spans"); }; assert!(brand > regular * 1.05, "regular {regular}, brand {brand}"); - assert_eq!(backgrounds(&document), [true, false], "the first background is in place"); + assert_eq!( + backgrounds(&document), + [true, false], + "the first background is in place" + ); // One resolve, as a paint after the animation ends makes, draws // the background it brings in. From ddebcb709b6f6637afc56e08872a53fb3165b22f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 07:53:11 -0800 Subject: [PATCH 6/6] README: a restart reuses an overlay's assets Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 49241f57..7db8827f 100644 --- a/README.md +++ b/README.md @@ -287,9 +287,9 @@ machine's fonts, so it looks the same on every machine. An overlay's own images and fonts, such as a logo or a brand face, come from `data:` URLs or from files in the scene file's directory, named relative to it (`url(brand/logo.png)`), never from the web or anywhere else on disk. The -studio reads them all as the overlay starts, so its first frame has them, and -a file changed on disk changes nothing on air until the overlay starts again, -as when it is edited. Images are PNG or JPEG, at most 4096 pixels a side; +studio reads them all as the overlay opens, so its first frame has them, and +a file changed on disk changes nothing on air until the overlay opens again, +as when it is edited; a restart on program reuses what it read. Images are PNG or JPEG, at most 4096 pixels a side; fonts are TrueType, OpenType, WOFF, or WOFF2, loaded with `@font-face`: ```css