From c1d6a588a45c7c6c764b3c721e033deb389d6cea Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 19:58:16 -0800 Subject: [PATCH 01/12] chore(quest): claim quest/m0/scene-editing.md Quest-Claim: 49e7e38ca46f1223 From 0bdab6da89e977d51f8d391b5cd7ed4863e39ee2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 21:58:03 -0800 Subject: [PATCH 02/12] wip: canvas edits, scene renames, and a rendition per canvas Co-Authored-By: Claude Opus 5.5 --- rs/moq-scene/src/compositor.rs | 111 +++++++- rs/moq-scene/src/edit.rs | 147 ++++++++++- rs/moq-studio/src/engine.rs | 456 ++++++++++++++++++++++++--------- rs/moq-studio/src/ui.rs | 221 ++++++++++++++-- 4 files changed, 773 insertions(+), 162 deletions(-) diff --git a/rs/moq-scene/src/compositor.rs b/rs/moq-scene/src/compositor.rs index a147625..8782d7d 100644 --- a/rs/moq-scene/src/compositor.rs +++ b/rs/moq-scene/src/compositor.rs @@ -145,13 +145,7 @@ impl Compositor { /// /// Fails if the device cannot hold a texture that large. pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, canvas: Canvas) -> Result { - let limit = device.limits().max_texture_dimension_2d; - if canvas.width > limit || canvas.height > limit { - return Err(Error::Invalid(format!( - "canvas {}x{} exceeds this GPU's {limit}px texture limit", - canvas.width, canvas.height - ))); - } + fits(device, canvas)?; let image_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("moq-scene image"), @@ -272,6 +266,24 @@ impl Compositor { self.canvas } + /// Draw `canvas` from the next [`render`](Self::render) on. A new size + /// replaces the [`preview`](Self::preview) and [`program`](Self::program) + /// textures with ones of that size, blank until drawn; a fade under way + /// carries on. + /// + /// Fails, changing nothing, if the device cannot hold a texture that large. + pub fn set_canvas(&mut self, canvas: Canvas) -> Result<(), Error> { + fits(&self.device, canvas)?; + if canvas.size() != self.canvas.size() { + let target = |label| Target::new(&self.device, &self.image_layout, &self.sampler, canvas, label); + self.preview = target("moq-scene preview"); + self.program = target("moq-scene program"); + self.incoming = target("moq-scene incoming"); + } + self.canvas = canvas; + Ok(()) + } + /// The scene in preview, as of the last [`render`](Self::render). pub fn preview(&self) -> &wgpu::Texture { &self.preview.texture @@ -309,6 +321,17 @@ impl Compositor { self.preview_scene = scene; } + /// Follow the scene called `from` to its new name `to`, wherever it is: in + /// preview, on program, or fading out. See [`Edit::renames`](crate::Edit::renames). + pub fn rename(&mut self, from: &str, to: &str) { + let fading = self.fade.as_mut().map(|fade| &mut fade.from); + for scene in [&mut self.preview_scene, &mut self.program_scene].into_iter().chain(fading) { + if scene.as_deref() == Some(from) { + *scene = Some(to.to_string()); + } + } + } + /// Put the preview on program at once, ending any fade. pub fn cut(&mut self) { self.fade = None; @@ -500,6 +523,18 @@ impl Compositor { } } +/// Refuse a canvas larger than `device` can hold as a texture. +fn fits(device: &wgpu::Device, canvas: Canvas) -> Result<(), Error> { + let limit = device.limits().max_texture_dimension_2d; + if canvas.width > limit || canvas.height > limit { + return Err(Error::Invalid(format!( + "canvas {}x{} exceeds this GPU's {limit}px texture limit", + canvas.width, canvas.height + ))); + } + Ok(()) +} + /// Place `rect` (canvas pixels) in clip space. fn uniform(rect: Rect, canvas: Canvas, opacity: f32) -> Uniform { let (width, height) = (canvas.width as f32, canvas.height as f32); @@ -1003,6 +1038,68 @@ mod tests { .await; } + /// A resize draws the next frame on textures of the new size, with items + /// where their pixels say, and a canvas the GPU cannot hold changes nothing. + #[tokio::test] + #[ignore = "needs a Vulkan device; run by just rs render-sw"] + async fn a_resize_draws_the_new_canvas() { + local(async { + let mut studio = Studio::new(collection()).await; + studio.compositor.set_preview(Some("blue".into())); + studio.compositor.cut(); + studio.render(Instant::now()); + let old = studio.compositor.program().clone(); + + let wide = Canvas { + width: 16, + ..studio.collection.canvas + }; + studio.collection.canvas = wide; + studio.compositor.set_canvas(wide).unwrap(); + assert_ne!(studio.compositor.program(), &old, "a new texture"); + studio.render(Instant::now()); + let program = studio.program(); + assert_eq!(program.len(), 16 * 8 * 4); + let pixel = |x: usize| -> [u8; 4] { program[x * 4..x * 4 + 4].try_into().unwrap() }; + assert_eq!(pixel(1), BLUE); + assert_eq!(pixel(12), BLACK, "items keep their pixels"); + + let limit = studio.compositor.device.limits().max_texture_dimension_2d; + let huge = Canvas { + width: limit + 2, + ..wide + }; + assert!(matches!(studio.compositor.set_canvas(huge), Err(Error::Invalid(_)))); + assert_eq!(studio.compositor.canvas(), wide); + }) + .await; + } + + /// A renamed scene stays in preview, on program, and in the fade under way. + #[tokio::test] + #[ignore = "needs a Vulkan device; run by just rs render-sw"] + async fn a_renamed_scene_stays_where_it_is() { + local(async { + let mut studio = Studio::new(collection()).await; + let start = Instant::now(); + studio.compositor.set_preview(Some("red".into())); + studio.compositor.cut(); + studio.compositor.set_preview(Some("blue".into())); + studio.compositor.fade(Duration::from_secs(1), start); + + for (from, to) in [("red", "crimson"), ("blue", "navy")] { + studio.collection.scenes.iter_mut().find(|scene| scene.name == from).unwrap().name = to.into(); + studio.compositor.rename(from, to); + } + assert_eq!(studio.compositor.preview_scene(), Some("navy")); + assert_eq!(studio.compositor.program_scene(), Some("navy")); + studio.render(start + Duration::from_millis(500)); + let actual = pixel(&studio.program(), 1, 1); + assert!(near(actual, [128, 0, 127, 255]), "still fading from red: {actual:?}"); + }) + .await; + } + /// A picture the GPU cannot hold says why, rather than leaving its source /// live and missing from the canvas. #[tokio::test] diff --git a/rs/moq-scene/src/edit.rs b/rs/moq-scene/src/edit.rs index 8674538..d338c43 100644 --- a/rs/moq-scene/src/edit.rs +++ b/rs/moq-scene/src/edit.rs @@ -14,7 +14,7 @@ use std::collections::VecDeque; use serde::{Deserialize, Serialize}; use crate::Error; -use crate::collection::{Audio, Collection, Item, Layer, Scene, Source, Transition, Video}; +use crate::collection::{Audio, Canvas, Collection, Item, Layer, Scene, Source, Transition, Video}; /// One part of a collection, before and after an edit. /// @@ -47,7 +47,8 @@ pub enum Change { after: Option