From baf4716246077d8f78303657d011d5fdfdd9ac18 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 05:14:12 -0400 Subject: [PATCH 1/4] M13.5: extended viewer (custom 3D views / Draft 3D render the pasteboard around the comp frame; view.extendedViewer) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/view.rs | 13 ++++++ crates/engine/src/menus.rs | 1 + crates/render/src/lib.rs | 57 ++++++++++++++++++++--- crates/render/src/tests_roi.rs | 30 ++++++++++++ crates/render/src/three_d/compose.rs | 10 ++-- crates/ui-egui/src/icons.rs | 8 ++++ crates/ui-egui/src/lib.rs | 14 +++++- crates/ui-egui/src/panels/viewer.rs | 41 +++++++++++++++- crates/ui-egui/src/panels/viewer_tools.rs | 8 +++- crates/ui-egui/src/state.rs | 5 ++ crates/ui-egui/tests/ui_3d.rs | 48 +++++++++++++++++++ 11 files changed, 219 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/commands/view.rs b/crates/engine/src/commands/view.rs index b3d86565..4cb001f6 100644 --- a/crates/engine/src/commands/view.rs +++ b/crates/engine/src/commands/view.rs @@ -89,6 +89,18 @@ fn share_view_options(s: &mut Session, p: &Value) -> Result { Ok(json!(s.state.share_view_options)) } +/// Settings ▸ 3D ▸ Extended Viewer: 3D views (custom views, or Draft 3D) show the pasteboard +/// around the comp frame with the layers that reach onto it. +fn extended_viewer(s: &mut Session, p: &Value) -> Result { + let v = super::b_p(p, "value").unwrap_or(!s.prefs.three_d.extended_viewer); + if v != s.prefs.three_d.extended_viewer { + s.prefs.three_d.extended_viewer = v; + s.prefs_changed(); + s.save_prefs(); + } + Ok(json!(v)) +} + pub fn specs() -> Vec { vec![ cmd!("view.addGuide", "Add Guide...", ["View"], None, "{orientation?: vertical|horizontal, position? (comp px)}", has_comp, add_guide), @@ -97,6 +109,7 @@ pub fn specs() -> Vec { cmd!("view.exportGuides", "Export Guides...", ["View"], None, "{path}", has_guides, export_guides), cmd!("view.layout", "Switch View Layout", [], None, "{views: 1|2|4}", has_comp, view_layout), cmd!("view.shareViewOptions", "Share View Options", ["View", "Switch View Layout"], None, "{value?}", has_comp, share_view_options), + cmd!("view.extendedViewer", "Extended Viewer", [], None, "{value?}", always, extended_viewer), cmd!("view.setRegionOfInterest", "Region of Interest", [], None, "{rect?: [x, y, w, h] | null}", always, region_of_interest), ] } diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 9ab366db..fce44a90 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -301,6 +301,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option { "layer.mask.hideLocked" => Some(s.state.hide_locked_masks), "view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)), "view.shareViewOptions" => Some(s.state.share_view_options), + "view.extendedViewer" => Some(s.prefs.three_d.extended_viewer), "view.snapping" => Some(s.state.snapping), "view.displayColorManagement" => Some(s.state.viewer.display_color_management), "view.simulateOutput" => { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6ede490b..2f468782 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -363,6 +363,9 @@ pub struct Renderer<'a> { /// Switches inherited from the precomp layers this comp is rendered through (see /// [`RenderOpts::nested_switches`]): the worst quality and whether motion blur is allowed. pub(crate) inherited: Option<(Quality, bool)>, + /// This renderer draws into the top-level frame's canvas (the top comp, or a collapsed + /// precomp drawn straight into it): the region of interest's offset applies. + top: bool, } /// How a collapsed precomp's 3D layers are placed in the parent (see `Renderer::collapse_into`). @@ -407,6 +410,7 @@ impl<'a> Renderer<'a> { opacity_mul: 1.0, collapse3d: None, inherited: None, + top: true, } } @@ -441,7 +445,7 @@ impl<'a> Renderer<'a> { /// A renderer for nested work (precomps, layers read by effects): one level deeper. fn nested(&self) -> Renderer<'a> { - Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, ..*self } + Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, top: false, ..*self } } /// The nested comp of a precomp layer whose transformations collapse into this comp: the @@ -604,18 +608,28 @@ impl<'a> Renderer<'a> { let s = self.opts.scale; let w = ((comp.width as f64 * s).round() as u32).max(1); let h = ((comp.height as f64 * s).round() as u32).max(1); - // Region of interest on a comp with 3D layers: render the frame and crop it. + // Region of interest on a comp with 3D layers: render the frame and crop it. A region + // reaching past the comp frame (the Extended Viewer) renders Classic 3D natively — the + // planes project through the offset camera — and Advanced 3D as the frame, padded. if self.depth == 0 && let Some(r) = self.opts.roi && comp.has_3d() + && (roi_inside(r, comp.width, comp.height) || comp.renderer == effectcraft_project::Renderer::Advanced3D) { let full = Renderer { opts: RenderOpts { roi: None, ..self.opts }, ..*self }.comp_frame(comp_id, t); - let (x0, y0) = ((r[0] * s).round().max(0.0) as u32, (r[1] * s).round().max(0.0) as u32); + let (x0, y0) = ((r[0] * s).round() as i64, (r[1] * s).round() as i64); let (rw, rh) = (((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1)); let mut out = Image::new(rw, rh); - for y in 0..rh.min(full.height.saturating_sub(y0)) { - for x in 0..rw.min(full.width.saturating_sub(x0)) { - out.data[(y * rw + x) as usize] = full.data[((y + y0) * full.width + x + x0) as usize]; + for y in 0..rh as i64 { + let sy = y + y0; + if sy < 0 || sy >= full.height as i64 { + continue; + } + for x in 0..rw as i64 { + let sx = x + x0; + if sx >= 0 && sx < full.width as i64 { + out.data[(y * rw as i64 + x) as usize] = full.data[(sy * full.width as i64 + sx) as usize]; + } } } return out; @@ -1124,7 +1138,7 @@ impl<'a> Renderer<'a> { /// The region of interest of a top-level 2D frame: (x, y) offset of the output in output /// pixels and its size. `None` renders the whole comp (no ROI, or a nested comp). pub(crate) fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> { - let r = self.opts.roi.filter(|_| self.depth == 0)?; + let r = self.opts.roi.filter(|_| self.top)?; let s = self.opts.scale; if r[2] <= 0.0 || r[3] <= 0.0 { return None; @@ -1132,6 +1146,11 @@ impl<'a> Renderer<'a> { Some(((r[0] * s).round(), (r[1] * s).round(), ((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1))) } + /// The region of interest's offset in output pixels for 3D projection (0 without one). + pub(crate) fn out_offset(&self) -> (f64, f64) { + self.roi_offset().map_or((0.0, 0.0), |(x, y, _, _)| (x, y)) + } + /// Scaled comp pixel → output pixel: the region of interest's offset. fn out_matrix(&self) -> Mat3 { match self.roi_offset() { @@ -1645,6 +1664,30 @@ pub fn adaptive_samples(travel: f64, per_frame: u32, limit: u32) -> usize { travel.ceil().clamp(lo, hi) as usize } +/// A region of interest `[x, y, w, h]` lies within a `w`×`h` comp frame. +pub fn roi_inside(r: [f64; 4], w: u32, h: u32) -> bool { + r[0] >= 0.0 && r[1] >= 0.0 && r[0] + r[2] <= w as f64 + 1e-6 && r[1] + r[3] <= h as f64 + 1e-6 +} + +/// The Extended Viewer's region of interest: the comp frame grown by `margin` × its size on every +/// side (`margin` = 1 shows a pasteboard one comp wide around the frame), intersected with the +/// `visible` comp-space rectangle `[x0, y0, x1, y1]` and snapped outward to whole 16 px steps from the frame edges +/// so panning re-renders rarely. `None` when the visible area lies inside the frame. +pub fn extended_region(w: u32, h: u32, visible: [f64; 4], margin: f64) -> Option<[f64; 4]> { + let (w, h) = (w as f64, h as f64); + let (mx, my) = (w * margin, h * margin); + let snap_lo = |v: f64| (v / 16.0).floor() * 16.0; + let snap_hi = |v: f64| (v / 16.0).ceil() * 16.0; + let x0 = snap_lo(visible[0].max(-mx)).min(0.0); + let y0 = snap_lo(visible[1].max(-my)).min(0.0); + let x1 = w + snap_hi(visible[2].min(w + mx) - w).max(0.0); + let y1 = h + snap_hi(visible[3].min(h + my) - h).max(0.0); + if x0 >= 0.0 && y0 >= 0.0 && x1 <= w && y1 <= h { + return None; + } + Some([x0, y0, x1 - x0, y1 - y0]) +} + pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image { Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t) } diff --git a/crates/render/src/tests_roi.rs b/crates/render/src/tests_roi.rs index 9adc8a8e..9182817c 100644 --- a/crates/render/src/tests_roi.rs +++ b/crates/render/src/tests_roi.rs @@ -71,3 +71,33 @@ fn roi_at_half_resolution() { assert_eq!((roi.width, roi.height), (30, 20)); assert_same(&roi, &crop(&full, 20, 10, 30, 20)); } + +/// Extended Viewer: a region reaching past the comp frame shows the 3D layer's pixels on the +/// pasteboard, and its inner part equals the normal frame. +#[test] +fn extended_region_renders_3d_layers_beyond_the_frame() { + let (mut p, cid) = project(true); + // Push the 3D (blue) layer half out of the frame on the right. + let comp = p.comp_mut(cid).unwrap(); + comp.layers[1].props.prop_mut("transform/position").unwrap().value = Value::Vec3([150.0, 60.0, 0.0]); + comp.layers[1].props.prop_mut("transform/rotation").unwrap().value = Value::Scalar(0.0); + let full = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); + let region = crate::extended_region(160, 120, [-100.0, -100.0, 260.0, 220.0], 0.5).unwrap(); + assert_eq!(region, [-80.0, -64.0, 320.0, 248.0]); + let ext = Renderer::new(&p, &NoFootage, RenderOpts { roi: Some(region), ..Default::default() }).comp_frame(cid, Tick::ZERO); + assert_eq!((ext.width, ext.height), (320, 248)); + // Comp x 175 (past the 160 px frame) at y 60: the layer covers x 115..185. + let out = ext.get(175 + 80, 60 + 64); + assert!(out[3] > 0.5 && out[2] > 0.5, "pasteboard pixel {out:?}"); + // Nothing is drawn far outside every layer. + assert_eq!(ext.get(5, 5)[3], 0.0); + // The frame part is the normal frame. + assert_same(&crop(&ext, 80, 64, 160, 120), &full); +} + +#[test] +fn extended_region_is_none_inside_the_frame() { + assert_eq!(crate::extended_region(160, 120, [10.0, 10.0, 150.0, 110.0], 1.0), None); + // Clamped to the margin. + assert_eq!(crate::extended_region(100, 100, [-1000.0, 0.0, 50.0, 100.0], 1.0), Some([-112.0, 0.0, 212.0, 100.0])); +} diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 25755a47..1f5470b3 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -74,8 +74,10 @@ impl Item<'_> { } /// Build the plane geometry of a layer for a camera. -fn geo(cam: &CameraState, world: Mat4, b: [f64; 4], comp: (f64, f64), scale: f64, out: (u32, u32)) -> Option { - let proj = Mat4::scale(vec3(scale, scale, 1.0)) * cam.projection(comp.0, comp.1); +fn geo(cam: &CameraState, world: Mat4, b: [f64; 4], comp: (f64, f64), scale: f64, out: (u32, u32), off: (f64, f64)) -> Option { + // The output canvas may start at `off` (scaled comp pixels): region of interest / Extended + // Viewer frames. + let proj = Mat4::translate(vec3(-off.0, -off.1, 0.0)) * Mat4::scale(vec3(scale, scale, 1.0)) * cam.projection(comp.0, comp.1); let full = proj * world; let h = full.plane_to_mat3(); // Edge-on planes project to a line (singular homography): nothing to draw. @@ -222,7 +224,7 @@ fn prepare_with<'a>( let bounds = buf_bounds(&buf); let n = if in_run { mb_samples(r, ctx, layer) } else { 1 }; let geos: Vec = if n <= 1 { - geo(&cam, world, bounds, comp, s, out).into_iter().collect() + geo(&cam, world, bounds, comp, s, out, r.out_offset()).into_iter().collect() } else { let fd = ctx.comp.frame_duration().seconds(); let angle = ctx.comp.shutter_angle / 360.0; @@ -232,7 +234,7 @@ fn prepare_with<'a>( let f = phase + angle * i as f64 / (n - 1) as f64; let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)); let c = camera_for(r, &sub); - geo(&c, outer * sub.world_matrix(layer) * local, bounds, comp, s, out) + geo(&c, outer * sub.world_matrix(layer) * local, bounds, comp, s, out, r.out_offset()) }) .collect() }; diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index d2de8fc3..ac9a028e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -89,6 +89,8 @@ pub enum Icon { MaskVis, Checker, Region, + /// Extended Viewer: a small frame with corner ticks reaching outward. + ExtendedViewer, Plus, Minus, Close, @@ -513,6 +515,12 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.rect(2.5, 2.5, 13.5, 13.5); pen.rect(5.5, 5.5, 10.5, 10.5); } + ExtendedViewer => { + pen.rect(5.0, 5.0, 11.0, 11.0); + for (x, y, dx, dy) in [(2.0, 2.0, 1.0, 1.0), (14.0, 2.0, -1.0, 1.0), (2.0, 14.0, 1.0, -1.0), (14.0, 14.0, -1.0, -1.0)] { + pen.line(&[(x + dx * 3.0, y), (x, y), (x, y + dy * 3.0)]); + } + } Plus => { pen.line(&[(8.0, 3.0), (8.0, 13.0)]); pen.line(&[(3.0, 8.0), (13.0, 8.0)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c56100af..e345e9c3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -344,6 +344,7 @@ impl EffectcraftApp { && v.channel == effectcraft_engine::viewer::Channel::Rgb && v.exposure == 0.0 && self.session.state.region_of_interest.is_none() + && self.ui.viewer.extended.is_none() } /// Set up the GPU compositor on egui-wgpu's device (once). Without an adapter that runs @@ -439,7 +440,7 @@ impl EffectcraftApp { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); // The region of interest changes what is rendered too. - let roi = self.session.state.region_of_interest.filter(|_| self.session.active_comp_id() == Some(comp)); + let roi = self.viewer_region(comp); if let Some(r) = roi { r.map(f64::to_bits).hash(&mut h); } @@ -454,6 +455,15 @@ impl EffectcraftApp { h.finish() | 1 } + /// The comp-space region viewer frames of `comp` cover: the region of interest, else the + /// Extended Viewer's area (frame plus pasteboard), else `None` (the whole frame). + pub fn viewer_region(&self, comp: ItemId) -> Option<[f64; 4]> { + if self.session.active_comp_id() != Some(comp) { + return None; + } + self.session.state.region_of_interest.or(self.ui.viewer.extended) + } + /// Request a prefetch frame render (no-op if cached/in flight). pub fn request_frame(&self, comp: ItemId, frame: i64, scale: f64) { self.request_frame_with(comp, frame, scale, false); @@ -467,7 +477,7 @@ impl EffectcraftApp { /// Render options of viewer frames of `comp` at `scale`. pub fn frame_opts(&self, comp: ItemId, scale: f64) -> RenderOpts { let (draft, _) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting); - let roi = self.session.state.region_of_interest.filter(|_| self.session.active_comp_id() == Some(comp)); + let roi = self.viewer_region(comp); RenderOpts { scale, motion_blur: true, diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index ccb963cf..3d1f8de4 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -461,6 +461,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ctx.data_mut(|d| d.insert_temp(map_id(), map)); let comp_rect = Rect::from_min_size(origin, vec2(cw * zoom, ch * zoom)); let painter = p.with_clip_rect(area); + // Settings ▸ 3D ▸ Extended Viewer: custom 3D views (and Draft 3D) render the visible + // pasteboard too, so 3D layers reaching past the comp frame stay visible there. + app.ui.viewer.extended = extended_region(app, &comp, cid, map); // Frame. let ppp = ctx.pixels_per_point(); @@ -491,7 +494,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None }; // The frame (or snapshot) through Show Channel and exposure; ROI frames cover the region. vt::draw_frame(app, &ctx, &painter, comp_rect, cid, &ectx); - painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); + if app.ui.viewer.extended.is_some() { + // The comp frame outlined over the extended render. + painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside); + app.auto.add("viewer.extendedArea", rect_of(&map, app.ui.viewer.extended), "Extended Viewer area"); + } else { + painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); + } app.auto.add("viewer.comp", comp_rect, &comp_name); app.auto.add("viewer.area", area, "Composition viewer"); @@ -1654,6 +1663,34 @@ pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> { Some([p(0)?, p(2)?, p(4)?]) } +/// The Extended Viewer's render region for this frame (see [`ViewerState::extended`]): +/// Settings ▸ 3D ▸ Extended Viewer on, the comp has 3D layers and the view is a custom 3D view +/// (or Draft 3D is on), and part of the visible viewer lies outside the comp frame. +/// +/// [`ViewerState::extended`]: crate::state::ViewerState::extended +pub(crate) fn extended_region( + app: &EffectcraftApp, + comp: &effectcraft_engine::project::Comp, + cid: effectcraft_engine::project::ItemId, + map: ViewerMap, +) -> Option<[f64; 4]> { + if !app.session.prefs.three_d.extended_viewer || !comp.has_3d() || app.session.state.region_of_interest.is_some() { + return None; + } + let view = app.session.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(); + if view == View3D::ActiveCamera && !comp.draft_3d { + return None; + } + let a = map.to_comp(map.area.min); + let b = map.to_comp(map.area.max); + effectcraft_engine::render::extended_region(comp.width, comp.height, [a[0], a[1], b[0], b[1]], 1.0) +} + +fn rect_of(map: &ViewerMap, r: Option<[f64; 4]>) -> Rect { + let [x, y, w, h] = r.unwrap_or_default(); + Rect::from_min_max(map.to_screen([x, y]), map.to_screen([x + w, y + h])) +} + /// Put a rendered frame on screen: CPU pixels go into an egui texture; GPU frames are drawn /// straight from their wgpu texture (registered with egui-wgpu, no readback). fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames::FrameKey, img: crate::frames::FrameImage) { @@ -1672,7 +1709,7 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames: } app.viewer_shown = app.viewer_tex.as_ref().map(|(t, k)| (t.id(), *k)); app.viewer_image = Some(img); - vt::set_texture_roi(ctx, app.session.state.region_of_interest); + vt::set_texture_roi(ctx, app.session.state.region_of_interest.or(app.ui.viewer.extended)); } FrameImage::Gpu(f) => { let Some(rs) = &app.wgpu else { return }; diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 0ce1b29c..18f67973 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -694,7 +694,13 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, if let Some(i) = popup(app, ui, "vw-cam-pop", r, &items, "view3dItem") { let _ = app.session.execute("view.set3DView", json!({"view": View3D::ALL[i].id()})); } - x = r.max.x + 6.0; + x = r.max.x + 4.0; + // Extended Viewer (Settings ▸ 3D): custom views and Draft 3D show past the comp frame. + let on = app.session.prefs.three_d.extended_viewer; + if tog(ui, &mut app.auto, &mut x, Icon::ExtendedViewer, on, "extendedViewer", "Extended Viewer").0 { + let _ = app.session.execute("view.extendedViewer", json!({})); + } + x += 2.0; } // Current time. let tc = crate::panels::timecode(&app.session, comp, time); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 07715d16..584cdd95 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -299,6 +299,10 @@ pub struct ViewerState { /// The pointer is dragging in the viewer this frame (Adaptive Resolution). #[serde(skip)] pub interacting: bool, + /// Extended Viewer: the comp-space region the viewer renders this frame (comp frame plus + /// the visible pasteboard), set while a 3D view shows past the frame. + #[serde(skip)] + pub extended: Option<[f64; 4]>, /// View ▸ Panel Background Color (`None` = theme default). pub pasteboard: Option<[u8; 3]>, pub custom_pasteboard: [u8; 3], @@ -326,6 +330,7 @@ impl Default for ViewerState { ruler_origin: [0.0, 0.0], roi_draw: false, interacting: false, + extended: None, pasteboard: None, custom_pasteboard: [0x80, 0x80, 0x80], } diff --git a/crates/ui-egui/tests/ui_3d.rs b/crates/ui-egui/tests/ui_3d.rs index 9cd7e04f..385bf7ac 100644 --- a/crates/ui-egui/tests/ui_3d.rs +++ b/crates/ui-egui/tests/ui_3d.rs @@ -116,3 +116,51 @@ fn snapshot() { img.save(format!("{d}/ui3d_reveal_rotation.png")).unwrap(); } } + +/// Settings ▸ 3D ▸ Extended Viewer: a custom 3D view renders the pasteboard around the comp +/// frame, and 3D layers reaching past the frame draw pixels there. +#[test] +fn extended_viewer_renders_past_the_comp_frame() { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); + h.state_mut().session.execute("view.3d.custom1", json!({})).unwrap(); + // Zoom out so the viewer shows plenty of pasteboard. + h.state_mut().ui.viewer.zoom = Some(0.15); + settle(&mut h); + assert!(h.state().ui.viewer.extended.is_none(), "off by default"); + let ids: Vec = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).map(|e| e.id.clone()).collect(); + assert!(ids.iter().any(|i| i == "viewer.extendedViewer"), "toggle registered"); + assert_eq!(h.state_mut().session.execute("view.extendedViewer", json!({"value": true})).unwrap(), json!(true)); + h.step(); + settle(&mut h); + h.step(); + settle(&mut h); + let region = h.state().ui.viewer.extended.expect("extended region in a custom view"); + let (cw, ch) = { + let c = h.state().session.active_comp().unwrap(); + (c.width as f64, c.height as f64) + }; + assert!(region[0] < 0.0 && region[1] < 0.0 && region[0] + region[2] > cw && region[1] + region[3] > ch, "{region:?}"); + let ids: Vec = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).map(|e| e.id.clone()).collect(); + assert!(ids.iter().any(|i| i == "viewer.extendedArea")); + let img = h.state_mut().viewer_pixels().expect("frame"); + let s = img.size[0] as f64 / region[2]; + assert!((img.size[1] as f64 / region[3] - s).abs() < 0.05, "frame covers the region"); + // Opaque pixels outside the comp rectangle. + let (fx0, fy0) = (-region[0] * s, -region[1] * s); + let (fx1, fy1) = (fx0 + cw * s, fy0 + ch * s); + let mut outside = 0; + for y in 0..img.size[1] { + for x in 0..img.size[0] { + let (xf, yf) = (x as f64 + 0.5, y as f64 + 0.5); + if (xf < fx0 - 1.0 || xf > fx1 + 1.0 || yf < fy0 - 1.0 || yf > fy1 + 1.0) && img.pixels[y * img.size[0] + x].a() > 0 { + outside += 1; + } + } + } + assert!(outside > 50, "pixels drawn on the pasteboard: {outside}"); + // The Active Camera view (Draft 3D off) shows just the frame. + h.state_mut().session.execute("view.3d.activeCamera", json!({})).unwrap(); + h.step(); + h.step(); + assert!(h.state().ui.viewer.extended.is_none()); +} From 1b823eb852b8d7ab00c41bfc6c795ede4dff1879 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 05:25:45 -0400 Subject: [PATCH 2/4] =?UTF-8?q?M13.5:=20Home=20=E2=96=B8=20Learn=20tab=20w?= =?UTF-8?q?ith=20interactive=20tutorials=20(learn.list/start/step/stop/sta?= =?UTF-8?q?te,=20coach=20card,=20target=20highlight)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/mod.rs | 1 + crates/engine/src/learn.rs | 525 +++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 5 + crates/ui-egui/src/control.rs | 4 + crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/panels/home.rs | 71 +++- crates/ui-egui/src/panels/learn.rs | 199 +++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/state.rs | 4 + 9 files changed, 810 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/learn.rs create mode 100644 crates/ui-egui/src/panels/learn.rs diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index d9e4feec..d9ef2816 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -182,6 +182,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(align_data::specs()); v.extend(content_fill::specs()); v.extend(stubs::specs()); + v.extend(crate::learn::specs()); v }) } diff --git a/crates/engine/src/learn.rs b/crates/engine/src/learn.rs new file mode 100644 index 00000000..cea72936 --- /dev/null +++ b/crates/engine/src/learn.rs @@ -0,0 +1,525 @@ +//! Learn: interactive tutorials on the Home screen (our own content, licensed with the repo). +//! +//! A tutorial is a list of steps. Each step names the UI element to highlight (an automation id, +//! see `docs/control-protocol.md`), the commands that complete it when the user performs them, +//! and the commands "Show me" runs to perform it for the user. Progress lives on the +//! [`Session`] (not the project), so opening the demo project in a step keeps the tutorial going. +//! +//! Commands: `learn.list`, `learn.start {id}`, `learn.step {action: next|back|showMe|goto, +//! index?}`, `learn.stop`, `learn.state`. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, always, bad, str_p}; +use crate::{Result, Session, cmd, query}; + +/// One step of a tutorial. +#[derive(Clone, Debug, Serialize)] +pub struct Step { + pub title: String, + pub text: String, + /// Automation id of the element to highlight. `*` matches any run of characters and + /// `{layer}` stands for the first selected layer's id (see [`resolve_target`]). + pub target: Option, + /// Commands that complete the step when they run (empty: a reading step, advanced with Next). + pub accepts: Vec, + /// Parameters the completing command must carry (a subset match; `null` = any). + pub when: Value, + /// What "Show me" runs: (command, params), in order. + pub show: Vec<(String, Value)>, +} + +/// An interactive tutorial. +#[derive(Clone, Debug, Serialize)] +pub struct Tutorial { + pub id: String, + pub title: String, + pub summary: String, + /// Rough length in minutes. + pub minutes: u32, + pub steps: Vec, +} + +/// Where the user is in a tutorial. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Progress { + pub tutorial: String, + /// Index of the current step (`== steps.len()` once finished). + pub step: usize, + pub done: bool, +} + +struct S<'a> { + title: &'a str, + text: &'a str, + target: Option<&'a str>, + accepts: &'a [&'a str], + when: Value, + show: Vec<(&'a str, Value)>, +} + +fn step(s: S) -> Step { + Step { + title: s.title.into(), + text: s.text.into(), + target: s.target.map(str::to_string), + accepts: s.accepts.iter().map(|a| a.to_string()).collect(), + when: s.when, + show: s.show.into_iter().map(|(c, p)| (c.to_string(), p)).collect(), + } +} + +/// The built-in tutorials. +pub fn tutorials() -> Vec { + vec![ + Tutorial { + id: "animate-title".into(), + title: "Animate a title".into(), + summary: "Make a composition, type a title and fly it in with two position keyframes and an easy ease.".into(), + minutes: 4, + steps: vec![ + step(S { + title: "Make a composition", + text: "Everything you animate lives in a composition. Choose Composition ▸ New Composition (Cmd+N) and keep the HD 1920×1080 settings.", + target: Some("menu.Composition"), + accepts: &["comp.new"], + when: Value::Null, + show: vec![("comp.new", json!({"name": "Title Animation", "width": 1920, "height": 1080, "duration": 5}))], + }), + step(S { + title: "Type a title", + text: "Choose Layer ▸ New ▸ Text (or the Type tool) and type a short title. A new text layer appears at the top of the Timeline.", + target: Some("menu.Layer"), + accepts: &["layer.newText"], + when: Value::Null, + show: vec![("layer.newText", json!({"text": "Hello, motion", "size": 120}))], + }), + step(S { + title: "Start the animation", + text: "Twirl the text layer open, then Transform. Click the stopwatch beside Position: it turns blue and records a keyframe at the current time.", + target: Some("timeline.layer.{layer}.twirl"), + accepts: &["prop.toggleAnimation", "prop.addKey", "prop.toggleKey", "anim.addKeyframe"], + when: Value::Null, + show: vec![ + ("prop.set", json!({"path": "transform/position", "value": [-400.0, 540.0, 0.0]})), + ("prop.toggleAnimation", json!({"path": "transform/position", "value": true})), + ], + }), + step(S { + title: "Move in time", + text: "Drag the blue current-time indicator to about 0:00:01:00, or click in the time ruler above the layer bars.", + target: Some("timeline.ruler"), + accepts: &["time.set", "time.step", "time.go"], + when: Value::Null, + show: vec![("time.set", json!({"time": 1.0}))], + }), + step(S { + title: "Set the end position", + text: "Drag the title to the middle of the frame in the Composition viewer, or scrub the Position value. Because the stopwatch is on, a second keyframe appears by itself.", + target: Some("viewer.comp"), + accepts: &["prop.set"], + when: json!({"path": "transform/position"}), + show: vec![("prop.set", json!({"path": "transform/position", "value": [960.0, 540.0, 0.0]}))], + }), + step(S { + title: "Smooth it out", + text: "Select both keyframes (drag a box around them, or click the Position name) and press F9 for Easy Ease. The title now glides in and settles.", + target: Some("timeline.ruler"), + accepts: &["keys.easyEase"], + when: Value::Null, + show: vec![("keys.selectAll", json!({})), ("keys.easyEase", json!({}))], + }), + step(S { + title: "Watch it", + text: "Press the Spacebar (or Play in the Preview panel) to play your animation. Press it again to stop. That's it: you made your first animation.", + target: Some("panel.Preview"), + accepts: &["playback.toggle"], + when: Value::Null, + show: vec![("time.set", json!({"time": 0.0})), ("playback.toggle", json!({}))], + }), + ], + }, + Tutorial { + id: "track-attach".into(), + title: "Track and attach".into(), + summary: "Follow a moving element with the point tracker, then hang a null on the track and parent a layer to it.".into(), + minutes: 5, + steps: vec![ + step(S { + title: "Open the demo", + text: "We'll track the moving lower third in the demo project. Open it from Home ▸ Open Demo Project (or File ▸ Open Demo Project).", + target: Some("menu.File"), + accepts: &["file.openDemoProject"], + when: Value::Null, + show: vec![("file.openDemoProject", json!({}))], + }), + step(S { + title: "Pick what to track", + text: "Click the Lower Third layer in the Timeline, then press I to jump to where it starts (0:00:05:00). The tracker follows the pixels of the selected layer's source.", + target: Some("timeline.layer.*.row"), + accepts: &["layer.select"], + when: Value::Null, + show: vec![("layer.select", json!({"layers": ["Lower Third"]})), ("time.set", json!({"time": 5.5}))], + }), + step(S { + title: "Add a track point", + text: "Open Window ▸ Tracker and click Track Motion. A track point appears: the inner box is the feature to follow, the outer box is where to look for it on the next frame.", + target: Some("tracker.trackMotion"), + accepts: &["track.motion", "track.new"], + when: Value::Null, + show: vec![("track.motion", json!({}))], + }), + step(S { + title: "Analyze", + text: "Click Analyze forward ▶ in the Tracker panel. The point follows the feature frame by frame and leaves a path of keyframes behind.", + target: Some("tracker.analyze.forward"), + accepts: &["track.analyze"], + when: Value::Null, + show: vec![("track.analyze", json!({"direction": "forward", "end": 6.0, "wait": true}))], + }), + step(S { + title: "Make a null to carry the motion", + text: "Choose Layer ▸ New ▸ Null Object. A null is invisible; it only holds a transform, which makes it a handy anchor for tracked motion.", + target: Some("menu.Layer"), + accepts: &["layer.newNull"], + when: Value::Null, + show: vec![("layer.newNull", json!({"name": "Track Null"}))], + }), + step(S { + title: "Point the track at the null", + text: "Select Lower Third again, click Edit Target… in the Tracker panel and choose the null as the motion target.", + target: Some("tracker.editTarget"), + accepts: &["track.setTarget"], + when: Value::Null, + show: vec![("track.setTarget", json!({"layer": "Lower Third", "target": "Track Null"}))], + }), + step(S { + title: "Apply", + text: "Click Apply and keep X and Y. The null's Position now follows the track.", + target: Some("tracker.apply"), + accepts: &["track.apply"], + when: Value::Null, + show: vec![("track.apply", json!({"layer": "Lower Third", "dimensions": "xy"}))], + }), + step(S { + title: "Attach a layer", + text: "Drag a layer's pick whip (the spiral in Parent & Link) onto the null, or pick it from the menu. The child now rides along with the track.", + target: Some("timeline.layer.*.pickWhip"), + accepts: &["layer.setParent"], + when: Value::Null, + show: vec![("layer.setParent", json!({"layers": ["Tagline"], "parent": "Track Null"}))], + }), + ], + }, + Tutorial { + id: "3d-scene".into(), + title: "Make a 3D scene".into(), + summary: "Turn flat layers into 3D, light them, add a camera and look at the scene from outside the frame.".into(), + minutes: 5, + steps: vec![ + step(S { + title: "Make a composition", + text: "Choose Composition ▸ New Composition. Any size works; 1920×1080 is a good start.", + target: Some("menu.Composition"), + accepts: &["comp.new"], + when: Value::Null, + show: vec![("comp.new", json!({"name": "3D Scene", "width": 1920, "height": 1080, "duration": 5}))], + }), + step(S { + title: "Add a floor", + text: "Choose Layer ▸ New ▸ Solid and make a large solid in a calm colour. It will become the floor.", + target: Some("menu.Layer"), + accepts: &["layer.newSolid"], + when: Value::Null, + show: vec![("layer.newSolid", json!({"name": "Floor", "color": "#3c5a82", "width": 2400, "height": 2400}))], + }), + step(S { + title: "Make it 3D", + text: "Click the cube switch of the solid in the Timeline. The layer gains a Z position and X/Y/Z rotation.", + target: Some("timeline.layer.{layer}.switch.threeD"), + accepts: &["layer.setSwitch"], + when: json!({"switch": "threeD"}), + show: vec![("layer.setSwitch", json!({"switch": "threeD", "value": true}))], + }), + step(S { + title: "Lay it down", + text: "Set X Rotation to -80° (twirl open Transform and scrub the value) so the solid lies like a floor, and push its Position down a little.", + target: Some("timeline.layer.{layer}.twirl"), + accepts: &["prop.set"], + when: Value::Null, + show: vec![ + ("prop.set", json!({"path": "transform/rotationX", "value": -80.0})), + ("prop.set", json!({"path": "transform/position", "value": [960.0, 760.0, 0.0]})), + ], + }), + step(S { + title: "Light it", + text: "Choose Layer ▸ New ▸ Light and pick Spot. 3D layers now catch light and fall-off.", + target: Some("menu.Layer"), + accepts: &["layer.newLight"], + when: Value::Null, + show: vec![("layer.newLight", json!({"kind": "Spot", "name": "Key Light"}))], + }), + step(S { + title: "Add a camera", + text: "Choose Layer ▸ New ▸ Camera. The comp is now seen through this camera; orbit it with the camera tools (press C).", + target: Some("menu.Layer"), + accepts: &["layer.newCamera"], + when: Value::Null, + show: vec![("layer.newCamera", json!({"name": "Camera"}))], + }), + step(S { + title: "Step outside the camera", + text: "In the 3D View popup under the viewer choose Custom View 1. You see the scene, the camera and the light from a working view.", + target: Some("viewer.view3d"), + accepts: &[ + "view.set3DView", + "view.3d.custom1", + "view.3d.custom2", + "view.3d.custom3", + "view.3d.front", + "view.3d.top", + "view.3d.left", + "view.3d.right", + "view.3d.back", + "view.3d.bottom", + ], + when: Value::Null, + show: vec![("view.3d.custom1", json!({}))], + }), + step(S { + title: "See past the frame", + text: "Click Extended Viewer beside the 3D View popup. The viewer now shows the layers beyond the comp's edges, with the frame outlined.", + target: Some("viewer.extendedViewer"), + accepts: &["view.extendedViewer"], + when: Value::Null, + show: vec![("view.extendedViewer", json!({"value": true}))], + }), + ], + }, + ] +} + +/// The tutorial with this id. +pub fn find(id: &str) -> Option { + tutorials().into_iter().find(|t| t.id == id) +} + +/// The automation id pattern of a step's target with `{layer}` filled in (`*` stays a wildcard). +pub fn resolve_target(target: &str, layer: Option) -> String { + match layer { + Some(l) => target.replace("{layer}", &l.to_string()), + None => target.replace("{layer}", "*"), + } +} + +/// Whether an automation id matches a target pattern (`*` = any run of characters). +pub fn target_matches(pattern: &str, id: &str) -> bool { + let parts: Vec<&str> = pattern.split('*').collect(); + if parts.len() == 1 { + return pattern == id; + } + let mut rest = id; + for (i, part) in parts.iter().enumerate() { + if i == 0 { + let Some(r) = rest.strip_prefix(part) else { return false }; + rest = r; + } else if i == parts.len() - 1 { + return rest.ends_with(part); + } else { + let Some(at) = rest.find(part) else { return false }; + rest = &rest[at + part.len()..]; + } + } + true +} + +/// `want`'s keys all equal in `got` (`null` matches anything). +fn params_match(want: &Value, got: &Value) -> bool { + match want { + Value::Object(m) => m.iter().all(|(k, v)| got.get(k) == Some(v)), + _ => true, + } +} + +impl Session { + /// The current tutorial and step, if one is running. + pub fn learn_current(&self) -> Option<(Tutorial, usize)> { + let p = self.learn.as_ref()?; + Some((find(&p.tutorial)?, p.step)) + } + + /// A command ran: complete the current step when it is one the step accepts. + pub(crate) fn learn_observe(&mut self, id: &str, params: &Value) { + if id.starts_with("learn.") { + return; + } + let Some((t, i)) = self.learn_current() else { return }; + let Some(st) = t.steps.get(i) else { return }; + if st.accepts.iter().any(|a| a == id) && params_match(&st.when, params) { + self.learn_goto(&t, i + 1); + } + } + + fn learn_goto(&mut self, t: &Tutorial, i: usize) { + let n = t.steps.len(); + let i = i.min(n); + if let Some(p) = &mut self.learn { + p.step = i; + p.done = i >= n; + } + if i >= n { + self.toast(format!("Tutorial complete: {}", t.title)); + } + } +} + +fn state_json(s: &Session) -> Value { + match s.learn_current() { + Some((t, i)) => json!({ + "tutorial": t.id, + "title": t.title, + "step": i, + "steps": t.steps.len(), + "done": i >= t.steps.len(), + "current": t.steps.get(i).map(|st| json!({ + "title": st.title, + "text": st.text, + "target": st.target.as_deref().map(|x| resolve_target(x, s.state.selected_layers.first().map(|l| l.0))), + "accepts": st.accepts, + })), + }), + None => Value::Null, + } +} + +fn list(_: &mut Session, _: &Value) -> Result { + Ok(json!( + tutorials() + .iter() + .map(|t| json!({"id": t.id, "title": t.title, "summary": t.summary, "minutes": t.minutes, "steps": t.steps.iter().map(|s| s.title.clone()).collect::>()})) + .collect::>() + )) +} + +fn start(s: &mut Session, p: &Value) -> Result { + let id = str_p(p, "id").or_else(|| str_p(p, "tutorial")).ok_or_else(|| bad("learn.start", "missing `id`"))?; + let t = find(id).ok_or_else(|| bad("learn.start", format!("unknown tutorial `{id}` (see learn.list)")))?; + s.learn = Some(Progress { tutorial: t.id.clone(), step: 0, done: false }); + Ok(state_json(s)) +} + +fn step_cmd(s: &mut Session, p: &Value) -> Result { + let (t, i) = s.learn_current().ok_or_else(|| bad("learn.step", "no tutorial is running (learn.start)"))?; + match str_p(p, "action").unwrap_or("next") { + "next" => s.learn_goto(&t, i + 1), + "back" | "prev" | "previous" => s.learn_goto(&t, i.saturating_sub(1)), + "goto" => { + let n = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("learn.step", "goto needs `index`"))?; + s.learn_goto(&t, n as usize); + } + "showMe" | "show" => { + let st = t.steps.get(i).ok_or_else(|| bad("learn.step", "the tutorial is finished"))?; + for (c, params) in &st.show { + s.execute(c, params.clone()).map_err(|e| bad("learn.step", format!("Show me: {c}: {e}")))?; + } + // Steps whose commands don't complete them (or reading steps) still move on. + if s.learn.as_ref().is_some_and(|p| p.step == i) { + s.learn_goto(&t, i + 1); + } + } + a => return Err(bad("learn.step", format!("action must be next|back|showMe|goto, not `{a}`"))), + } + Ok(state_json(s)) +} + +fn stop(s: &mut Session, _: &Value) -> Result { + s.learn = None; + Ok(Value::Null) +} + +pub fn specs() -> Vec { + vec![ + query!("learn.list", "Learn Tutorials", "{} → [{id, title, summary, minutes, steps}]", list), + query!("learn.state", "Tutorial State", "{} → {tutorial, step, steps, done, current: {title, text, target, accepts}} | null", |s, _| Ok(state_json( + s + ))), + cmd!("learn.start", "Start Tutorial", [], None, "{id}", always, start), + cmd!("learn.step", "Tutorial Step", [], None, "{action?: next|back|showMe|goto, index?}", always, step_cmd), + cmd!("learn.stop", "Close Tutorial", [], None, "{}", always, stop), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn targets_match_with_wildcards() { + assert!(target_matches("timeline.layer.*.row", "timeline.layer.12.row")); + assert!(!target_matches("timeline.layer.*.row", "timeline.layer.12.bar")); + assert!(target_matches("menu.File", "menu.File")); + assert!(!target_matches("menu.File", "menu.File.2")); + assert_eq!(resolve_target("timeline.layer.{layer}.twirl", Some(7)), "timeline.layer.7.twirl"); + assert_eq!(resolve_target("timeline.layer.{layer}.twirl", None), "timeline.layer.*.twirl"); + } + + #[test] + fn every_step_names_real_commands() { + for t in tutorials() { + assert!(!t.steps.is_empty(), "{}", t.id); + for st in &t.steps { + for c in st.accepts.iter().chain(st.show.iter().map(|(c, _)| c)) { + assert!(crate::commands::find(c).is_some(), "{}: unknown command {c}", t.id); + } + assert!(!st.show.is_empty(), "{}: `{}` has no Show me", t.id, st.title); + } + } + } + + #[test] + fn tutorials_run_end_to_end_with_show_me() { + for id in ["animate-title", "3d-scene", "track-attach"] { + let mut s = Session::default(); + s.execute("learn.start", json!({"id": id})).unwrap(); + let n = find(id).unwrap().steps.len(); + for i in 0..n { + let st = s.execute("learn.step", json!({"action": "showMe"})).unwrap_or_else(|e| panic!("{id} step {i}: {e}")); + assert_eq!(st["step"], json!(i + 1), "{id}: {st}"); + } + assert_eq!(s.execute("learn.state", json!({})).unwrap()["done"], json!(true)); + } + } + + #[test] + fn performing_the_command_advances() { + let mut s = Session::default(); + let list = s.execute("learn.list", json!({})).unwrap(); + assert_eq!(list.as_array().unwrap().len(), 3); + s.execute("learn.start", json!({"id": "animate-title"})).unwrap(); + // Unrelated commands don't advance. + s.execute("file.openDemoProject", json!({})).unwrap(); + assert_eq!(s.learn.as_ref().unwrap().step, 0); + s.execute("comp.new", json!({"name": "Mine"})).unwrap(); + assert_eq!(s.learn.as_ref().unwrap().step, 1); + s.execute("layer.newText", json!({"text": "Hi"})).unwrap(); + assert_eq!(s.learn.as_ref().unwrap().step, 2); + let cur = s.execute("learn.state", json!({})).unwrap(); + let lid = s.state.selected_layers[0].0; + assert_eq!(cur["current"]["target"], json!(format!("timeline.layer.{lid}.twirl"))); + // Back and Next. + s.execute("learn.step", json!({"action": "back"})).unwrap(); + assert_eq!(s.learn.as_ref().unwrap().step, 1); + s.execute("learn.step", json!({})).unwrap(); + assert_eq!(s.learn.as_ref().unwrap().step, 2); + // A matching command with the wrong parameters doesn't complete a `when` step. + s.execute("learn.start", json!({"id": "3d-scene"})).unwrap(); + s.execute("learn.step", json!({"action": "goto", "index": 2})).unwrap(); + s.execute("layer.setSwitch", json!({"switch": "shy", "value": true})).ok(); + assert_eq!(s.learn.as_ref().unwrap().step, 2); + s.execute("learn.stop", json!({})).unwrap(); + assert!(s.learn.is_none()); + assert!(s.execute("learn.step", json!({})).is_err()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 17d9b235..25c88881 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ pub mod commands; pub mod config; pub mod demo; pub mod jobs; +pub mod learn; pub mod links; pub mod logging; pub mod mask_track; @@ -351,6 +352,8 @@ pub struct Session { /// Finished tasks, newest last (Progress panel, `jobs.list`). pub job_log: Vec, pub next_task_id: u64, + /// The running Learn tutorial (Home ▸ Learn), see [`learn`]. + pub learn: Option, } /// A script to run (see [`Session::script`]). @@ -412,6 +415,7 @@ impl Default for Session { tasks: vec![], job_log: vec![], next_task_id: 1, + learn: None, } } } @@ -433,6 +437,7 @@ impl Session { } } let r = (spec.run)(self, ¶ms)?; + self.learn_observe(id, ¶ms); if spec.journal { self.journal.push((id.to_string(), params)); if self.journal.len() > 10_000 { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d60b2ed2..ff3db561 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -204,6 +204,10 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques if let Some(b) = p.get("home").and_then(Value::as_bool) { app.ui.start_screen = b; } + // Home screen tab: "home" or "learn". + if let Some(tab) = p.get("homeTab").and_then(Value::as_str) { + app.ui.home_learn = tab == "learn"; + } ok(Value::Null) } "ui.panel.show" | "ui.panel.close" => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e345e9c3..d873a333 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -287,9 +287,10 @@ impl EffectcraftApp { self.ui.dock = self.ui.saved_workspaces.get(name).cloned().unwrap_or_else(|| dock::workspace(name)); self.ui.floating = self.ui.saved_floating.get(name).cloned().unwrap_or_else(|| dock::workspace_floating(name)); self.ui.maximized = None; - // Learn: the Home screen (community links) in the Composition panel. + // Learn: the Home screen's Learn tab (tutorials) in the Composition panel. if name == "Learn" { self.ui.start_screen = true; + self.ui.home_learn = true; } } @@ -854,6 +855,7 @@ impl EffectcraftApp { panels::precomp::mini_flowchart(self, &ctx); panels::home::capture_thumbnail(self); panels::dialogs::show(self, &ctx); + panels::learn::coach(self, &ctx); self.draw_toast(ui, full); } diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index 42ded4b7..d53a0c08 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -173,7 +173,25 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::header::paint_logo(&p, Rect::from_min_size(pos2(x0, y), vec2(40.0, 40.0))); p.text(pos2(x0 + 52.0, y + 12.0), Align2::LEFT_CENTER, "EffectCraft", Tokens::semibold(22.0), Color32::WHITE); p.text(pos2(x0 + 52.0, y + 32.0), Align2::LEFT_CENTER, format!("Version {}", env!("CARGO_PKG_VERSION")), Tokens::ui(11.0), t.text_faint); - y += 64.0; + y += 56.0; + // Home / Learn tabs. + for (k, (label, learn)) in [("Home", false), ("Learn", true)].into_iter().enumerate() { + let r = Rect::from_min_size(pos2(x0 + k as f32 * 84.0, y), vec2(78.0, 28.0)); + let resp = ui.interact(r, egui::Id::new(("home-tab", label)), Sense::click()); + let on = app.ui.home_learn == learn; + if on || resp.hovered() { + p.rect_filled(r, 6.0, if on { Color32::from_rgb(0x2e, 0x31, 0x3b) } else { t.hover }); + } + p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::medium(13.0), if on { Color32::WHITE } else { t.text_dim }); + if on { + p.line_segment([pos2(r.min.x + 12.0, r.max.y - 1.0), pos2(r.max.x - 12.0, r.max.y - 1.0)], Stroke::new(2.0, t.accent)); + } + app.auto.add(&format!("home.tab.{}", label.to_ascii_lowercase()), r, label); + if resp.clicked() { + app.ui.home_learn = learn; + } + } + y += 40.0; for (label, id, primary) in [ ("New Project", "file.newProject", true), ("Open Project…", "file.open", false), @@ -239,13 +257,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let community_bottom = cy + sib.len().div_ceil(per_row) as f32 * 28.0; - // ---- recent projects. + // ---- recent projects (or the Learn tab's tutorials). let (rx, mut ry, rw) = if wide { let rx = x0 + left_w + 40.0; (rx, rect.min.y + pad + 6.0, rect.max.x - pad - rx) } else { (x0, community_bottom + 24.0, left_w) }; + if app.ui.home_learn { + super::learn::home_tab(app, ui, Rect::from_min_max(pos2(rx, ry), pos2(rx + rw, rect.max.y - 8.0))); + run_actions(app, &ctx, actions); + return; + } p.text(pos2(rx, ry + 6.0), Align2::LEFT_CENTER, "Recent", Tokens::semibold(16.0), Color32::WHITE); let entries = recent_entries(&app.session.prefs); if !entries.is_empty() { @@ -304,9 +327,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ry += row_h; } + run_actions(app, &ctx, actions); +} + +fn run_actions(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str, serde_json::Value)>) { for (id, params) in actions { let before = (app.session.path.clone(), app.session.revision); - match crate::menus::invoke(app, &ctx, id, params) { + match crate::menus::invoke(app, ctx, id, params) { Err(e) => app.ui.status = e, // Leave Home unless only the list changed or a file dialog was cancelled. Ok(_) => { @@ -382,8 +409,46 @@ mod tests { "home.help.discord", "home.help.github", "home.recent.0", + "home.tab.home", + "home.tab.learn", ] { assert!(app.auto.find(id).is_some(), "{id}"); } } + + #[test] + fn learn_tab_lists_and_starts_tutorials() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.ui.start_screen = true; + app.ui.home_learn = true; + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens); + let frame = |app: &mut EffectcraftApp| { + ctx.run_ui(Default::default(), |ui| { + app.auto.begin_frame(); + show(app, ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))); + crate::panels::learn::coach(app, ui.ctx()); + }) + .textures_delta + .clear(); + }; + frame(&mut app); + frame(&mut app); + for t in effectcraft_engine::learn::tutorials() { + assert!(app.auto.find(&format!("home.learn.{}.start", t.id)).is_some(), "{}", t.id); + } + assert!(app.auto.find("home.recent.0").is_none()); + // Start through the session (as the Start button does); the coach card appears. + app.session.execute("learn.start", json!({"id": "animate-title"})).unwrap(); + app.ui.start_screen = false; + frame(&mut app); + frame(&mut app); + for id in ["learn.coach", "learn.showMe", "learn.next", "learn.close"] { + assert!(app.auto.find(id).is_some(), "{id}"); + } + // Show me performs the step and the coach moves on. + app.session.execute("learn.step", json!({"action": "showMe"})).unwrap(); + assert_eq!(app.session.learn.as_ref().unwrap().step, 1); + assert!(app.session.active_comp().is_some()); + } } diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs new file mode 100644 index 00000000..a184e230 --- /dev/null +++ b/crates/ui-egui/src/panels/learn.rs @@ -0,0 +1,199 @@ +//! Learn: the Home screen's tutorial list and the coach card that walks through a running +//! tutorial (engine model in `effectcraft_engine::learn`). The coach highlights the step's target +//! (an automation id) and moves on when the user performs the step's command; "Show me" runs it. + +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::json; + +use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; + +/// The Learn tab of the Home screen: one card per tutorial with its steps and a Start button. +pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { + let t = app.tokens; + let p = ui.painter().with_clip_rect(area); + let (x, mut y, w) = (area.min.x, area.min.y, area.width()); + p.text(pos2(x, y + 6.0), Align2::LEFT_CENTER, "Learn", Tokens::semibold(16.0), Color32::WHITE); + p.text( + pos2(x, y + 26.0), + Align2::LEFT_CENTER, + "Hands-on tutorials that run inside EffectCraft. Do each step yourself, or press Show me.", + Tokens::ui(11.5), + t.text_faint, + ); + y += 44.0; + let tutorials = effectcraft_engine::learn::tutorials(); + let running = app.session.learn.as_ref().map(|l| l.tutorial.clone()); + let card_h = 128.0; + for tut in &tutorials { + let r = Rect::from_min_size(pos2(x, y), vec2(w, card_h - 10.0)); + if r.min.y > area.max.y { + break; + } + let resp = ui.interact(r, egui::Id::new(("learn-card", &tut.id)), Sense::click()); + p.rect_filled(r, 8.0, if resp.hovered() { Color32::from_rgb(0x2c, 0x2e, 0x37) } else { Color32::from_rgb(0x24, 0x26, 0x2e) }); + // A small original badge: the step count in a circle. + let badge = Rect::from_min_size(pos2(r.min.x + 16.0, r.min.y + 16.0), vec2(44.0, 44.0)); + p.circle_filled(badge.center(), 22.0, t.accent.gamma_multiply(0.25)); + p.circle_stroke(badge.center(), 22.0, Stroke::new(1.5, t.accent)); + p.text(badge.center() - vec2(0.0, 5.0), Align2::CENTER_CENTER, tut.steps.len().to_string(), Tokens::semibold(15.0), Color32::WHITE); + p.text(badge.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, "steps", Tokens::ui(9.0), t.text_dim); + let tx = badge.max.x + 16.0; + p.text(pos2(tx, r.min.y + 22.0), Align2::LEFT_CENTER, &tut.title, Tokens::semibold(14.0), Color32::WHITE); + p.text(pos2(tx, r.min.y + 40.0), Align2::LEFT_CENTER, format!("{} min", tut.minutes), Tokens::ui(11.0), t.text_faint); + let text_w = (r.max.x - tx - 120.0).max(120.0); + let g = p.layout(tut.summary.clone(), Tokens::ui(12.0), t.text_dim, text_w); + p.galley(pos2(tx, r.min.y + 52.0), g, t.text_dim); + let steps: Vec = tut.steps.iter().map(|s| s.title.clone()).collect(); + let g = p.layout(steps.join(" · "), Tokens::ui(10.5), t.text_faint, text_w); + p.galley(pos2(tx, r.min.y + 84.0), g, t.text_faint); + let br = Rect::from_min_size(pos2(r.max.x - 104.0, r.min.y + 16.0), vec2(88.0, 28.0)); + let label = if running.as_deref() == Some(tut.id.as_str()) { "Resume" } else { "Start" }; + let clicked = widgets::text_button(ui, br, label, true, &t, egui::Id::new(("learn-start", &tut.id))).clicked(); + app.auto.add(&format!("home.learn.{}", tut.id), r, &tut.title); + app.auto.add(&format!("home.learn.{}.start", tut.id), br, label); + if clicked || resp.clicked() { + if running.as_deref() != Some(tut.id.as_str()) + && let Err(e) = app.session.execute("learn.start", json!({"id": tut.id})) + { + app.ui.status = e.to_string(); + } + app.ui.start_screen = false; + } + y += card_h; + } +} + +/// The automation element a step's target pattern points at (last frame's registry). +fn target_rect(app: &EffectcraftApp, pattern: &str) -> Option { + let lid = app.session.state.selected_layers.first().map(|l| l.0); + let pat = effectcraft_engine::learn::resolve_target(pattern, lid); + app.auto + .previous + .iter() + .find(|e| effectcraft_engine::learn::target_matches(&pat, &e.id) && e.rect[2] > 0.0 && e.rect[3] > 0.0) + .map(|e| Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))) +} + +/// The coach card (bottom right) and the highlight around the current step's target. +pub fn coach(app: &mut EffectcraftApp, ctx: &egui::Context) { + let Some((tut, i)) = app.session.learn_current() else { return }; + let t = app.tokens; + let n = tut.steps.len(); + let screen = ctx.content_rect(); + let step = tut.steps.get(i).cloned(); + // Highlight the target (pulsing outline over everything but the card). + if app.ui.start_screen { + // The Home screen covers the panels: just the card. + } else if let Some(st) = &step + && let Some(tp) = &st.target + && let Some(r) = target_rect(app, tp) + { + let time = ctx.input(|i| i.time); + let pulse = (0.5 + 0.5 * (time * 4.0).sin()) as f32; + let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("learn-highlight"))); + let hr = r.expand(3.0 + 2.0 * pulse); + p.rect_stroke(hr, 5.0, Stroke::new(2.0, t.accent.gamma_multiply(0.6 + 0.4 * pulse)), StrokeKind::Outside); + app.auto.add("learn.highlight", hr, tp); + ctx.request_repaint_after(std::time::Duration::from_millis(33)); + } + let w = 340.0; + let mut action: Option<&str> = None; + let area = egui::Area::new(egui::Id::new("learn-coach")) + .order(egui::Order::Foreground) + .fixed_pos(pos2(screen.max.x - w - 20.0, screen.max.y - 250.0)) + .show(ctx, |ui| { + egui::Frame::new() + .fill(Color32::from_rgb(0x26, 0x28, 0x30)) + .stroke(Stroke::new(1.0, t.accent.gamma_multiply(0.7))) + .corner_radius(8.0) + .inner_margin(egui::Margin::same(14)) + .shadow(egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(140) }) + .show(ui, |ui| { + ui.set_width(w - 28.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("LEARN").font(Tokens::semibold(10.0)).color(t.accent)); + ui.label(egui::RichText::new(&tut.title).font(Tokens::ui(11.0)).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let (cr, r) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click()); + let r = r.on_hover_text("Close tutorial"); + crate::icons::paint(ui.painter(), cr, crate::icons::Icon::Close, if r.hovered() { Color32::WHITE } else { t.text_dim }); + app.auto.add("learn.close", r.rect, "Close tutorial"); + if r.clicked() { + action = Some("close"); + } + }); + }); + // Progress dots. + let (dr, _) = ui.allocate_exact_size(vec2(w - 28.0, 10.0), Sense::hover()); + for k in 0..n { + let c = pos2(dr.min.x + 4.0 + k as f32 * 12.0, dr.center().y); + let col = if k < i { + t.accent + } else if k == i { + Color32::WHITE + } else { + Color32::from_gray(80) + }; + ui.painter().circle_filled(c, 3.0, col); + } + ui.add_space(4.0); + match &step { + Some(st) => { + ui.label(egui::RichText::new(format!("Step {} of {n}: {}", i + 1, st.title)).font(Tokens::semibold(13.0)).color(Color32::WHITE)); + ui.add_space(2.0); + ui.label(egui::RichText::new(&st.text).font(Tokens::ui(12.0)).color(t.text)); + } + None => { + ui.label(egui::RichText::new("Tutorial complete").font(Tokens::semibold(13.0)).color(Color32::WHITE)); + ui.label( + egui::RichText::new("Nice work. Pick another tutorial on the Home screen's Learn tab.").font(Tokens::ui(12.0)).color(t.text), + ); + } + } + ui.add_space(8.0); + ui.horizontal(|ui| { + let back = ui.add_enabled(i > 0, egui::Button::new("Back")); + app.auto.add("learn.back", back.rect, "Back"); + if back.clicked() { + action = Some("back"); + } + if step.is_some() { + let show = ui.add(egui::Button::new("Show me")); + app.auto.add("learn.showMe", show.rect, "Show me"); + if show.clicked() { + action = Some("showMe"); + } + let next = ui.add(egui::Button::new("Next")); + app.auto.add("learn.next", next.rect, "Next"); + if next.clicked() { + action = Some("next"); + } + } else { + let more = ui.add(egui::Button::new("More tutorials")); + app.auto.add("learn.more", more.rect, "More tutorials"); + if more.clicked() { + action = Some("more"); + } + } + }); + }); + }); + app.auto.add("learn.coach", area.response.rect, &tut.title); + match action { + Some("close") => { + let _ = app.session.execute("learn.stop", json!({})); + } + Some("more") => { + let _ = app.session.execute("learn.stop", json!({})); + app.ui.home_learn = true; + app.ui.start_screen = true; + } + Some(a) => { + if let Err(e) = app.session.execute("learn.step", json!({"action": a})) { + app.ui.status = e.to_string(); + } + } + None => {} + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d9a9864a..ed766f0d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -22,6 +22,7 @@ pub mod info; pub mod key_dialogs; pub mod layer_panel; pub mod layer_styles_dialog; +pub mod learn; pub mod markers_ui; pub mod media_panels; pub mod misc; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 584cdd95..50031da7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -597,6 +597,9 @@ pub struct UiState { #[serde(default = "yes")] pub preview_audio: bool, pub start_screen: bool, + /// The Home screen shows its Learn tab (tutorials) instead of the recent projects. + #[serde(default)] + pub home_learn: bool, /// Composition ▸ Preview ▸ Cache Frames When Idle. pub cache_when_idle: bool, /// Tracker panel ▸ Motion Source chosen without a tracker yet (layer id). @@ -666,6 +669,7 @@ impl Default for UiState { preview_cache_first: false, preview_audio: true, start_screen: false, + home_learn: false, cache_when_idle: false, tracker_source: None, layer_panel: None, From 40a498d6a2c73d649d53e7b92d3d21c4f79f3e48 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 05:36:59 -0400 Subject: [PATCH 3/4] M13.5: UI fidelity pass (compact rows, flat rows with hairlines, switch wells, selected name cells, muted bars, AE ruler/work-area layout, twirl chevrons, timecode size, workspace bar order, single viewer breadcrumb row) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/header.rs | 4 +- crates/ui-egui/src/panels/project.rs | 25 +++--- crates/ui-egui/src/panels/timeline.rs | 111 ++++++++++++++++++-------- crates/ui-egui/src/panels/viewer.rs | 9 ++- crates/ui-egui/src/theme.rs | 2 +- crates/ui-egui/src/widgets.rs | 10 ++- 6 files changed, 111 insertions(+), 50 deletions(-) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index b2fa142e..d185b52c 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -168,7 +168,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.set_workspace(crate::dock::WORKSPACES[i]); } rx = more.min.x - 6.0; - let shown = ["Small Screen", "Standard", "Animation", "Default"]; + // After Effects 2026's workspace bar order (right to left here): Default, Review, Learn, + // Small Screen, Standard. + let shown = ["Standard", "Small Screen", "Learn", "Review", "Default"]; for name in shown { let g = p.layout_no_wrap(name.to_string(), Tokens::ui(12.0), t.text); let w = g.size().x + 16.0; diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index f3898954..8c89f25f 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -14,6 +14,9 @@ use crate::panels::DragPayload; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; +/// Item row height (After Effects' Project panel rows are compact, about 19 pt). +const ROW_H: f32 = 19.0; + fn item_icon(it: &Item) -> Icon { match &it.kind { ItemKind::Folder => Icon::Folder, @@ -323,8 +326,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut actions: Vec<(String, serde_json::Value)> = vec![]; for (i, (id, depth)) in rows.iter().enumerate() { let Some(it) = app.session.project.item(*id).cloned() else { continue }; - let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), 22.0)); - y += 22.0; + let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H)); + y += ROW_H; if r.min.y > list.max.y { break; } @@ -335,14 +338,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { lp.rect_filled( r, 0.0, - if selected { - t.row_selected - } else if i % 2 == 0 { - t.row - } else { - t.row_alt - }, + // Flat rows like After Effects' Project panel (no stripes). + if selected { t.row_selected } else { t.row }, ); + let _ = i; let x0 = r.min.x + 8.0 + 14.0 * *depth as f32; if it.is_folder() { let open = app.ui.project_open_folders.contains(&id.0); @@ -400,12 +399,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { true }; if !cell_edit(app, ui, &mut actions, "name", name_rect) { + // The selected item's name sits in a light cell with dark text. + if selected { + let g = lp.layout_no_wrap(it.name.clone(), Tokens::ui(12.0), Color32::BLACK); + let cell = Rect::from_min_max(pos2(x0 + 27.0, r.min.y + 1.0), pos2((x0 + 33.0 + g.size().x).min(name_clip.max.x), r.max.y - 1.0)); + lp.with_clip_rect(name_clip.expand2(vec2(3.0, 0.0))).rect_filled(cell, 0.0, Color32::from_rgb(0xa6, 0xa6, 0xa6)); + } lp.with_clip_rect(name_clip).text( pos2(x0 + 30.0, r.center().y), Align2::LEFT_CENTER, &it.name, Tokens::ui(12.0), - if selected { Color32::WHITE } else { t.text }, + if selected { Color32::from_gray(0x16) } else { t.text }, ); } // Label swatch: click for the label colour menu. diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 80c1a36c..9b3d6aac 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -47,6 +47,22 @@ enum RowKind { /// Synthetic group uid for a layer's Audio > Waveform twirl (never a real property uid). const WAVE_BIT: u64 = 1 << 60; +/// Time navigator's visible-span bar and the work area bar. +const NAV_BAR: Color32 = Color32::from_rgb(0x55, 0x55, 0x55); +const WORK_AREA_BAR: Color32 = Color32::from_rgb(0x5c, 0x5c, 0x5c); +/// Fill of the timeline's switch / A/V boxes (dark wells, as in After Effects). +const SWITCH_BOX: Color32 = Color32::from_rgb(0x19, 0x19, 0x19); +/// Background of the selected layer's name cell. +const SELECTED_NAME_BG: Color32 = Color32::from_rgb(0xa6, 0xa6, 0xa6); + +/// A layer bar's fill: the label colour muted toward grey (a little brighter when selected). +pub(crate) fn bar_color(label: Color32, selected: bool) -> Color32 { + let k = if selected { 0.62 } else { 0.45 }; + let g = 0x3a as f32; + let mix = |c: u8| (c as f32 * k + g * (1.0 - k)).round() as u8; + Color32::from_rgb(mix(label.r()), mix(label.g()), mix(label.b())) +} + /// Row height (expression editors grow with their text). fn row_height(row: &Row, rh: f32) -> f32 { match row.kind { @@ -756,7 +772,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let tc = crate::panels::timecode(&app.session, &comp, time); let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0)); let tc_resp = ui.interact(tc_rect, egui::Id::new("tl-timecode"), Sense::click_and_drag()); - p.text(tc_rect.left_center(), Align2::LEFT_CENTER, &tc, Tokens::semibold(20.0), t.timecode); + p.text(tc_rect.left_center(), Align2::LEFT_CENTER, &tc, Tokens::semibold(16.0), t.timecode); let sub = format!("{:05} ({:.2} fps)", fr.frame_at(time) + app.session.project.settings.frame_start, fr.as_f64()); p.text(pos2(tc_rect.min.x, tc_rect.max.y + 8.0), Align2::LEFT_CENTER, sub, Tokens::ui(10.5), t.text_faint); app.auto.add("timeline.timecode", tc_rect, &tc); @@ -770,7 +786,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } if let Some(mut buf) = ctx.data(|d| d.get_temp::(egui::Id::new("tl-tc-edit"))) { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(tc_rect)); - let r = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::semibold(18.0)).desired_width(150.0)); + let r = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::semibold(16.0)).desired_width(150.0)); r.request_focus(); if r.lost_focus() { let _ = app.session.execute("time.set", json!({"timecode": buf})); @@ -810,14 +826,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // ---- ruler (right). let ruler = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h), pos2(rect.max.x, top + header_h)); - let nav = Rect::from_min_max(pos2(graph_x0 + 6.0, top + 6.0), pos2(graph_x1, top + 12.0)); - // Time navigator. - p.rect_filled(nav, 3.0, t.field_bg); + let nav = Rect::from_min_max(pos2(graph_x0 + 6.0, top + 6.0), pos2(graph_x1, top + 14.0)); + // Time navigator: the visible span as a grey bar with blue end handles (as in After + // Effects), over a dark track. + p.rect_filled(nav, 1.0, t.field_bg); let vis0 = (tm.start / comp.duration.seconds()) as f32; let vis1 = (tm.t(graph_x1) / comp.duration.seconds()) as f32; let nav_vis = Rect::from_min_max(pos2(nav.min.x + nav.width() * vis0.clamp(0.0, 1.0), nav.min.y), pos2(nav.min.x + nav.width() * vis1.clamp(0.0, 1.0), nav.max.y)); - p.rect_filled(nav_vis, 3.0, t.work_area); + p.rect_filled(nav_vis, 0.0, NAV_BAR); + for x in [nav_vis.min.x, nav_vis.max.x] { + p.rect_filled(Rect::from_min_max(pos2(x - 3.0, nav.min.y - 1.0), pos2(x + 3.0, nav.max.y + 1.0)), 3.0, t.accent); + } let nresp = ui.interact(nav, egui::Id::new("tl-nav"), Sense::drag()); if nresp.dragged() { let d = nresp.drag_delta().x as f64 / nav.width() as f64 * comp.duration.seconds(); @@ -828,12 +848,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } p.rect_filled(ruler, 0.0, t.tl_ruler_bg); app.auto.add("timeline.ruler", ruler, &format!("{},{}", tm.start, tm.pps)); - // Work area bar. - let wa = Rect::from_min_max(pos2(tm.x(comp.work_area.0.seconds()), ruler.min.y + 2.0), pos2(tm.x(comp.work_area.1.seconds()), ruler.min.y + 10.0)); - p.rect_filled(wa, 2.0, t.work_area); + // Work area bar: below the ruler, beside the column headers (After Effects' layout), with + // blue begin / end handles; the cache bar runs under it. + let wa_row = Rect::from_min_max(pos2(graph_x0, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h)); + let wa = Rect::from_min_max(pos2(tm.x(comp.work_area.0.seconds()), wa_row.min.y + 3.0), pos2(tm.x(comp.work_area.1.seconds()), wa_row.max.y - 6.0)); + p.with_clip_rect(wa_row).rect_filled(wa, 0.0, WORK_AREA_BAR); for (hx, set) in [(wa.min.x, "begin"), (wa.max.x, "end")] { - let hr = Rect::from_center_size(pos2(hx, wa.center().y), vec2(8.0, 12.0)); - p.rect_filled(Rect::from_center_size(pos2(hx, wa.center().y), vec2(3.0, 10.0)), 1.0, t.text_dim); + let hr = Rect::from_center_size(pos2(hx, wa.center().y), vec2(8.0, 14.0)); + let handle = if set == "begin" { + Rect::from_min_max(pos2(hx - 1.0, wa.min.y), pos2(hx + 4.0, wa.max.y)) + } else { + Rect::from_min_max(pos2(hx - 4.0, wa.min.y), pos2(hx + 1.0, wa.max.y)) + }; + p.rect_filled(handle, 2.0, t.accent); let resp = ui.interact(hr, egui::Id::new(("wa", set)), Sense::drag()); app.auto.add(&format!("timeline.workArea.{set}"), hr, set); if resp.dragged() @@ -848,7 +875,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let scale_key = app.viewer_shown.as_ref().map(|(_, k)| k.scale).unwrap_or(1000); let cached = app.frames.cached_frames(app.session.revision, cid.0, scale_key); let fd = comp.frame_duration().seconds(); - let cy0 = ruler.min.y + 11.0; + let cy0 = wa_row.max.y - 4.0; let mut run: Option<(i64, i64)> = None; let draw_run = |a: i64, b: i64| { let x0 = tm.x(a as f64 * fd); @@ -1100,19 +1127,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let cy = r.min.y + rh / 2.0; match &row.kind { RowKind::Layer => { - lp.rect_filled( - left, - 0.0, - if is_sel { - t.row_selected - } else if ri % 2 == 0 { - t.row - } else { - t.row_alt - }, - ); - gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, if is_sel { Color32::from_rgb(0x26, 0x26, 0x26) } else { t.tl_bg }); - lp.line_segment([pos2(rect.min.x, r.max.y), pos2(graph_x0, r.max.y)], Stroke::new(1.0, Color32::from_black_alpha(80))); + // After Effects draws layer rows as one flat tone separated by dark hairlines + // (outline and time graph alike), not as alternating stripes. + let _ = ri; + lp.rect_filled(left, 0.0, if is_sel { t.row_selected } else { t.row_alt }); + gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, if is_sel { Color32::from_rgb(0x2a, 0x2a, 0x2a) } else { t.tl_bg }); + lp.line_segment([pos2(rect.min.x, r.max.y - 0.5), pos2(graph_x0, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg)); + gp.line_segment([pos2(graph_x0, r.max.y - 0.5), pos2(r.max.x, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg)); // A/V features. let sw = &layer.switches; let av_items: [(Icon, bool, &str, bool); 4] = [ @@ -1122,11 +1143,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { (Icon::Lock, sw.locked, "lock", true), ]; for (i, (icon, on, name, applicable)) in av_items.into_iter().enumerate() { - let br = Rect::from_center_size(pos2(cw.av + 10.0 + 18.0 * i as f32, cy), vec2(17.0, 17.0)); + let br = Rect::from_center_size(pos2(cw.av + 10.0 + 18.0 * i as f32, cy), vec2(15.0, 15.0)); if !applicable || !vis.av { continue; } - lp.rect_stroke(br.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); + lp.rect_filled(br.shrink(0.5), 1.0, SWITCH_BOX); let resp = widgets::icon_toggle(ui, br, icon, on, &t, egui::Id::new(("av", layer.id.0, i)), None); app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name); if resp.clicked() { @@ -1176,7 +1197,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ctx.data_mut(|d| d.insert_temp(rename_id, (rl, buf))); } } else { - let name_col = if is_sel { Color32::WHITE } else { t.text }; + // The selected layer's name sits in a light cell with dark text. + let name_col = if is_sel { Color32::from_gray(0x16) } else { t.text }; + if is_sel { + let g = lp.layout_no_wrap(display_name(app, layer), Tokens::ui(12.0), name_col); + let cell = Rect::from_min_max( + pos2(name_rect.min.x - 3.0, r.min.y + 1.0), + pos2((name_rect.min.x + g.size().x + 4.0).max(cw.name_end - 4.0).min(cw.name_end - 2.0), r.max.y - 1.0), + ); + lp.rect_filled(cell, 0.0, SELECTED_NAME_BG); + } let lpn = lp.with_clip_rect(name_rect.intersect(lp.clip_rect())); lpn.text(pos2(name_rect.min.x, cy), Align2::LEFT_CENTER, display_name(app, layer), Tokens::ui(12.0), name_col); } @@ -1223,7 +1253,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } let br = Rect::from_center_size(pos2(cw.switches + SW * i as f32 + SW / 2.0, cy), vec2(16.0, 16.0)); - lp.rect_stroke(br.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); + lp.rect_filled(br.shrink(1.0), 1.0, SWITCH_BOX); let resp = widgets::icon_toggle(ui, br, icon, on, &t, egui::Id::new(("sw", layer.id.0, i)), None); app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name); if resp.clicked() { @@ -1321,12 +1351,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if !graph_on { let x_in = tm.x(layer.in_point.seconds()); let x_out = tm.x(layer.out_point.seconds()); - let bar = Rect::from_min_max(pos2(x_in, r.min.y + 3.0), pos2(x_out, r.max.y - 3.0)); + let bar = Rect::from_min_max(pos2(x_in, r.min.y + 1.0), pos2(x_out, r.max.y - 1.0)); let lc = t.label(layer.label); - let fill = if is_sel { lc.gamma_multiply(0.85) } else { lc.gamma_multiply(0.55) }; - gp.rect_filled(bar, 2.0, fill); + let fill = bar_color(lc, is_sel); + gp.rect_filled(bar, 0.0, fill); if is_sel { - gp.rect_stroke(bar, 2.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Inside); + gp.rect_stroke(bar, 0.0, Stroke::new(1.0, Color32::from_white_alpha(90)), StrokeKind::Inside); } // Source extent (e.g. trimmed footage) shown faint. if let LayerSource::Footage { .. } | LayerSource::Comp { .. } = layer.source @@ -1437,7 +1467,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } if let Some(en) = eye { // Layer style eye switch in the A/V column, like AE. - let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(17.0, 17.0)); + let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(15.0, 15.0)); lp.rect_stroke(er.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::new(("geye", uid)), None).clicked() { actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid}))); @@ -2610,4 +2640,17 @@ mod tests { let c = snap_candidates(&comp, &build_rows(&app, &comp)); assert!(c.contains(&comp.duration.seconds()) && c.contains(&0.0)); } + + /// UI fidelity (M13.5): compact After Effects-like rows and muted label-coloured bars. + #[test] + fn rows_and_bars_follow_after_effects_proportions() { + let t = crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark); + assert_eq!(t.row_h, 19.0); + let red = t.label(effectcraft_engine::color::Label::Red); + let bar = bar_color(red, false); + // Muted: darker and less saturated than the label, still reddish. + assert!(bar.r() < red.r() && bar.r() > bar.g() + 30 && bar.g() >= red.g().min(0x3a) - 1); + let sel = bar_color(red, true); + assert!(sel.r() > bar.r()); + } } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 3d1f8de4..b8ec8d41 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -404,11 +404,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let p = ui.painter().clone(); VIEW_CAM.with(|c| c.set(app.session.view_camera(cid))); - // Navigator bar (open comps as breadcrumbs). - let nav = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0)); + // Navigator bar (open comps as breadcrumbs). After Effects shows a single breadcrumb row: + // when the Composition Navigator (nested comp flow) is above the viewer, this row is left + // out. + let nav_h = if super::precomp::has_flow(app) { 0.0 } else { 24.0 }; + let nav = Rect::from_min_size(rect.min, vec2(rect.width(), nav_h)); p.rect_filled(nav, 0.0, t.panel_bg); let mut x = nav.min.x + 10.0; - let open = app.session.state.open_comps.clone(); + let open = if nav_h > 0.0 { app.session.state.open_comps.clone() } else { vec![] }; for oc in open { let name = app.session.project.item(oc).map(|i| i.name.clone()).unwrap_or_default(); let g = p.layout_no_wrap(name.clone(), Tokens::ui(11.5), t.text); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index eb02bf48..288a04b5 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -145,7 +145,7 @@ impl Tokens { radius_sm: 3.0, gap: 4.0, tab_h: 30.0, - row_h: 22.0, + row_h: 19.0, gradients: true, }; match kind { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 2af57f21..9703d6ba 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -87,7 +87,15 @@ pub fn swatch(ui: &mut Ui, rect: Rect, c: [f32; 4], id: egui::Id, t: &Tokens) -> pub fn twirl(ui: &mut Ui, rect: Rect, open: bool, id: egui::Id, t: &Tokens) -> Response { let resp = ui.interact(rect, id, Sense::click()); let col = if resp.hovered() { t.tab_text_active } else { t.text_dim }; - icons::paint(ui.painter(), rect.shrink(rect.width() * 0.22), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col); + // An open chevron (> / v), as After Effects draws its twirl-downs. + let c = rect.center(); + let k = rect.width().min(rect.height()) * 0.2; + let pts = if open { + vec![c + vec2(-1.6 * k, -0.8 * k), c + vec2(0.0, 0.8 * k), c + vec2(1.6 * k, -0.8 * k)] + } else { + vec![c + vec2(-0.8 * k, -1.6 * k), c + vec2(0.8 * k, 0.0), c + vec2(-0.8 * k, 1.6 * k)] + }; + ui.painter().add(egui::Shape::line(pts, Stroke::new(1.4, col))); resp } From 93aa098a29f945cf165d80af6a2aa387397ac56f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 05:37:24 -0400 Subject: [PATCH 4/4] M13.5: docs (parity: extended viewer, Learn tutorials, UI fidelity) Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index 3974630f..1cb71322 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -116,9 +116,9 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Animation | 67% | 10.0 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) | | Text | ≈ 94% | 0.5 | no extruded strokes, variable-axis animation changes outlines but not advances (OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | | Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) | -| 3D | 85% | 6.5 | multi-view layouts, iris shapes in Advanced 3D's depth of field, collapsed 3D precomps in Advanced 3D, the Extended Viewer; collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | +| 3D | 85% | 6.5 | multi-view layouts, iris shapes in Advanced 3D's depth of field, collapsed 3D precomps in Advanced 3D, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | | Effects | ≈ 80% | 4.0 | GPU versions of the remaining effects (58 run on the GPU since M12.7) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | -| Interface | 74% | 5.0 | a richer Learn area (Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | +| Interface | 74% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | | Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | | Preview | 66% | 4.0 | GPU bokeh depth of field, wireframes and Advanced 3D compositing (Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | @@ -163,6 +163,24 @@ style runs; the Layer Style dialog (LYR-9); Graph Editor snapping to markers and Reference Axes; Animate Text ▸ Variable Font Axes. Remaining in these rows: Lottie can't carry taper/wave, Advanced 3D's DOF has no iris shapes, and variable axes don't change advances. +## Update: M13.5 UI completion & fidelity + +**Extended Viewer** (Settings ▸ 3D, the viewer's Extended Viewer button, `view.extendedViewer`): +custom 3D views (and the Active Camera view with Draft 3D on) render the visible pasteboard +around the comp frame (the frame is outlined) so 3D layers reaching past the frame stay +visible; Classic 3D planes project through the offset canvas natively, Advanced 3D comps show +the frame only. **Home ▸ Learn**: three original interactive tutorials (Animate a title, Track +and attach, Make a 3D scene) whose steps highlight their UI target by automation id, advance when +the user runs the step's command, and can be performed with Show me (`learn.list`, +`learn.start`, `learn.step`, `learn.stop`, `learn.state`). **UI fidelity pass** against After +Effects 2026 captures: compact 19 pt Timeline / Project rows, flat rows with dark hairlines, +dark switch wells, the selected layer / item name in a light cell, label-coloured bars muted +toward grey, the time navigator with blue end handles and the work area bar (with the cache +bar under it) below the ruler, open twirl chevrons, a 16 pt current-time display, the +workspace bar order (Default, Review, Learn, Small Screen, Standard) and a single breadcrumb row +above the viewer. Not yet: Composition Settings / Render Queue / Settings dialogs compared +pixel by pixel (no reference captures of them yet). + ## Update: M13.6 panels and content tools The last placeholder panels became real panels with automation ids: **Lumetri Scopes**