diff --git a/README.md b/README.md index 59368c9..18ab281 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 @@ -318,8 +318,15 @@ element, attribute, rule, property, or asset 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. @@ -512,6 +519,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:?}"); + } + + // 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; + } + + /// 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] @@ -3162,6 +3360,7 @@ mod tests { html: "".into(), width: 4, height: 4, + restart: true, }, ); let tile = |x, y| Rect { @@ -3205,6 +3404,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 782427d..15e32c0 100644 --- a/rs/moq-scene/src/overlay.rs +++ b/rs/moq-scene/src/overlay.rs @@ -924,6 +924,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. @@ -988,6 +990,7 @@ impl Overlays { Overlay { id, texture, + start, requests: self.requests.clone(), } } @@ -1017,6 +1020,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, } @@ -1030,6 +1035,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 { @@ -1073,6 +1091,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 = open(&document.assets, 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) => { @@ -1212,7 +1238,7 @@ impl Painter { /// An overlay's document on the overlay thread, and where it paints. struct Document { document: BaseDocument, - /// What it fetches from. + /// What it fetches from, and lays out again from on a restart. assets: Arc, /// Where Vello paints, straight. paint: wgpu::TextureView, @@ -1387,8 +1413,8 @@ mod tests { } /// Images and fonts load from `data:` URLs and files beside the scene - /// file, and are in place once the overlay opens, a background image an - /// animation brings in later included. + /// file, and are in place once the overlay opens or restarts, a background + /// image an animation brings in later included. #[test] fn assets_load_before_the_first_paint() { let dir = tempfile::tempdir().unwrap(); @@ -1415,23 +1441,28 @@ mod tests { assert_eq!(content.assets.len(), 4); let assets = Assets::new(content); - let document = open(&assets, 400, 100); - assert_eq!(*assets.missing.lock().unwrap(), None); + // A restart lays the overlay out again from the same assets. + for _ in ["open", "restart"] { + let mut document = open(&assets, 400, 100); + assert_eq!(*assets.missing.lock().unwrap(), None); + + assert_eq!(widths(&document, "img"), [20.0]); + let [regular, brand] = widths(&document, "span")[..] else { + 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!(widths(&document, "img"), [20.0]); - let [regular, brand] = widths(&document, "span")[..] else { - panic!("two spans"); - }; - assert!(brand > regular * 1.05, "regular {regular}, brand {brand}"); - assert!(backgrounds(&document)[0], "the background is in place"); - - // One resolve, as a paint after the animation ends makes, draws the - // background it brings in. - let mut document = document; - assert!(!backgrounds(&document)[1]); - document.resolve(2.0); - assert_eq!(backgrounds(&document), [true, true]); - assert_eq!(*assets.missing.lock().unwrap(), None); + // One resolve, as a paint after the animation ends makes, draws + // the background it brings in. + document.resolve(2.0); + assert_eq!(backgrounds(&document), [true, true]); + assert_eq!(*assets.missing.lock().unwrap(), None); + } } /// Whether each `
` in `document` has its background image in place. diff --git a/rs/moq-scene/src/source.rs b/rs/moq-scene/src/source.rs index eae2d79..fa20256 100644 --- a/rs/moq-scene/src/source.rs +++ b/rs/moq-scene/src/source.rs @@ -2713,6 +2713,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 d808f9f..64e88af 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -3707,8 +3707,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, } @@ -3837,6 +3837,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| { @@ -3882,6 +3889,7 @@ impl AddSource { html: value, width: self.width, height: self.height, + restart: self.restart, }, Kind::Replay => Source::Replay, })