From 7e854a622764cbd981a01fb66647520ebbbd2273 Mon Sep 17 00:00:00 2001 From: "Factory: factory-dev" <138933559+factory-droid[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:45:06 +0000 Subject: [PATCH 1/5] feat(droid-control): point callouts at on-screen terminal text A callout's `at` can now name text on a cast clip's screen. The composition replays the cast through avt (the emulator agg renders with), boxes the text's cells, and follows the text as the screen changes. A region target boxes part of any clip, and a point keeps the card alone. The card goes where it covers the least text over the callout's life, on a straight leader, or one with a single jog near a screen edge. An optional `key` adds keycaps. Callouts stage in (box, leader, card) and leave in reverse. Clips now carry their probed size and source cast. Window layout is computed in one place for both drawing and the clip-to-screen mapping. Co-authored-by: factory-ain3sh --- plugins/droid-control/ARCHITECTURE.md | 3 +- plugins/droid-control/fframes/Cargo.lock | 21 +- plugins/droid-control/fframes/Cargo.toml | 1 + plugins/droid-control/fframes/src/callout.rs | 450 ++++++++++++++++++ plugins/droid-control/fframes/src/cast.rs | 169 +++++++ plugins/droid-control/fframes/src/clips.rs | 49 +- plugins/droid-control/fframes/src/content.rs | 96 ++-- plugins/droid-control/fframes/src/lib.rs | 2 + plugins/droid-control/fframes/src/main.rs | 2 +- plugins/droid-control/fframes/src/overlays.rs | 71 +-- plugins/droid-control/fframes/src/props.rs | 46 +- plugins/droid-control/fframes/src/showcase.rs | 45 +- plugins/droid-control/fframes/src/stage.rs | 50 +- .../droid-control/atoms/compose/ATOM.md | 20 +- 14 files changed, 863 insertions(+), 162 deletions(-) create mode 100644 plugins/droid-control/fframes/src/callout.rs create mode 100644 plugins/droid-control/fframes/src/cast.rs diff --git a/plugins/droid-control/ARCHITECTURE.md b/plugins/droid-control/ARCHITECTURE.md index 7d6a5b1..c5f373a 100644 --- a/plugins/droid-control/ARCHITECTURE.md +++ b/plugins/droid-control/ARCHITECTURE.md @@ -146,7 +146,8 @@ The compose stage uses the [fframes](https://github.com/dmtrKovalenko/fframes) p | Background + particles | `scenery.rs` | Preset-driven warmth or coolness | `preset` | | Title card / Droid outro | `title.rs`, `outro.rs` | Opening and closing cards (outro plays fanning rotor → crossfade → DROID wordmark) | `title`, `subtitle`, `speedNote` | | Window chrome + layouts | `content.rs` | Single or side-by-side windows, panel entrances, zooms, spotlights | `layout`, `labels`, `windowTitle`, `objectFit`, `effects` | -| Callouts / section sweeps and headers / keystrokes | `overlays.rs` | Timed in-scene overlays | `effects`, `sections`, `keys` | +| Callouts | `callout.rs`, `cast.rs` | Cards pointing at a region or at text found on a cast clip's screen (replayed through avt, the emulator agg renders with) | `effects` | +| Section sweeps and headers / keystrokes | `overlays.rs` | Timed in-scene overlays | `sections`, `keys` | | Code cards | `overlays.rs`, `code.rs` | Timed syntax-highlighted code cards | `codeAnnotations` | | Transition presentation | `transition.rs` | Title→content and content→outro crossfade | `transitionStyle` (default `motion-blur`) | | Noise + colour grade + watermark | `scenery.rs` | Topmost polish pass | `fidelity`, `preset` | diff --git a/plugins/droid-control/fframes/Cargo.lock b/plugins/droid-control/fframes/Cargo.lock index e2ae47b..dad6581 100644 --- a/plugins/droid-control/fframes/Cargo.lock +++ b/plugins/droid-control/fframes/Cargo.lock @@ -197,6 +197,16 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "avt" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7179c44abe2ac36173d4713bfed24136e5988f005c7fe2c4fcde621d3d4d29b9" +dependencies = [ + "rgb", + "unicode-width 0.1.14", +] + [[package]] name = "base64" version = "0.21.7" @@ -430,7 +440,7 @@ checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" dependencies = [ "encode_unicode", "libc", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.61.2", ] @@ -502,6 +512,7 @@ dependencies = [ name = "droid-showcase" version = "0.1.0" dependencies = [ + "avt", "clap", "fast_image_resize", "fframes", @@ -1014,7 +1025,7 @@ checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ "console", "portable-atomic", - "unicode-width", + "unicode-width 0.2.2", "unit-prefix", "web-time", ] @@ -2405,6 +2416,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" diff --git a/plugins/droid-control/fframes/Cargo.toml b/plugins/droid-control/fframes/Cargo.toml index cc70265..826c5e2 100644 --- a/plugins/droid-control/fframes/Cargo.toml +++ b/plugins/droid-control/fframes/Cargo.toml @@ -6,6 +6,7 @@ publish = false description = "droid-control showcase composition, rendered with fframes" [dependencies] +avt = "0.18" clap = { version = "4", features = ["derive"] } fast_image_resize = { version = "6.1", features = ["only_u8x4"] } fframes = { version = "=1.2.0", features = ["compile-time-svgtree"] } diff --git a/plugins/droid-control/fframes/src/callout.rs b/plugins/droid-control/fframes/src/callout.rs new file mode 100644 index 0000000..0bf238d --- /dev/null +++ b/plugins/droid-control/fframes/src/callout.rs @@ -0,0 +1,450 @@ +//! Callouts: a card of text, with optional keycaps, pointing at a frame region or at text on a +//! terminal clip's screen, or centred on a point. A pointing callout stages in (its box draws +//! around the target, the leader extends, the card slides in) and leaves in reverse. + +use crate::cast::{Cast, Span}; +use crate::content::Stage; +use crate::fonts::Font; +use crate::motion::{cubic_out, interpolate_eased}; +use crate::overlays::{Overlays, frame_of}; +use crate::props::Target; +use crate::svg::{Anchor, Card, Edge, Rect, Rgba, Shadow, Similarity, TextStyle, faded, rounded_rect_d}; +use crate::timing::FPS; +use fframes::{Svgr, svgr}; +use std::collections::HashSet; + +const TEXT: TextStyle = TextStyle::new(Font::sans(500), 22.); +const LINE_HEIGHT: f32 = 31.; +const KEY: TextStyle = TextStyle::new(Font::mono(500), 16.); +const KEY_HEIGHT: f32 = 27.; +const KEY_PADDING: f32 = 8.; +const KEY_GAP: f32 = 6.; +const PADDING: (f32, f32) = (18., 12.); +const ACCENT: f32 = 3.; +/// The least space between the target's box and the card. +const GAP: f32 = 40.; +/// Between the card and the screen edge. +const EDGE: f32 = 24.; +/// Frames a box takes to follow its text to a new place. +const GLIDE: f32 = 8.; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Side { + Right, + Below, + Left, + Above, +} + +enum Aim { + Point(f32, f32), + /// The target from `enter` on, in screen pixels (`clip: None`) or in fractions of a clip's + /// frame; each entry holds until the next. + Target { + clip: Option, + track: Vec<(f32, Rect)>, + side: Side, + gap: f32, + }, +} + +/// A callout laid out once: its card and what it points at. +pub struct Callout { + id: usize, + enter: f32, + exit: f32, + keys: Vec, + lines: Vec, + size: (f32, f32), + aim: Aim, +} + +impl Callout { + /// Text targets are found on the shown cast clips' screens (the first clip showing the text + /// at `t` wins) and followed until the callout ends; the card goes where it covers the least + /// text while the callout shows (see `placement`). + pub fn new( + id: usize, + t: f32, + dur: f32, + text: &str, + at: &Target, + key: Option<&str>, + stage: &Stage<'_>, + ) -> Result { + let (w, h) = stage.size; + let keys: Vec = key.into_iter().flat_map(|key| key.split('+')).filter_map(keycap).collect(); + let lines = TEXT.font.wrap(text, TEXT.size, 0., (0.3 * w).max(240.)); + let text_w = lines.iter().map(|line| TEXT.width(line)).fold(0., f32::max); + let size = + (2. * PADDING.0 + ACCENT + keys_width(&keys) + text_w, 2. * PADDING.1 + lines.len() as f32 * LINE_HEIGHT); + let (enter, exit) = (frame_of(t), frame_of(t + dur)); + let pointing = |clip: Option, track: Vec<(f32, Rect)>, ink: Vec| { + let rects = stage.clip_rects(enter as usize); + let to_screen = |r: Rect| clip.map_or(r, |i| within(r, rects[i])); + let target = boxed(to_screen(track[0].1)); + let ink: Vec = ink.into_iter().map(to_screen).collect(); + let (side, gap) = placement(target, size, &ink, stage.size); + Aim::Target { clip, track, side, gap } + }; + let aim = match at { + Target::Point(point) => Aim::Point(point.x.0 * w, point.y.0 * h), + Target::Region(region) => { + let rect = Rect::new(region.x.0 * w, region.y.0 * h, region.w.0 * w, region.h.0 * h); + pointing(None, vec![(enter, rect)], Vec::new()) + } + Target::Text { text: needle } => { + let shown = stage.clip_rects(enter as usize).len(); + let casts = stage.clips[..shown] + .iter() + .enumerate() + .filter_map(|(i, clip)| Some((i, clip, clip.cast.as_ref()?))); + let found = casts.clone().find_map(|(i, clip, cast)| { + // The clip frame shown at `f` (see `Stage::clip_frame`), in cast seconds. + let time = + |f: f32| (f64::from(f) * stage.speed + 1e-9).floor().min(clip.last_frame as f64) / FPS as f64; + let mut replay = cast.replay(); + replay.advance(time(enter)); + let first = replay.find(needle)?; + // Text where it is relative to the target (the card moves with the target); + // text that would sit off the grid is out of the card's reach anyway. + let mut ink = HashSet::new(); + let mut seen = |at: Span, now: Vec| { + let moved = |from: usize, by: (usize, usize)| (from + by.0).checked_sub(by.1); + ink.extend(now.into_iter().filter_map(|span| { + let row = moved(span.row, (first.row, at.row))?; + let col = moved(span.col, (first.col, at.col))?; + Some(Span { row, col, ..span }) + })); + }; + seen(first, replay.ink()); + let mut track = vec![(enter, first)]; + for f in (enter as usize + 1)..exit as usize { + if !replay.advance(time(f as f32)) { + continue; + } + let at = replay.find(needle).unwrap_or(track[track.len() - 1].1); + seen(at, replay.ink()); + if at != track[track.len() - 1].1 { + track.push((f as f32, at)); + } + } + let track = track.into_iter().map(|(f, span)| (f, cells(span, cast))).collect(); + Some(pointing(Some(i), track, ink.into_iter().map(|span| cells(span, cast)).collect())) + }); + found.ok_or_else(|| match casts.count() { + 0 => format!("callout target text {needle:?} needs a .cast clip"), + _ => format!("callout target text {needle:?} is not on a cast clip's screen at {t}s"), + })? + } + }; + Ok(Callout { id, enter, exit, keys, lines, size, aim }) + } +} + +/// A key name as its cap reads, e.g. `"shift"` -> `"Shift"`, `"k"` -> `"K"`. +fn keycap(name: &str) -> Option { + let mut chars = name.trim().chars(); + let first = chars.next()?; + Some(first.to_uppercase().chain(chars).collect()) +} + +fn keys_width(keys: &[String]) -> f32 { + if keys.is_empty() { + return 0.; + } + let caps: f32 = keys.iter().map(|key| KEY.width(key) + 2. * KEY_PADDING).sum(); + caps + KEY_GAP * (keys.len() - 1) as f32 + 12. +} + +/// A run of cells as fractions of an agg frame: agg pads the grid by one cell on the left and +/// right and half a row above and below, so a frame is `cols + 2` cells by `rows + 1`. +fn cells(span: Span, cast: &Cast) -> Rect { + let (cw, ch) = (1. / (cast.cols + 2) as f32, 1. / (cast.rows + 1) as f32); + Rect::new((span.col + 1) as f32 * cw, (span.row as f32 + 0.5) * ch, span.len as f32 * cw, ch) +} + +/// `r`, in fractions of a frame, where the frame is drawn at `to`. +fn within(r: Rect, to: Rect) -> Rect { + Rect::new(to.x + r.x * to.w, to.y + r.y * to.h, r.w * to.w, r.h * to.h) +} + +fn lerp(a: Rect, b: Rect, t: f32) -> Rect { + let mix = |x: f32, y: f32| x + (y - x) * t; + Rect::new(mix(a.x, b.x), mix(a.y, b.y), mix(a.w, b.w), mix(a.h, b.h)) +} + +fn area(r: Rect) -> f32 { + r.w.max(0.) * r.h.max(0.) +} + +/// The box drawn around a target. +fn boxed(target: Rect) -> Rect { + Rect::new(target.x - 6., target.y - 2., target.w + 12., target.h + 4.) +} + +/// Where the target is at `f`: easing from where it was to where it moved. +fn glide(track: &[(f32, Rect)], f: f32) -> Rect { + let i = track.partition_point(|(at, _)| *at <= f).max(1) - 1; + let (at, rect) = track[i]; + match i.checked_sub(1) { + Some(previous) => lerp(track[previous].1, rect, cubic_out(((f - at) / GLIDE).clamp(0., 1.))), + None => rect, + } +} + +/// The card `gap` away from `target` on `side`, unclamped. +fn beside(side: Side, gap: f32, target: Rect, (w, h): (f32, f32)) -> Rect { + let (x, y) = match side { + Side::Right => (target.right() + gap, target.cy() - h / 2.), + Side::Left => (target.x - gap - w, target.cy() - h / 2.), + Side::Below => (target.x, target.bottom() + gap), + Side::Above => (target.x, target.y - gap - h), + }; + Rect::new(x, y, w, h) +} + +/// The card `gap` away from `target` on `side`, kept `EDGE` inside the screen. +fn place(side: Side, gap: f32, target: Rect, size: (f32, f32), (sw, sh): (f32, f32)) -> Rect { + let card = beside(side, gap, target, size); + let x = card.x.min(sw - EDGE - card.w).max(EDGE); + let y = card.y.min(sh - EDGE - card.h).max(EDGE); + Rect::new(x, y, card.w, card.h) +} + +/// Where the card goes: the side and distance from the target that keep it on screen and +/// clear of the target while covering, and leading across, the least `ink`. Covered text +/// weighs most, then crossed text, then leader length; ties go to the nearer card and to +/// the earlier of right, below, left, above. +fn placement(target: Rect, size: (f32, f32), ink: &[Rect], screen: (f32, f32)) -> (Side, f32) { + let safe = Rect::new(EDGE, EDGE, screen.0 - 2. * EDGE, screen.1 - 2. * EDGE); + let inked = |r: Rect| ink.iter().map(|ink| area(r.intersect(*ink))).sum::(); + let cost = |side: Side, gap: f32| { + let wanted = beside(side, gap, target, size); + let card = place(side, gap, target, size, screen); + let clear = area(card.intersect(target.outset(GAP / 2.))) <= 0.; + let crossed: f32 = leader(target, card, side) + .windows(2) + .map(|pair| { + let ((ax, ay), (bx, by)) = (pair[0], pair[1]); + inked(Rect::new(ax.min(bx), ay.min(by), (bx - ax).abs(), (by - ay).abs()).outset(2.)) + }) + .sum(); + let offscreen = area(wanted) - area(wanted.intersect(safe)); + (clear, offscreen + 4. * inked(card) + 10. * crossed + 8. * (gap - GAP)) + }; + let reach = screen.0.max(screen.1) / 2.; + [Side::Right, Side::Below, Side::Left, Side::Above] + .into_iter() + .flat_map(|side| { + (0..).map(|step| GAP + 24. * step as f32).take_while(|gap| *gap <= reach).map(move |gap| (side, gap)) + }) + .map(|(side, gap)| (side, gap, cost(side, gap))) + .min_by(|(_, _, a), (_, _, b)| b.0.cmp(&a.0).then(a.1.total_cmp(&b.1))) + .map(|(side, gap, _)| (side, gap)) + .expect("four sides") +} + +/// The leader from the box to the card: straight across the gap, or with one jog where the +/// card was pushed off the target's line by the screen edge. +fn leader(target: Rect, card: Rect, side: Side) -> Vec<(f32, f32)> { + let horizontal = matches!(side, Side::Right | Side::Left); + let (from, to, along) = match side { + Side::Right => (target.right(), card.x, target.cy()), + Side::Left => (target.x, card.right(), target.cy()), + Side::Below => (target.bottom(), card.y, target.cx()), + Side::Above => (target.y, card.bottom(), target.cx()), + }; + let (lo, hi) = if horizontal { (card.y + 14., card.bottom() - 14.) } else { (card.x + 20., card.right() - 20.) }; + let landing = along.clamp(lo, hi.max(lo)); + let point = |a: f32, b: f32| if horizontal { (a, b) } else { (b, a) }; + if (landing - along).abs() < 0.5 { + return vec![point(from, along), point(to, along)]; + } + let middle = (from + to) / 2.; + vec![point(from, along), point(middle, along), point(middle, landing), point(to, landing)] +} + +/// The first `fraction` of a polyline's length. +fn partial(points: &[(f32, f32)], fraction: f32) -> Vec<(f32, f32)> { + let length = |(ax, ay): (f32, f32), (bx, by): (f32, f32)| (bx - ax).hypot(by - ay); + let mut left = fraction * points.windows(2).map(|pair| length(pair[0], pair[1])).sum::(); + let mut drawn = vec![points[0]]; + for pair in points.windows(2) { + let (a, b) = (pair[0], pair[1]); + let segment = length(a, b); + if left >= segment { + drawn.push(b); + left -= segment; + } else { + let t = if segment > 0. { left / segment } else { 0. }; + drawn.push((a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)); + break; + } + } + drawn +} + +/// Progress through frames `a..b` of a stage, eased. +fn staged(x: f32, a: f32, b: f32) -> f32 { + interpolate_eased(x, &[a, b], &[0., 1.], cubic_out) +} + +impl Overlays<'_> { + /// Every callout showing at `frame`; `clips` are where the shown clips are on screen. + pub fn callouts<'a>(&self, callouts: &[Callout], clips: &[Rect], frame: usize) -> Svgr<'a> { + callouts.iter().map(|callout| self.callout(callout, clips, frame as f32)).collect() + } + + fn callout<'a>(&self, callout: &Callout, clips: &[Rect], f: f32) -> Svgr<'a> { + if f < callout.enter || f >= callout.exit { + return Svgr::empty(); + } + let (local, remain) = (f - callout.enter, callout.exit - 1. - f); + let (w, h) = callout.size; + let (clip, track, side, gap) = match &callout.aim { + Aim::Point(x, y) => { + let shown = staged(local, 0., 10.).min(staged(remain, 0., 8.)); + let rect = Rect::new(x - w / 2., y - h / 2., w, h); + return faded(shown, self.card(callout, rect, Similarity::scale_about(0.96 + 0.04 * shown, *x, *y))); + } + Aim::Target { clip, track, side, gap } => (clip, track, *side, *gap), + }; + let target = glide(track, f); + let target = boxed(clip.map_or(target, |i| within(target, clips[i]))); + let rect = place(side, gap, target, callout.size, self.size); + let accent = Rgba::hex(self.palette.accent); + + let drawn = staged(local, 0., 9.).min(staged(remain, 0., 7.)); + let radius = 6_f32.min(target.h / 2.); + let perimeter = 2. * (target.w + target.h) - (8. - 2. * std::f32::consts::PI) * radius; + let outline = svgr!( + + ); + + let extended = staged(local, 6., 14.).min(staged(remain, 5., 11.)); + let route = partial(&leader(target, rect, side), extended); + let points = route.iter().map(|(x, y)| format!("{x},{y}")).collect::>().join(" "); + let (dot_x, dot_y) = route[0]; + let line = faded( + 4. * extended, + svgr!( + + + + + ), + ); + + let slid = staged(local, 10., 20.).min(staged(remain, 9., 16.)); + let back = 10. * (1. - slid); + let (dx, dy) = match side { + Side::Right => (-back, 0.), + Side::Left => (back, 0.), + Side::Below => (0., -back), + Side::Above => (0., back), + }; + let card = faded(slid, self.card(callout, rect, Similarity::translate(dx, dy))); + svgr!({faded(drawn, outline)}{line}{card}) + } + + /// The card at `rect`, drawn on screen by `place`: keycaps, then the wrapped text. + fn card<'a>(&self, callout: &Callout, rect: Rect, place: Similarity) -> Svgr<'a> { + let palette = self.palette; + let text = Rgba::hex(palette.text); + let top = rect.y + PADDING.1; + let mut x = rect.x + ACCENT + PADDING.0; + let key_y = top + (LINE_HEIGHT - KEY_HEIGHT) / 2.; + let caps: Svgr = callout + .keys + .iter() + .map(|key| { + let cap = Rect::new(x, key_y, KEY.width(key) + 2. * KEY_PADDING, KEY_HEIGHT - 2.); + x = cap.right() + KEY_GAP; + let baseline = KEY.baseline(cap.y, cap.h); + svgr!( + + + + {KEY.draw(key.clone(), cap.cx(), baseline, text, Anchor::Middle)} + + ) + }) + .collect(); + let text_x = rect.x + ACCENT + PADDING.0 + keys_width(&callout.keys); + let lines: Svgr = callout + .lines + .iter() + .enumerate() + .map(|(row, line)| { + let baseline = TEXT.baseline(top + row as f32 * LINE_HEIGHT, LINE_HEIGHT); + TEXT.draw(line.clone(), text_x, baseline, text, Anchor::Start) + }) + .collect(); + let card = Card { + id: &format!("callout{}", callout.id), + rect, + radius: 10., + fill: Rgba::hex(palette.surface).a8(0xE6), + border: Rgba::hex(palette.border), + accent: Some((Edge::Left, ACCENT, Rgba::hex(palette.accent))), + shadows: &[Shadow::new(8., 32., Rgba(0, 0, 0, 0.35))], + frost: self.frost(12.), + to_screen: place, + screen: self.size, + }; + svgr!({card.draw(svgr!({caps}{lines}))}) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SCREEN: (f32, f32) = (1920., 1080.); + + #[test] + fn cards_go_where_they_cover_the_least_text_nearest_the_target() { + let target = Rect::new(100., 500., 200., 40.); + assert_eq!(placement(target, (300., 52.), &[], SCREEN), (Side::Right, GAP)); + // Text beside the target from y = 400 to 700: the nearest clear spot is just above it. + let text_right = Rect::new(320., 400., 1000., 300.); + assert_eq!(placement(target, (300., 52.), &[text_right], SCREEN), (Side::Above, GAP + 72.)); + // Near the right edge, a card on the right would be pushed back over the target. + let edge = Rect::new(1700., 500., 180., 40.); + assert_eq!(placement(edge, (300., 52.), &[], SCREEN), (Side::Left, GAP)); + } + + #[test] + fn leaders_run_straight_and_jog_only_when_the_card_is_pushed_off_line() { + let target = Rect::new(100., 500., 200., 40.); + let card = place(Side::Right, GAP, target, (300., 52.), SCREEN); + assert_eq!(leader(target, card, Side::Right), [(300., 520.), (340., 520.)]); + let low = Rect::new(100., 1040., 200., 30.); + let card = place(Side::Right, GAP, low, (300., 52.), SCREEN); + assert_eq!(card.bottom(), SCREEN.1 - EDGE); + let route = leader(low, card, Side::Right); + assert_eq!(route, [(300., 1055.), (320., 1055.), (320., card.bottom() - 14.), (340., card.bottom() - 14.)]); + assert_eq!(partial(&route, 0.), [(300., 1055.), (300., 1055.)]); + assert_eq!(partial(&route, 1.), route); + } + + #[test] + fn boxes_glide_to_where_their_text_moved() { + let (a, b) = (Rect::new(0., 0., 10., 10.), Rect::new(100., 0., 10., 10.)); + let track = [(0., a), (30., b)]; + assert_eq!(glide(&track, 29.), a); + assert!((glide(&track, 34.).x - 100. * cubic_out(0.5)).abs() < 1e-3); + assert_eq!(glide(&track, 38.), b); + } + + #[test] + fn agg_cells_sit_inside_its_padding() { + let cast = Cast::parse(r#"{"version":2,"width":100,"height":28}"#).unwrap(); + let rect = within(cells(Span { row: 2, col: 4, len: 3 }, &cast), Rect::new(0., 0., 102. * 9.6, 29. * 22.4)); + assert!((rect.x - 5. * 9.6).abs() < 1e-3 && (rect.y - 2.5 * 22.4).abs() < 1e-3); + assert!((rect.w - 3. * 9.6).abs() < 1e-3 && (rect.h - 22.4).abs() < 1e-3); + } +} diff --git a/plugins/droid-control/fframes/src/cast.rs b/plugins/droid-control/fframes/src/cast.rs new file mode 100644 index 0000000..8901c42 --- /dev/null +++ b/plugins/droid-control/fframes/src/cast.rs @@ -0,0 +1,169 @@ +//! asciinema recordings (v2 and v3): the terminal size and the output stream, replayed +//! through avt, the emulator agg renders with, so text on screen can be found by time. + +use avt::Vt; +use serde::Deserialize; +use serde_json::Value; +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone, PartialEq)] +pub struct Cast { + pub cols: usize, + pub rows: usize, + /// Terminal output at absolute seconds, in order. + output: Vec<(f64, String)>, +} + +/// A run of cells on one screen row. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Span { + pub row: usize, + pub col: usize, + pub len: usize, +} + +#[derive(Deserialize)] +struct Header { + version: u8, + width: Option, + height: Option, + term: Option, +} + +#[derive(Deserialize)] +struct Term { + cols: usize, + rows: usize, +} + +impl Cast { + pub fn read(path: &Path) -> Result { + let text = + fs::read_to_string(path).map_err(|err| format!("clip is not readable: {}: {err}", path.display()))?; + Cast::parse(&text).map_err(|err| format!("not an asciinema cast: {}: {err}", path.display())) + } + + pub(crate) fn parse(text: &str) -> Result { + let mut lines = text.lines().filter(|line| !line.trim().is_empty()); + let header: Header = serde_json::from_str(lines.next().ok_or("empty file")?).map_err(|err| err.to_string())?; + // v3 times are intervals since the previous event; v2 times are absolute. + let relative = match header.version { + 2 => false, + 3 => true, + version => return Err(format!("unsupported version {version}")), + }; + let (cols, rows) = match header.term { + Some(Term { cols, rows }) => (cols, rows), + None => (header.width.unwrap_or(120), header.height.unwrap_or(36)), + }; + let mut clock = 0.; + let mut output = Vec::new(); + for line in lines { + let event: (f64, String, Value) = serde_json::from_str(line).map_err(|err| format!("{err}: {line}"))?; + clock = if relative { clock + event.0 } else { event.0 }; + if let ("o", Value::String(data)) = (event.1.as_str(), event.2) { + output.push((clock, data)); + } + } + Ok(Cast { cols, rows, output }) + } + + pub fn replay(&self) -> Replay<'_> { + Replay { cast: self, vt: Vt::new(self.cols, self.rows), next: 0 } + } +} + +/// The screen of a cast as it plays, moved forward with [`Replay::advance`]. +pub struct Replay<'c> { + cast: &'c Cast, + vt: Vt, + next: usize, +} + +impl Replay<'_> { + /// Feeds the output up to `time` seconds; whether any was fed. + pub fn advance(&mut self, time: f64) -> bool { + let start = self.next; + while let Some((_, data)) = self.cast.output.get(self.next).filter(|(at, _)| *at <= time) { + self.vt.feed_str(data); + self.next += 1; + } + self.next > start + } + + /// The first occurrence of `text` on screen, top to bottom. + pub fn find(&self, text: &str) -> Option { + let width = text.chars().count(); + self.vt.view().enumerate().find_map(|(row, line)| { + let (chars, cols): (String, Vec) = visible(line).map(|(col, c, _)| (c, col)).unzip(); + let at = chars.find(text)?; + let first = chars[..at].chars().count(); + let last = line.cells()[cols[first + width - 1]].width().max(1) as usize; + Some(Span { row, col: cols[first], len: cols[first + width - 1] + last - cols[first] }) + }) + } + + /// The text on screen: runs of non-blank cells, joined across the one or two blank cells + /// between words. + pub fn ink(&self) -> Vec { + let mut runs: Vec = Vec::new(); + for (row, line) in self.vt.view().enumerate() { + for (col, c, width) in visible(line) { + if c.is_whitespace() { + continue; + } + match runs.last_mut() { + Some(run) if run.row == row && run.col + run.len + 2 >= col => run.len = col + width - run.col, + _ => runs.push(Span { row, col, len: width }), + } + } + } + runs + } +} + +/// The column, character and width of every cell that starts a character (the tail cell of a +/// wide character has width 0). +fn visible(line: &avt::Line) -> impl Iterator + '_ { + line.cells() + .iter() + .enumerate() + .filter(|(_, cell)| cell.width() > 0) + .map(|(col, cell)| (col, cell.char(), cell.width() as usize)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn v2_and_v3_casts_give_size_and_absolute_output_times() { + let v2 = Cast::parse( + "{\"version\":2,\"width\":20,\"height\":5}\n[0.5,\"o\",\"a\"]\n[0.7,\"i\",\"x\"]\n[1.5,\"o\",\"b\"]\n", + ) + .unwrap(); + let v3 = Cast::parse("{\"version\":3,\"term\":{\"cols\":20,\"rows\":5}}\n[0.5,\"o\",\"a\"]\n[0.2,\"m\",\"\"]\n[0.8,\"o\",\"b\"]\n").unwrap(); + assert_eq!(v2, v3); + assert_eq!((v2.cols, v2.rows), (20, 5)); + assert_eq!(v2.output, [(0.5, "a".to_owned()), (1.5, "b".to_owned())]); + assert!(Cast::parse("{\"version\":1}").is_err()); + } + + #[test] + fn replay_finds_text_by_cell_through_wide_characters() { + let cast = Cast::parse( + "{\"version\":2,\"width\":20,\"height\":3}\n[1,\"o\",\"> \\u001b[1mok\\u001b[0m\\r\\n\"]\n[2,\"o\",\"漢字 mode: auto\"]\n", + ) + .unwrap(); + let mut replay = cast.replay(); + assert!(!replay.advance(0.5)); + assert_eq!(replay.find("ok"), None); + assert!(replay.advance(1.)); + assert_eq!(replay.find("ok"), Some(Span { row: 0, col: 2, len: 2 })); + assert!(replay.advance(5.)); + assert_eq!(replay.find("字 mode"), Some(Span { row: 1, col: 2, len: 7 })); + assert_eq!(replay.find("auto"), Some(Span { row: 1, col: 11, len: 4 })); + assert_eq!(replay.ink(), [Span { row: 0, col: 0, len: 4 }, Span { row: 1, col: 0, len: 15 }]); + } +} diff --git a/plugins/droid-control/fframes/src/clips.rs b/plugins/droid-control/fframes/src/clips.rs index 122edf7..04a57d4 100644 --- a/plugins/droid-control/fframes/src/clips.rs +++ b/plugins/droid-control/fframes/src/clips.rs @@ -1,6 +1,8 @@ //! Clip sources, probed once with ffprobe: the longest one sets the content length and //! every clip holds its own last frame once it ends. +use crate::cast::Cast; +use crate::stage::Staged; use crate::timing::FPS; use serde::Deserialize; use std::collections::HashSet; @@ -16,15 +18,27 @@ pub struct Clip { pub duration: f64, /// Output-frame offset of the last frame; playback holds it after the clip ends. pub last_frame: usize, + /// Frame size in pixels. + pub size: (f32, f32), + /// The recording a cast clip was rendered from. + pub cast: Option, } #[derive(Deserialize)] struct Probe { format: ProbeFormat, #[serde(default)] + streams: Vec, + #[serde(default)] packets: Vec, } +#[derive(Deserialize)] +struct ProbeStream { + width: u32, + height: u32, +} + #[derive(Deserialize)] struct ProbeFormat { duration: Option, @@ -36,12 +50,12 @@ struct ProbePacket { } impl Clip { - pub fn probe_all(paths: &[PathBuf]) -> Result, String> { + pub fn probe_all(staged: Vec) -> Result, String> { let mut names = HashSet::new(); - paths - .iter() - .map(|path| { - let clip = Clip::probe(path)?; + staged + .into_iter() + .map(|Staged { path, cast }| { + let clip = Clip { cast, ..Clip::probe(&path)? }; if !names.insert(clip.name.clone()) { return Err(format!( "clips must have distinct file names, {} repeats one (stage them as clip-.)", @@ -61,7 +75,7 @@ impl Clip { "-select_streams", "v:0", "-show_entries", - "format=duration:packet=pts_time", + "format=duration:stream=width,height:packet=pts_time", "-of", "json", ]) @@ -90,6 +104,7 @@ impl Clip { .filter_map(|packet| seconds(&packet.pts_time)) .fold(None, |max: Option, pts| Some(max.map_or(pts, |m| m.max(pts)))) .ok_or_else(|| format!("clip has no video frames: {}", path.display()))?; + let stream = probe.streams.first().ok_or_else(|| format!("clip has no video stream: {}", path.display()))?; let name = path .file_name() .and_then(|name| name.to_str()) @@ -102,6 +117,8 @@ impl Clip { // The decoder shows the first frame at or after the requested time, so the last // frame's own timestamp (not the container end) is the latest offset to ask for. last_frame: (last_pts * FPS as f64 + 1e-6).floor().max(0.) as usize, + size: (stream.width as f32, stream.height as f32), + cast: None, }) } } @@ -122,23 +139,35 @@ mod tests { #[test] fn last_frame_comes_from_the_last_presented_packet() { let clip = probe( - r#"{"format":{"duration":"5.000000"},"packets":[{"pts_time":"0.000000"},{"pts_time":"4.966667"},{"pts_time":"4.933333"}]}"#, + r#"{"format":{"duration":"5.000000"},"streams":[{"width":978,"height":650}],"packets":[{"pts_time":"0.000000"},{"pts_time":"4.966667"},{"pts_time":"4.933333"}]}"#, ) .unwrap(); assert_eq!(clip.name, "clip-0.mp4"); assert_eq!(clip.duration, 5.); assert_eq!(clip.last_frame, 149); + assert_eq!(clip.size, (978., 650.)); } #[test] fn unreadable_probes_are_errors() { - assert!(probe(r#"{"format":{"duration":"N/A"},"packets":[{"pts_time":"0"}]}"#).is_err()); - assert!(probe(r#"{"format":{"duration":"1.0"},"packets":[]}"#).is_err()); + let stream = r#""streams":[{"width":2,"height":2}]"#; + assert!( + probe(&format!(r#"{{"format":{{"duration":"N/A"}},{stream},"packets":[{{"pts_time":"0"}}]}}"#)).is_err() + ); + assert!(probe(&format!(r#"{{"format":{{"duration":"1.0"}},{stream},"packets":[]}}"#)).is_err()); + assert!(probe(r#"{"format":{"duration":"1.0"},"packets":[{"pts_time":"0"}]}"#).is_err()); } #[test] fn longest_rounds_to_centiseconds() { - let clip = |duration| Clip { name: String::new(), path: PathBuf::new(), duration, last_frame: 0 }; + let clip = |duration| Clip { + name: String::new(), + path: PathBuf::new(), + duration, + last_frame: 0, + size: (2., 2.), + cast: None, + }; assert_eq!(longest(&[clip(1.234), clip(5.006)]), Some(5.01)); assert_eq!(longest(&[]), None); } diff --git a/plugins/droid-control/fframes/src/content.rs b/plugins/droid-control/fframes/src/content.rs index 051086e..6dd7964 100644 --- a/plugins/droid-control/fframes/src/content.rs +++ b/plugins/droid-control/fframes/src/content.rs @@ -60,6 +60,24 @@ impl Stage<'_> { /// Composites the windows with their clips at `frame` on the clip clock, inside every /// zoom, and the spotlights over them onto `canvas`. pub fn compose(&self, canvas: &mut Pixels, frame: usize, video: &Frame, ctx: &FFramesContext<'_, '_>) { + for (window, clip) in self.windows(frame) { + self.window(canvas, &window, clip, frame, video, ctx); + } + canvas.draw(self.spotlights(frame)); + } + + /// Where each shown clip's whole frame lands on screen at `frame`, in clip order. + pub fn clip_rects(&self, frame: usize) -> Vec { + self.windows(frame) + .into_iter() + .map(|(window, clip)| { + window.to_screen.apply(fitted(clip.size, self.content_box(window.inner), self.object_fit)) + }) + .collect() + } + + /// The windows at `frame`, inside every zoom, with the clips they show. + fn windows(&self, frame: usize) -> Vec<(Window, &Clip)> { let (w, h) = self.size; let m = self.config.margin; let f = frame as f32; @@ -72,16 +90,19 @@ impl Stage<'_> { _ => zoom, }); match self.layout { - Layout::Single => { - if let Some(clip) = self.clips.first() { + Layout::Single => self + .clips + .first() + .map(|clip| { let inner = Rect::new(m, m, w - 2. * m, h - 2. * m); let progress = entrance(0.); let place = Similarity::scale_about(0.92 + 0.08 * progress, inner.cx(), inner.cy()); - let title = (!self.window_title.is_empty()).then_some(self.window_title); - let window = Window { id: "window0", inner, title, to_screen: place.then(zoom), opacity: progress }; - self.window(canvas, &window, clip, frame, video, ctx); - } - } + let title = (!self.window_title.is_empty()).then(|| self.window_title.to_owned()); + let id = "window0".to_owned(); + (Window { id, inner, title, to_screen: place.then(zoom), opacity: progress }, clip) + }) + .into_iter() + .collect(), Layout::SideBySide => { let shown = &self.clips[..self.clips.len().min(2)]; let panel_w = ((w - 2. * m - SIDE_BY_SIDE_GAP) / 2.).floor(); @@ -89,28 +110,29 @@ impl Stage<'_> { // Panels are content-box: the 1px border sits outside the panel size. let row = shown.len() as f32 * (panel_w + 2.) + (shown.len().max(1) - 1) as f32 * SIDE_BY_SIDE_GAP; let left = (w - row) / 2.; - for (i, clip) in shown.iter().enumerate() { - let inner = Rect::new( - left + i as f32 * (panel_w + 2. + SIDE_BY_SIDE_GAP) + 1., - (h - panel_h) / 2., - panel_w, - panel_h, - ); - let progress = entrance(i as f32 * 6.); - let place = Similarity::translate(0., 12. * (1. - progress)).then(Similarity::scale_about( - 0.92 + 0.08 * progress, - inner.cx(), - inner.cy(), - )); - let label = self.labels.get(i).cloned().unwrap_or_else(|| format!("Clip {}", i + 1)); - let id = format!("window{i}"); - let window = - Window { id: &id, inner, title: Some(&label), to_screen: place.then(zoom), opacity: progress }; - self.window(canvas, &window, clip, frame, video, ctx); - } + shown + .iter() + .enumerate() + .map(|(i, clip)| { + let inner = Rect::new( + left + i as f32 * (panel_w + 2. + SIDE_BY_SIDE_GAP) + 1., + (h - panel_h) / 2., + panel_w, + panel_h, + ); + let progress = entrance(i as f32 * 6.); + let place = Similarity::translate(0., 12. * (1. - progress)).then(Similarity::scale_about( + 0.92 + 0.08 * progress, + inner.cx(), + inner.cy(), + )); + let label = self.labels.get(i).cloned().unwrap_or_else(|| format!("Clip {}", i + 1)); + let id = format!("window{i}"); + (Window { id, inner, title: Some(label), to_screen: place.then(zoom), opacity: progress }, clip) + }) + .collect() } } - canvas.draw(self.spotlights(frame)); } /// Dimmed frame with a rounded cutout around each active spotlight region. @@ -149,13 +171,14 @@ impl Stage<'_> { /// keeps its image, so a screen recording holding still is resampled once. fn clip_frame( &self, - window: &Window<'_>, + window: &Window, clip: &Clip, frame: usize, video: &Frame, ctx: &FFramesContext<'_, '_>, ) -> Option { - let &Window { id, inner, to_screen, .. } = window; + let Window { id, inner, to_screen, .. } = window; + let (inner, to_screen) = (*inner, *to_screen); let mut at = video.clone(); at.index = ((frame as f64 * self.speed + 1e-9).floor() as usize).min(clip.last_frame); let input = SyncVideoFrameInput { start_from: 0., looping: false, editor_fallback_image: None }; @@ -225,13 +248,14 @@ impl Stage<'_> { fn window( &self, canvas: &mut Pixels, - window: &Window<'_>, + window: &Window, clip: &Clip, frame: usize, video: &Frame, ctx: &FFramesContext<'_, '_>, ) { - let &Window { id, inner, title, to_screen, opacity } = window; + let Window { id, inner, title, to_screen, opacity } = window; + let (inner, to_screen, opacity) = (*inner, *to_screen, *opacity); if opacity <= 0. { return; } @@ -265,10 +289,10 @@ impl Stage<'_> { } }) .collect(); - let label = title.map_or_else(Svgr::empty, |title| { + let label = title.as_ref().map_or_else(Svgr::empty, |title| { let style = TextStyle::new(Font::sans(400), 13.); style.draw( - title.to_owned(), + title.clone(), inner.cx(), style.baseline(inner.y, BAR_HEIGHT), Rgba::hex(palette.muted), @@ -311,10 +335,10 @@ impl Stage<'_> { /// A window as laid out at a frame: its content rect (the 1px border lies outside it), bar /// title, where it is drawn on screen and how faded in it is. -struct Window<'s> { - id: &'s str, +struct Window { + id: String, inner: Rect, - title: Option<&'s str>, + title: Option, to_screen: Similarity, opacity: f32, } diff --git a/plugins/droid-control/fframes/src/lib.rs b/plugins/droid-control/fframes/src/lib.rs index 5626ec4..d57171f 100644 --- a/plugins/droid-control/fframes/src/lib.rs +++ b/plugins/droid-control/fframes/src/lib.rs @@ -4,6 +4,7 @@ //! overlays (keystrokes, sections, zooms, spotlights, callouts, code), and the Droid outro. //! [`Showcase`] draws any frame as SVG; [`render`] rasterises stills and whole videos. +pub mod cast; pub mod clips; pub mod code; pub mod fonts; @@ -15,6 +16,7 @@ pub mod svg; pub mod theme; pub mod timing; +mod callout; mod composite; mod content; mod outro; diff --git a/plugins/droid-control/fframes/src/main.rs b/plugins/droid-control/fframes/src/main.rs index d865d92..60bfac9 100644 --- a/plugins/droid-control/fframes/src/main.rs +++ b/plugins/droid-control/fframes/src/main.rs @@ -88,7 +88,7 @@ fn run(cli: &Cli, work_dir: &Path) -> Result<(), String> { let encoding = props.fidelity.encoding(); let staged = stage::stage(&cli.clips, work_dir, &encoding)?; - let clips = Clip::probe_all(&staged)?; + let clips = Clip::probe_all(staged)?; let showcase = Showcase::new(props, clips)?; let props = showcase.props(); diff --git a/plugins/droid-control/fframes/src/overlays.rs b/plugins/droid-control/fframes/src/overlays.rs index fadb8e4..033ab78 100644 --- a/plugins/droid-control/fframes/src/overlays.rs +++ b/plugins/droid-control/fframes/src/overlays.rs @@ -1,19 +1,19 @@ -//! Timed overlays on the clip clock: callouts, section sweeps and headers, keystrokes and -//! code annotations. Every panel frosts the scene behind it. +//! Timed overlays on the clip clock: section sweeps and headers, keystrokes and code +//! annotations (callouts are in `callout`). Every panel frosts the scene behind it. use crate::code::highlight; use crate::fonts::Font; use crate::motion::{Bezier, interpolate, interpolate_eased}; -use crate::props::{CodeAnnotation, CodePosition, Effect, Keystroke, Section}; +use crate::props::{CodeAnnotation, CodePosition, Keystroke, Section}; use crate::raster::Backdrop; use crate::svg::{Anchor, Card, Edge, Frost, Rect, Rgba, Shadow, Similarity, TextStyle, blur, faded, stops}; use crate::theme::{Palette, PresetConfig}; use fframes::{Svgr, svgr}; -/// Overshooting pop-in shared by callouts and keystrokes. +/// Overshooting pop-in for keystrokes. const POP: Bezier = Bezier(0.34, 1.56, 0.64, 1.); -fn frame_of(seconds: f32) -> f32 { +pub fn frame_of(seconds: f32) -> f32 { (seconds * 30.).round() } @@ -26,69 +26,10 @@ pub struct Overlays<'s> { } impl<'s> Overlays<'s> { - fn frost(&self, sigma: f32) -> Option> { + pub fn frost(&self, sigma: f32) -> Option> { Some(Frost { backdrop: self.backdrop, sigma }) } - /// Text pills centred on percentage positions. - pub fn callouts<'a>(&self, effects: &[Effect], frame: usize) -> Svgr<'a> { - let (w, h) = self.size; - let f = frame as f32; - let style = TextStyle::new(Font::sans(500), 24.); - let line_height = 24. * 1.4; - effects - .iter() - .enumerate() - .filter_map(|(i, effect)| { - let Effect::Callout { t, dur, text, at } = effect else { return None }; - let (enter, exit) = (frame_of(*t), frame_of(t + dur)); - if f < enter || f >= exit { - return None; - } - let local = f - enter; - let pop = interpolate_eased(local, &[0., 7.5], &[0., 1.], POP.f()); - let fade = interpolate(local, &[exit - enter - 6., exit - enter], &[1., 0.]); - - // Shrink-to-fit next to `left`, capped at 40% of the width. - let (left, top) = (at.x.0 * w, at.y.0 * h); - let (border_x, padding_x) = (1. + 3., 48.); - let max_content = style.width(text); - let min_content = text.split_whitespace().map(|word| style.width(word)).fold(0., f32::max); - let available = w - left - padding_x - border_x; - let content_w = max_content.min(min_content.max(available)).min(0.4 * w); - let lines = style.font.wrap(text, style.size, 0., content_w); - let rect_w = content_w + padding_x + border_x; - let rect_h = lines.len() as f32 * line_height + 24. + 2.; - let rect = Rect::new(left - rect_w / 2., top - rect_h / 2., rect_w, rect_h); - let place = Similarity::scale_about(0.85 + 0.15 * pop, left, top); - - let text_x = rect.x + 3. + 24. + content_w / 2.; - let fill = Rgba::hex(self.palette.text); - let text: Svgr = lines - .into_iter() - .enumerate() - .map(|(row, line)| { - let baseline = style.baseline(rect.y + 1. + 12. + row as f32 * line_height, line_height); - style.draw(line, text_x, baseline, fill, Anchor::Middle) - }) - .collect(); - let card = Card { - id: &format!("callout{i}"), - rect, - radius: 12., - fill: Rgba::hex(self.palette.surface).a8(0xE6), - border: Rgba::hex(self.palette.border), - accent: Some((Edge::Left, 3., Rgba::hex(self.palette.accent))), - shadows: &[Shadow::new(8., 32., Rgba(0, 0, 0, 0.35))], - frost: self.frost(12.), - to_screen: place, - screen: self.size, - }; - Some(faded(pop.min(fade), svgr!({card.draw(text)}))) - }) - .collect() - } - /// A frosted band sweeping across the frame at every section boundary after the first. pub fn section_sweeps<'a>(&self, sections: &[Section], frame: usize) -> Svgr<'a> { let (w, h) = self.size; diff --git a/plugins/droid-control/fframes/src/props.rs b/plugins/droid-control/fframes/src/props.rs index 336fce8..28c67cd 100644 --- a/plugins/droid-control/fframes/src/props.rs +++ b/plugins/droid-control/fframes/src/props.rs @@ -98,6 +98,18 @@ pub struct Point { pub y: Pct, } +/// What a callout points at. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum Target { + /// Boxed, with the card beside it. + Region(Region), + /// Text on a terminal clip's screen, boxed by its cells and followed as the screen changes. + Text { text: String }, + /// The card centred on it. + Point(Point), +} + #[derive(Debug, Clone, Deserialize)] #[serde(tag = "fx", rename_all = "kebab-case")] pub enum Effect { @@ -125,7 +137,9 @@ pub enum Effect { t: f32, dur: f32, text: String, - at: Point, + at: Target, + /// A shortcut shown as keycaps before the text, e.g. `"shift+tab"`. + key: Option, }, } @@ -264,6 +278,12 @@ impl ShowcaseProps { self.width, self.height )); } + let blank_target = self.effects.iter().any( + |effect| matches!(effect, Effect::Callout { at: Target::Text { text }, .. } if text.trim().is_empty()), + ); + if blank_target { + return Err("invalid props: callout target text must not be blank".to_owned()); + } let ranges = self.code_annotations.iter().flat_map(|a| a.highlight.iter().chain(&a.focus)); if ranges.clone().any(|range| range.start == 0 || range.end == 0) { return Err("invalid props: code line ranges are 1-based (start and end must be positive)".to_owned()); @@ -337,6 +357,28 @@ mod tests { assert_eq!(props.effects[2].unrendered_name(), Some("fade-out")); } + #[test] + fn callouts_point_at_a_region_text_or_point() { + let props = parse( + r#"{"effects":[ + {"fx":"callout","t":1,"dur":2,"text":"a","at":{"x":"10%","y":"20%","w":"5%","h":"5%"}}, + {"fx":"callout","t":1,"dur":2,"text":"b","at":{"text":"Auto (High)"},"key":"shift+tab"}, + {"fx":"callout","t":1,"dur":2,"text":"c","at":{"x":"50%","y":"50%"}} + ]}"#, + ); + let targets: Vec<_> = props + .effects + .iter() + .map(|effect| match effect { + Effect::Callout { at, key, .. } => (at, key.as_deref()), + _ => panic!("callout"), + }) + .collect(); + assert!(matches!(targets[0], (Target::Region(Region { w: Pct(0.05), .. }), None))); + assert!(matches!(targets[1], (Target::Text { text }, Some("shift+tab")) if text == "Auto (High)")); + assert!(matches!(targets[2], (Target::Point(Point { x: Pct(0.5), .. }), None))); + } + #[test] fn code_annotations_take_schema_defaults() { let props = parse(r#"{"codeAnnotations":[{"t":0,"dur":1,"code":"x"}]}"#); @@ -359,6 +401,8 @@ mod tests { r#"{"fidelity":"ultra"}"#, r#"{"effects":[{"fx":"zoom","t":0,"dur":1,"to":{"x":"left","y":"0%","w":"1%","h":"1%"}}]}"#, r#"{"codeAnnotations":[{"t":0,"dur":1,"code":"x","highlight":[{"start":0,"end":1}]}]}"#, + r#"{"effects":[{"fx":"callout","t":0,"dur":1,"text":"x","at":{"text":" "}}]}"#, + r#"{"effects":[{"fx":"callout","t":0,"dur":1,"text":"x","at":{"y":"1%"}}]}"#, ] { assert!(ShowcaseProps::from_json(json, None).is_err(), "{json} must be rejected"); } diff --git a/plugins/droid-control/fframes/src/showcase.rs b/plugins/droid-control/fframes/src/showcase.rs index 26c0bcd..ff68e9f 100644 --- a/plugins/droid-control/fframes/src/showcase.rs +++ b/plugins/droid-control/fframes/src/showcase.rs @@ -2,6 +2,7 @@ //! with its overlays and the Droid outro (joined by two transitions), then the watermark, //! and the film grain and colour grade. Each frame is composited in layers (see `raster`). +use crate::callout::Callout; use crate::clips::{self, Clip}; use crate::composite::Pixels; use crate::content::Stage; @@ -27,6 +28,7 @@ pub struct Showcase { scenery: Scenery, title: TitleLayout, code_cards: Vec, + callouts: Vec, } impl Showcase { @@ -43,6 +45,18 @@ impl Showcase { let size = (props.width as f32, props.height as f32); let scenery = Scenery::new(palette, config, props.fidelity.treatment(), size)?; let code_cards = props.code_annotations.iter().map(|a| CodeCard::new(a, palette, config, size)).collect(); + let stage = stage(&props, &clips, palette, config); + let callouts = props + .effects + .iter() + .enumerate() + .filter_map(|(i, effect)| match effect { + Effect::Callout { t, dur, text, at, key } => { + Some(Callout::new(i, *t, *dur, text, at, key.as_deref(), &stage)) + } + _ => None, + }) + .collect::>()?; Ok(Self { timeline: Timeline { content: timing::content_frames(clips::longest(&clips), props.speed) }, title: TitleLayout::new(&props.title, &props.subtitle, size.0), @@ -53,6 +67,7 @@ impl Showcase { size, scenery, code_cards, + callouts, }) } @@ -142,18 +157,7 @@ impl Showcase { return scene; }; let props = &self.props; - let stage = Stage { - palette: self.palette, - config: self.config, - size: self.size, - layout: props.layout, - clips: &self.clips, - labels: &props.labels, - window_title: &props.window_title, - speed: props.speed, - object_fit: props.object_fit, - effects: &props.effects, - }; + let stage = stage(props, &self.clips, self.palette, self.config); stage.compose(&mut scene, clock, frame, ctx); let backdrop = Backdrop::new(scene); let overlays = Overlays { palette: self.palette, config: self.config, size: self.size, backdrop: &backdrop }; @@ -161,7 +165,7 @@ impl Showcase { if props.sections.len() > 1 { overlays.section_sweeps(&props.sections, clock) } else { Svgr::empty() }; let drawn = svgr!( - {overlays.callouts(&props.effects, clock)} + {overlays.callouts(&self.callouts, &stage.clip_rects(clock), clock)} {sweeps} {overlays.section_headers(&props.sections, clock, sequence - TRANSITION_FRAMES)} {overlays.keystrokes(&props.keys, clock)} @@ -174,6 +178,21 @@ impl Showcase { } } +fn stage<'s>(props: &'s ShowcaseProps, clips: &'s [Clip], palette: &'s Palette, config: &'s PresetConfig) -> Stage<'s> { + Stage { + palette, + config, + size: (props.width as f32, props.height as f32), + layout: props.layout, + clips, + labels: &props.labels, + window_title: &props.window_title, + speed: props.speed, + object_fit: props.object_fit, + effects: &props.effects, + } +} + impl Video for Showcase { const FPS: usize = timing::FPS; // Nominal: `render` rasterises every frame at the props size, whatever these say. diff --git a/plugins/droid-control/fframes/src/stage.rs b/plugins/droid-control/fframes/src/stage.rs index 36cee8d..ce57a87 100644 --- a/plugins/droid-control/fframes/src/stage.rs +++ b/plugins/droid-control/fframes/src/stage.rs @@ -2,10 +2,9 @@ //! directory (fframes keys decoders by file name), and asciinema casts become MP4s on their //! own timeline. +use crate::cast::Cast; use crate::theme::Encoding; -use serde::Deserialize; use std::fs; -use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -30,16 +29,24 @@ pub fn kind(clip: &Path) -> Result { } } -/// Links (or converts) every clip into `work_dir` and returns the staged paths in order. -pub fn stage(clips: &[PathBuf], work_dir: &Path, encoding: &Encoding) -> Result, String> { +/// A clip as the composition reads it: the staged video, and the recording a cast was +/// rendered from. +pub struct Staged { + pub path: PathBuf, + pub cast: Option, +} + +/// Links (or converts) every clip into `work_dir`, in order. +pub fn stage(clips: &[PathBuf], work_dir: &Path, encoding: &Encoding) -> Result, String> { clips .iter() .enumerate() .map(|(index, clip)| match kind(clip)? { Kind::Cast => { - let staged = work_dir.join(format!("clip-{index}.mp4")); - convert_cast(clip, &staged, encoding)?; - Ok(staged) + let path = work_dir.join(format!("clip-{index}.mp4")); + let cast = Cast::read(clip)?; + convert_cast(clip, &cast, &path, encoding)?; + Ok(Staged { path, cast: Some(cast) }) } Kind::Video => { let ext = clip.extension().and_then(|ext| ext.to_str()).unwrap_or_default(); @@ -48,28 +55,12 @@ pub fn stage(clips: &[PathBuf], work_dir: &Path, encoding: &Encoding) -> Result< fs::canonicalize(clip).map_err(|err| format!("clip is not readable: {}: {err}", clip.display()))?; std::os::unix::fs::symlink(&source, &staged) .map_err(|err| format!("could not stage {}: {err}", clip.display()))?; - Ok(staged) + Ok(Staged { path: staged, cast: None }) } }) .collect() } -#[derive(Deserialize)] -struct CastHeader { - #[serde(default = "default_cols")] - width: u32, - #[serde(default = "default_rows")] - height: u32, -} - -fn default_cols() -> u32 { - 120 -} - -fn default_rows() -> u32 { - 36 -} - fn run(command: &mut Command, what: &str) -> Result<(), String> { let output = command.output().map_err(|err| format!("could not run {what}: {err}"))?; if output.status.success() { @@ -82,19 +73,14 @@ fn run(command: &mut Command, what: &str) -> Result<(), String> { /// `speed` to every clip alike), then ffmpeg encodes the GIF losslessly: the composition /// decodes it again, so it loses nothing to a second lossy encode, and the fastest preset /// costs only file size. -fn convert_cast(cast: &Path, output: &Path, encoding: &Encoding) -> Result<(), String> { - let file = fs::File::open(cast).map_err(|err| format!("clip is not readable: {}: {err}", cast.display()))?; - let mut header = String::new(); - BufReader::new(file).read_line(&mut header).map_err(|err| format!("could not read {}: {err}", cast.display()))?; - let header: CastHeader = - serde_json::from_str(&header).map_err(|err| format!("not an asciinema cast: {}: {err}", cast.display()))?; +fn convert_cast(clip: &Path, cast: &Cast, output: &Path, encoding: &Encoding) -> Result<(), String> { let gif = output.with_extension("gif"); run( Command::new("agg") .args(["--speed", "1", "--idle-time-limit", "1000000000", "--renderer", "fontdue"]) - .args(["--cols", &header.width.to_string(), "--rows", &header.height.to_string()]) + .args(["--cols", &cast.cols.to_string(), "--rows", &cast.rows.to_string()]) .args(["--fps-cap", &encoding.cast_fps_cap.to_string(), "--theme", DROID_CLI_THEME]) - .arg(cast) + .arg(clip) .arg(&gif), "agg", )?; diff --git a/plugins/droid-control/skills/droid-control/atoms/compose/ATOM.md b/plugins/droid-control/skills/droid-control/atoms/compose/ATOM.md index 8566978..21ae48f 100644 --- a/plugins/droid-control/skills/droid-control/atoms/compose/ATOM.md +++ b/plugins/droid-control/skills/droid-control/atoms/compose/ATOM.md @@ -244,10 +244,28 @@ Use a run-scoped props path like `$PROPS`; do not reuse a global `/tmp/showcase- | `fade-out` | `t`, `dur` | Fade to black (schema-valid but not yet rendered by Showcase — emits a render-time warning) | | `zoom` | `t`, `dur`, `to: {x,y,w,h}` | Directed zoom to a target region (30% in, 40% hold, 30% out) | | `spotlight` | `t`, `dur`, `on: {x,y,w,h}`, `dim?` | Dim everything except a region (`dim`: 0–1, default 0.6) | -| `callout` | `t`, `dur`, `text`, `at: {x,y}` | Text overlay anchored to a point | +| `callout` | `t`, `dur`, `text`, `at`, `key?` | Card pointing at its target, or centred on a point (see below) | Regions use percentage strings (e.g., `"25%"`) relative to the video dimensions. +#### Callout targets + +`at` takes one of three shapes: + +| `at` | Draws | +|---|---| +| `{ "text": "Auto (High)" }` | A box around that text on a `.cast` clip's screen, with a leader to the card. The box sits on the text's terminal cells and follows the text while the callout shows (scrolling, redraws). | +| `{ "x", "y", "w", "h" }` | A box around a region of the video (any clip type), with a leader to the card. | +| `{ "x", "y" }` | The card alone, centred on the point. | + +`key` (optional) shows a shortcut as keycaps before the text: `"shift+tab"` draws `Shift` and `Tab` caps. + +```json +{ "fx": "callout", "t": 3.7, "dur": 2.4, "text": "Cycle the autonomy level", "key": "shift+tab", "at": { "text": "Auto (High)" } } +``` + +Prefer text targets for terminal clips. Measured coordinates drift, but the cells don't. The composition places the card itself: it picks the side and distance where the card and leader cover the least text while the callout shows. Matching is case-sensitive and takes the first occurrence on screen, top to bottom, so pick text that's unique on screen at `t`. The render fails if no shown `.cast` clip has that text on screen at `t`. Write `t` in output seconds, like every other overlay time. + ### When to use effects | Effect | Use when… | Don't use when… | From dcd2e0ce57bd0dbfb276dbde827bd88a5476d57a Mon Sep 17 00:00:00 2001 From: "Factory: factory-dev" <138933559+factory-droid[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:55:57 +0000 Subject: [PATCH 2/5] fix(droid-control): clamp resample crops to the source A crop worked out in f32 can overrun the source image by a rounding error, which made fast_image_resize panic. A fractionally scaled card's shadow hit it. Co-authored-by: factory-ain3sh --- plugins/droid-control/fframes/src/composite.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/droid-control/fframes/src/composite.rs b/plugins/droid-control/fframes/src/composite.rs index 1e34c4b..fb8daca 100644 --- a/plugins/droid-control/fframes/src/composite.rs +++ b/plugins/droid-control/fframes/src/composite.rs @@ -156,9 +156,12 @@ pub fn resample(data: &[u8], size: (u32, u32), crop: Rect, (width, height): (u32 let source = TypedImageRef::::from_buffer(size.0, size.1, data).expect("images hold their size in pixels"); let mut resampled = vec![0; width as usize * height as usize * 4]; let mut target = TypedImage::::from_buffer(width, height, &mut resampled).expect("sized for the target"); + // A crop worked out in f32 can overrun the source by a rounding error. + let (x, y) = (f64::from(crop.x), f64::from(crop.y)); + let (w, h) = (f64::from(crop.w).min(f64::from(size.0) - x), f64::from(crop.h).min(f64::from(size.1) - y)); let options = ResizeOptions::new() .resize_alg(ResizeAlg::Convolution(FilterType::Mitchell)) - .crop(f64::from(crop.x), f64::from(crop.y), f64::from(crop.w), f64::from(crop.h)) + .crop(x, y, w, h) // Premultiplied already. .use_alpha(false); RESIZER From 59967ff23d41b62d63b0ffc1b1502fd0add46448 Mon Sep 17 00:00:00 2001 From: "Factory: factory-dev" <138933559+factory-droid[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:55:57 +0000 Subject: [PATCH 3/5] feat(droid-control): draw callouts as the droids-view pointer Port the pointer element from the droids-view video: the frame dims to 50% around an accent box on the target, a straight accent line grows from the card to the box with a dot at each end, and the card is a solid panel under a 2px accent bar with a SHORTCUT tag over Geist Light text. Keycaps sit on a slab and press once, lit in the accent, as the card settles. Placement and text tracking are unchanged. The leader is one straight line, slanting only where the screen edge pushes the card off line, and cards above or below a narrow target shift so it drops straight. Co-authored-by: factory-ain3sh --- plugins/droid-control/fframes/src/callout.rs | 299 ++++++++++-------- plugins/droid-control/fframes/src/motion.rs | 10 + plugins/droid-control/fframes/src/overlays.rs | 2 +- plugins/droid-control/fframes/src/svg.rs | 4 + .../droid-control/atoms/compose/ATOM.md | 8 +- 5 files changed, 181 insertions(+), 142 deletions(-) diff --git a/plugins/droid-control/fframes/src/callout.rs b/plugins/droid-control/fframes/src/callout.rs index 0bf238d..7b54e3c 100644 --- a/plugins/droid-control/fframes/src/callout.rs +++ b/plugins/droid-control/fframes/src/callout.rs @@ -1,32 +1,43 @@ //! Callouts: a card of text, with optional keycaps, pointing at a frame region or at text on a -//! terminal clip's screen, or centred on a point. A pointing callout stages in (its box draws -//! around the target, the leader extends, the card slides in) and leaves in reverse. +//! terminal clip's screen, or centred on a point. A pointing callout dims the frame around an +//! orange box on its target and grows a line from the card to the box; keycaps press once as +//! the card settles. use crate::cast::{Cast, Span}; use crate::content::Stage; use crate::fonts::Font; -use crate::motion::{cubic_out, interpolate_eased}; +use crate::motion::{cubic_out, interpolate_eased, quad_in, smoothstep}; use crate::overlays::{Overlays, frame_of}; use crate::props::Target; -use crate::svg::{Anchor, Card, Edge, Rect, Rgba, Shadow, Similarity, TextStyle, faded, rounded_rect_d}; +use crate::svg::{Anchor, Card, Edge, Rect, Rgba, Similarity, TextStyle, faded, rounded_rect_d}; use crate::timing::FPS; use fframes::{Svgr, svgr}; use std::collections::HashSet; -const TEXT: TextStyle = TextStyle::new(Font::sans(500), 22.); -const LINE_HEIGHT: f32 = 31.; -const KEY: TextStyle = TextStyle::new(Font::mono(500), 16.); -const KEY_HEIGHT: f32 = 27.; -const KEY_PADDING: f32 = 8.; -const KEY_GAP: f32 = 6.; -const PADDING: (f32, f32) = (18., 12.); -const ACCENT: f32 = 3.; +const TEXT: TextStyle = TextStyle::new(Font::sans(300), 30.); +const LINE_HEIGHT: f32 = 38.; +const TAG: TextStyle = TextStyle::new(Font::mono(400), 12.).spaced(0.25); +const TAG_HEIGHT: f32 = 24.; +const KEY: TextStyle = TextStyle::new(Font::mono(400), 30.); +const KEY_HEIGHT: f32 = 2.4 * KEY.size; +const KEY_GAP: f32 = 10.; +/// Around the card's content. +const PADDING: (f32, f32) = (30., 20.); +/// Between the keycaps and the text. +const GUTTER: f32 = 26.; +/// How far the frame around pointed-at targets darkens. +const DIM: f32 = 0.5; /// The least space between the target's box and the card. const GAP: f32 = 40.; /// Between the card and the screen edge. const EDGE: f32 = 24.; +/// The least distance from where the pointer leaves the card to the card's corners. +const INSET: f32 = 40.; /// Frames a box takes to follow its text to a new place. const GLIDE: f32 = 8.; +/// Frames into a callout when its keycaps press, and how long they stay lit. +const PRESS: f32 = 18.; +const LIT: f32 = 18.; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Side { @@ -76,8 +87,9 @@ impl Callout { let keys: Vec = key.into_iter().flat_map(|key| key.split('+')).filter_map(keycap).collect(); let lines = TEXT.font.wrap(text, TEXT.size, 0., (0.3 * w).max(240.)); let text_w = lines.iter().map(|line| TEXT.width(line)).fold(0., f32::max); - let size = - (2. * PADDING.0 + ACCENT + keys_width(&keys) + text_w, 2. * PADDING.1 + lines.len() as f32 * LINE_HEIGHT); + let keyed = !keys.is_empty(); + let height = (text_height(keyed, lines.len()) + 2. * PADDING.1).max(if keyed { KEY_HEIGHT + 24. } else { 0. }); + let size = (2. * PADDING.0 + keys_width(&keys) + text_w, height); let (enter, exit) = (frame_of(t), frame_of(t + dur)); let pointing = |clip: Option, track: Vec<(f32, Rect)>, ink: Vec| { let rects = stage.clip_rects(enter as usize); @@ -149,12 +161,23 @@ fn keycap(name: &str) -> Option { Some(first.to_uppercase().chain(chars).collect()) } +fn cap_width(key: &str) -> f32 { + 1.6 * KEY.size + KEY.width(key) +} + +/// The keycaps and the gutter after them. fn keys_width(keys: &[String]) -> f32 { if keys.is_empty() { return 0.; } - let caps: f32 = keys.iter().map(|key| KEY.width(key) + 2. * KEY_PADDING).sum(); - caps + KEY_GAP * (keys.len() - 1) as f32 + 12. + let caps: f32 = keys.iter().map(|key| cap_width(key)).sum(); + caps + KEY_GAP * (keys.len() - 1) as f32 + GUTTER +} + +/// The text column: a tag over the wrapped text when there are keycaps. +fn text_height(keyed: bool, lines: usize) -> f32 { + let tag = if keyed { TAG_HEIGHT } else { 0. }; + tag + lines as f32 * LINE_HEIGHT } /// A run of cells as fractions of an agg frame: agg pads the grid by one cell on the left and @@ -180,7 +203,7 @@ fn area(r: Rect) -> f32 { /// The box drawn around a target. fn boxed(target: Rect) -> Rect { - Rect::new(target.x - 6., target.y - 2., target.w + 12., target.h + 4.) + Rect::new(target.x - 7., target.y - 1., target.w + 14., target.h + 2.) } /// Where the target is at `f`: easing from where it was to where it moved. @@ -193,13 +216,15 @@ fn glide(track: &[(f32, Rect)], f: f32) -> Rect { } } -/// The card `gap` away from `target` on `side`, unclamped. +/// The card `gap` away from `target` on `side`, unclamped: level with the target beside it, +/// and above or below it no further left than lets the pointer drop straight down or up. fn beside(side: Side, gap: f32, target: Rect, (w, h): (f32, f32)) -> Rect { + let x = target.x.min(target.cx() - INSET.min(w / 2.)); let (x, y) = match side { Side::Right => (target.right() + gap, target.cy() - h / 2.), Side::Left => (target.x - gap - w, target.cy() - h / 2.), - Side::Below => (target.x, target.bottom() + gap), - Side::Above => (target.x, target.y - gap - h), + Side::Below => (x, target.bottom() + gap), + Side::Above => (x, target.y - gap - h), }; Rect::new(x, y, w, h) } @@ -213,8 +238,8 @@ fn place(side: Side, gap: f32, target: Rect, size: (f32, f32), (sw, sh): (f32, f } /// Where the card goes: the side and distance from the target that keep it on screen and -/// clear of the target while covering, and leading across, the least `ink`. Covered text -/// weighs most, then crossed text, then leader length; ties go to the nearer card and to +/// clear of the target while covering, and pointing across, the least `ink`. Covered text +/// weighs most, then crossed text, then pointer length; ties go to the nearer card and to /// the earlier of right, below, left, above. fn placement(target: Rect, size: (f32, f32), ink: &[Rect], screen: (f32, f32)) -> (Side, f32) { let safe = Rect::new(EDGE, EDGE, screen.0 - 2. * EDGE, screen.1 - 2. * EDGE); @@ -223,13 +248,8 @@ fn placement(target: Rect, size: (f32, f32), ink: &[Rect], screen: (f32, f32)) - let wanted = beside(side, gap, target, size); let card = place(side, gap, target, size, screen); let clear = area(card.intersect(target.outset(GAP / 2.))) <= 0.; - let crossed: f32 = leader(target, card, side) - .windows(2) - .map(|pair| { - let ((ax, ay), (bx, by)) = (pair[0], pair[1]); - inked(Rect::new(ax.min(bx), ay.min(by), (bx - ax).abs(), (by - ay).abs()).outset(2.)) - }) - .sum(); + let [(ax, ay), (bx, by)] = pointer(target, card, side); + let crossed = inked(Rect::new(ax.min(bx), ay.min(by), (bx - ax).abs(), (by - ay).abs()).outset(2.)); let offscreen = area(wanted) - area(wanted.intersect(safe)); (clear, offscreen + 4. * inked(card) + 10. * crossed + 8. * (gap - GAP)) }; @@ -245,157 +265,161 @@ fn placement(target: Rect, size: (f32, f32), ink: &[Rect], screen: (f32, f32)) - .expect("four sides") } -/// The leader from the box to the card: straight across the gap, or with one jog where the -/// card was pushed off the target's line by the screen edge. -fn leader(target: Rect, card: Rect, side: Side) -> Vec<(f32, f32)> { - let horizontal = matches!(side, Side::Right | Side::Left); - let (from, to, along) = match side { - Side::Right => (target.right(), card.x, target.cy()), - Side::Left => (target.x, card.right(), target.cy()), - Side::Below => (target.bottom(), card.y, target.cx()), - Side::Above => (target.y, card.bottom(), target.cx()), +/// The pointer from the card to the box: it leaves the card's edge facing the box, as near +/// the box's middle as `INSET` from the card's corners allows, and ends at the middle of the +/// box's facing edge. +fn pointer(target: Rect, card: Rect, side: Side) -> [(f32, f32); 2] { + let along = |from: f32, length: f32, to: f32| { + let inset = INSET.min(length / 2.); + to.clamp(from + inset, from + length - inset) }; - let (lo, hi) = if horizontal { (card.y + 14., card.bottom() - 14.) } else { (card.x + 20., card.right() - 20.) }; - let landing = along.clamp(lo, hi.max(lo)); - let point = |a: f32, b: f32| if horizontal { (a, b) } else { (b, a) }; - if (landing - along).abs() < 0.5 { - return vec![point(from, along), point(to, along)]; + match side { + Side::Right => [(card.x, along(card.y, card.h, target.cy())), (target.right(), target.cy())], + Side::Left => [(card.right(), along(card.y, card.h, target.cy())), (target.x, target.cy())], + Side::Below => [(along(card.x, card.w, target.cx()), card.y), (target.cx(), target.bottom())], + Side::Above => [(along(card.x, card.w, target.cx()), card.bottom()), (target.cx(), target.y)], } - let middle = (from + to) / 2.; - vec![point(from, along), point(middle, along), point(middle, landing), point(to, landing)] } -/// The first `fraction` of a polyline's length. -fn partial(points: &[(f32, f32)], fraction: f32) -> Vec<(f32, f32)> { - let length = |(ax, ay): (f32, f32), (bx, by): (f32, f32)| (bx - ax).hypot(by - ay); - let mut left = fraction * points.windows(2).map(|pair| length(pair[0], pair[1])).sum::(); - let mut drawn = vec![points[0]]; - for pair in points.windows(2) { - let (a, b) = (pair[0], pair[1]); - let segment = length(a, b); - if left >= segment { - drawn.push(b); - left -= segment; - } else { - let t = if segment > 0. { left / segment } else { 0. }; - drawn.push((a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)); - break; - } - } - drawn +/// Progress through `frames` frames from `start`, shaped by `easing`. +fn eased(x: f32, start: f32, frames: f32, easing: fn(f32) -> f32) -> f32 { + interpolate_eased(x, &[start, start + frames], &[0., 1.], easing) } -/// Progress through frames `a..b` of a stage, eased. -fn staged(x: f32, a: f32, b: f32) -> f32 { - interpolate_eased(x, &[a, b], &[0., 1.], cubic_out) -} +/// A drawn callout and, when it points at a target, the box the frame stays bright around +/// and how far the rest is dimmed for it. +type Drawn<'a> = (Option<(Rect, f32)>, Svgr<'a>); impl Overlays<'_> { - /// Every callout showing at `frame`; `clips` are where the shown clips are on screen. + /// Every callout showing at `frame`, over one dimmed frame with a hole at each target's + /// box; `clips` are where the shown clips are on screen. pub fn callouts<'a>(&self, callouts: &[Callout], clips: &[Rect], frame: usize) -> Svgr<'a> { - callouts.iter().map(|callout| self.callout(callout, clips, frame as f32)).collect() + let (w, h) = self.size; + let drawn: Vec> = + callouts.iter().filter_map(|callout| self.callout(callout, clips, frame as f32)).collect(); + let holes: Vec<(Rect, f32)> = drawn.iter().filter_map(|(hole, _)| *hole).collect(); + let dim = holes.iter().map(|(_, dim)| *dim).fold(0., f32::max); + let mut d = format!("M0 0H{w}V{h}H0Z"); + for (hole, _) in &holes { + d.push_str(&rounded_rect_d(*hole, 5.)); + } + let callouts: Svgr = drawn.into_iter().map(|(_, drawn)| drawn).collect(); + let dimmed = faded(DIM * dim, svgr!()); + svgr!({dimmed}{callouts}) } - fn callout<'a>(&self, callout: &Callout, clips: &[Rect], f: f32) -> Svgr<'a> { + fn callout<'a>(&self, callout: &Callout, clips: &[Rect], f: f32) -> Option> { if f < callout.enter || f >= callout.exit { - return Svgr::empty(); + return None; } - let (local, remain) = (f - callout.enter, callout.exit - 1. - f); + let (local, total) = (f - callout.enter, callout.exit - callout.enter); + let shown = eased(local, 0., 9., smoothstep) * (1. - eased(local, total - 10.5, 10.5, quad_in)); let (w, h) = callout.size; let (clip, track, side, gap) = match &callout.aim { Aim::Point(x, y) => { - let shown = staged(local, 0., 10.).min(staged(remain, 0., 8.)); let rect = Rect::new(x - w / 2., y - h / 2., w, h); - return faded(shown, self.card(callout, rect, Similarity::scale_about(0.96 + 0.04 * shown, *x, *y))); + return Some((None, self.card(callout, rect, local, shown))); } Aim::Target { clip, track, side, gap } => (clip, track, *side, *gap), }; let target = glide(track, f); let target = boxed(clip.map_or(target, |i| within(target, clips[i]))); - let rect = place(side, gap, target, callout.size, self.size); - let accent = Rgba::hex(self.palette.accent); - - let drawn = staged(local, 0., 9.).min(staged(remain, 0., 7.)); - let radius = 6_f32.min(target.h / 2.); - let perimeter = 2. * (target.w + target.h) - (8. - 2. * std::f32::consts::PI) * radius; - let outline = svgr!( - - ); - - let extended = staged(local, 6., 14.).min(staged(remain, 5., 11.)); - let route = partial(&leader(target, rect, side), extended); - let points = route.iter().map(|(x, y)| format!("{x},{y}")).collect::>().join(" "); - let (dot_x, dot_y) = route[0]; - let line = faded( - 4. * extended, + let card = place(side, gap, target, callout.size, self.size); + let pointed = eased(local, 3., 9., smoothstep) * (1. - eased(local, total - 10.5, 9., quad_in)); + let grow = eased(local, 4.5, 12., smoothstep); + let [from, to] = pointer(target, card, side); + let end = (from.0 + (to.0 - from.0) * grow, from.1 + (to.1 - from.1) * grow); + let accent = Rgba::hex(self.palette.accent).to_string(); + let pointing = faded( + pointed, svgr!( - - + + + + ), ); - - let slid = staged(local, 10., 20.).min(staged(remain, 9., 16.)); - let back = 10. * (1. - slid); - let (dx, dy) = match side { - Side::Right => (-back, 0.), - Side::Left => (back, 0.), - Side::Below => (0., -back), - Side::Above => (0., back), - }; - let card = faded(slid, self.card(callout, rect, Similarity::translate(dx, dy))); - svgr!({faded(drawn, outline)}{line}{card}) + let card = self.card(callout, card, local, shown); + Some((Some((target, pointed)), svgr!({pointing}{card}))) } - /// The card at `rect`, drawn on screen by `place`: keycaps, then the wrapped text. - fn card<'a>(&self, callout: &Callout, rect: Rect, place: Similarity) -> Svgr<'a> { + /// The card at `rect`, `opacity` shown and `local` frames into its callout: a solid panel + /// under an accent bar, holding the keycaps, then a tag over the wrapped text. It pops up + /// to full size as it fades in. + fn card<'a>(&self, callout: &Callout, rect: Rect, local: f32, opacity: f32) -> Svgr<'a> { + if opacity <= 0. { + return Svgr::empty(); + } let palette = self.palette; - let text = Rgba::hex(palette.text); - let top = rect.y + PADDING.1; - let mut x = rect.x + ACCENT + PADDING.0; - let key_y = top + (LINE_HEIGHT - KEY_HEIGHT) / 2.; + let (ink, muted) = (Rgba::hex(palette.text), Rgba::hex(palette.muted)); + let keyed = !callout.keys.is_empty(); + let lit = (PRESS..PRESS + LIT).contains(&local); + let press = if lit { (1. - (local - PRESS) / 7.5).max(0.) } else { 0. }; + let mut x = rect.x + PADDING.0; let caps: Svgr = callout .keys .iter() .map(|key| { - let cap = Rect::new(x, key_y, KEY.width(key) + 2. * KEY_PADDING, KEY_HEIGHT - 2.); - x = cap.right() + KEY_GAP; - let baseline = KEY.baseline(cap.y, cap.h); - svgr!( - - - - {KEY.draw(key.clone(), cap.cx(), baseline, text, Anchor::Middle)} - - ) + let cap = self.keycap(key, x, rect.cy(), lit, press); + x += cap_width(key) + KEY_GAP; + cap }) .collect(); - let text_x = rect.x + ACCENT + PADDING.0 + keys_width(&callout.keys); + let text_x = rect.x + PADDING.0 + keys_width(&callout.keys); + let mut top = rect.cy() - text_height(keyed, callout.lines.len()) / 2.; + let tag = if keyed { + let tag = TAG.draw("SHORTCUT".to_owned(), text_x, TAG.baseline(top, TAG_HEIGHT - 8.), muted, Anchor::Start); + top += TAG_HEIGHT; + tag + } else { + Svgr::empty() + }; let lines: Svgr = callout .lines .iter() .enumerate() .map(|(row, line)| { let baseline = TEXT.baseline(top + row as f32 * LINE_HEIGHT, LINE_HEIGHT); - TEXT.draw(line.clone(), text_x, baseline, text, Anchor::Start) + TEXT.draw(line.clone(), text_x, baseline, ink, Anchor::Start) }) .collect(); + let place = Similarity::scale_about(0.96 + 0.04 * eased(local, 0., 12., smoothstep), rect.cx(), rect.cy()); let card = Card { id: &format!("callout{}", callout.id), rect, - radius: 10., - fill: Rgba::hex(palette.surface).a8(0xE6), + radius: 6., + fill: Rgba::hex(palette.surface).alpha(0.96), border: Rgba::hex(palette.border), - accent: Some((Edge::Left, ACCENT, Rgba::hex(palette.accent))), - shadows: &[Shadow::new(8., 32., Rgba(0, 0, 0, 0.35))], - frost: self.frost(12.), + accent: Some((Edge::Top, 2., Rgba::hex(palette.accent))), + shadows: &[], + frost: None, to_screen: place, screen: self.size, }; - svgr!({card.draw(svgr!({caps}{lines}))}) + faded(opacity, svgr!({card.draw(svgr!({caps}{tag}{lines}))})) + } + + /// A keycap of `key` from `x`, centred on `cy`: a cap on a slab it is pushed `press` of + /// the way down onto, outlined and labelled in the accent while `lit`. + fn keycap<'a>(&self, key: &str, x: f32, cy: f32, lit: bool, press: f32) -> Svgr<'a> { + let palette = self.palette; + let (surface, border) = (Rgba::hex(palette.surface).to_string(), Rgba::hex(palette.border)); + let (edge, label) = if lit { + (Rgba::hex(palette.accent), Rgba::hex(palette.accent)) + } else { + (border, Rgba::hex(palette.text)) + }; + let slab = Rect::new(x, cy - KEY_HEIGHT / 2. + 4., cap_width(key), KEY_HEIGHT); + let cap = Rect::new(x, cy - KEY_HEIGHT / 2. + 3. * press, cap_width(key), KEY_HEIGHT); + svgr!( + + + + {KEY.draw(key.to_owned(), cap.cx(), KEY.baseline(cap.y, cap.h), label, Anchor::Middle)} + + ) } } @@ -418,17 +442,18 @@ mod tests { } #[test] - fn leaders_run_straight_and_jog_only_when_the_card_is_pushed_off_line() { + fn pointers_run_straight_and_slant_only_when_the_card_is_pushed_off_line() { let target = Rect::new(100., 500., 200., 40.); - let card = place(Side::Right, GAP, target, (300., 52.), SCREEN); - assert_eq!(leader(target, card, Side::Right), [(300., 520.), (340., 520.)]); + let card = place(Side::Right, GAP, target, (300., 100.), SCREEN); + assert_eq!(pointer(target, card, Side::Right), [(340., 520.), (300., 520.)]); + // A narrow target: the card shifts left so the pointer still drops straight down. + let narrow = Rect::new(500., 500., 30., 20.); + let card = place(Side::Below, GAP, narrow, (300., 100.), SCREEN); + assert_eq!(pointer(narrow, card, Side::Below), [(515., 560.), (515., 520.)]); let low = Rect::new(100., 1040., 200., 30.); - let card = place(Side::Right, GAP, low, (300., 52.), SCREEN); + let card = place(Side::Right, GAP, low, (300., 100.), SCREEN); assert_eq!(card.bottom(), SCREEN.1 - EDGE); - let route = leader(low, card, Side::Right); - assert_eq!(route, [(300., 1055.), (320., 1055.), (320., card.bottom() - 14.), (340., card.bottom() - 14.)]); - assert_eq!(partial(&route, 0.), [(300., 1055.), (300., 1055.)]); - assert_eq!(partial(&route, 1.), route); + assert_eq!(pointer(low, card, Side::Right), [(340., card.bottom() - INSET), (300., 1055.)]); } #[test] diff --git a/plugins/droid-control/fframes/src/motion.rs b/plugins/droid-control/fframes/src/motion.rs index 182ccc8..dd387e5 100644 --- a/plugins/droid-control/fframes/src/motion.rs +++ b/plugins/droid-control/fframes/src/motion.rs @@ -77,6 +77,16 @@ pub fn back_out(s: f32) -> impl Fn(f32) -> f32 { } } +/// Hermite smoothstep: eases in and out. +pub fn smoothstep(t: f32) -> f32 { + t * t * (3. - 2. * t) +} + +/// `Easing.in(Easing.quad)`. +pub fn quad_in(t: f32) -> f32 { + t * t +} + /// `Easing.out(Easing.cubic)`. pub fn cubic_out(t: f32) -> f32 { 1. - (1. - t).powi(3) diff --git a/plugins/droid-control/fframes/src/overlays.rs b/plugins/droid-control/fframes/src/overlays.rs index 033ab78..1ba99a5 100644 --- a/plugins/droid-control/fframes/src/overlays.rs +++ b/plugins/droid-control/fframes/src/overlays.rs @@ -26,7 +26,7 @@ pub struct Overlays<'s> { } impl<'s> Overlays<'s> { - pub fn frost(&self, sigma: f32) -> Option> { + fn frost(&self, sigma: f32) -> Option> { Some(Frost { backdrop: self.backdrop, sigma }) } diff --git a/plugins/droid-control/fframes/src/svg.rs b/plugins/droid-control/fframes/src/svg.rs index 33bdbd8..c6558c2 100644 --- a/plugins/droid-control/fframes/src/svg.rs +++ b/plugins/droid-control/fframes/src/svg.rs @@ -260,6 +260,7 @@ impl Shadow { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Edge { Left, + Top, Bottom, } @@ -357,6 +358,9 @@ impl Card<'_> { Some((Edge::Left, width, color)) => { svgr!() } + Some((Edge::Top, width, color)) => { + svgr!() + } Some((Edge::Bottom, width, color)) => { svgr!() } diff --git a/plugins/droid-control/skills/droid-control/atoms/compose/ATOM.md b/plugins/droid-control/skills/droid-control/atoms/compose/ATOM.md index 21ae48f..55158bc 100644 --- a/plugins/droid-control/skills/droid-control/atoms/compose/ATOM.md +++ b/plugins/droid-control/skills/droid-control/atoms/compose/ATOM.md @@ -254,17 +254,17 @@ Regions use percentage strings (e.g., `"25%"`) relative to the video dimensions. | `at` | Draws | |---|---| -| `{ "text": "Auto (High)" }` | A box around that text on a `.cast` clip's screen, with a leader to the card. The box sits on the text's terminal cells and follows the text while the callout shows (scrolling, redraws). | -| `{ "x", "y", "w", "h" }` | A box around a region of the video (any clip type), with a leader to the card. | +| `{ "text": "Auto (High)" }` | An accent box around that text on a `.cast` clip's screen, with a line to the card; the rest of the frame dims. The box sits on the text's terminal cells and follows the text while the callout shows (scrolling, redraws). | +| `{ "x", "y", "w", "h" }` | An accent box around a region of the video (any clip type), with a line to the card; the rest of the frame dims. | | `{ "x", "y" }` | The card alone, centred on the point. | -`key` (optional) shows a shortcut as keycaps before the text: `"shift+tab"` draws `Shift` and `Tab` caps. +`key` (optional) shows a shortcut as keycaps before the text, under a `SHORTCUT` tag: `"shift+tab"` draws `Shift` and `Tab` caps, which press once as the card settles. ```json { "fx": "callout", "t": 3.7, "dur": 2.4, "text": "Cycle the autonomy level", "key": "shift+tab", "at": { "text": "Auto (High)" } } ``` -Prefer text targets for terminal clips. Measured coordinates drift, but the cells don't. The composition places the card itself: it picks the side and distance where the card and leader cover the least text while the callout shows. Matching is case-sensitive and takes the first occurrence on screen, top to bottom, so pick text that's unique on screen at `t`. The render fails if no shown `.cast` clip has that text on screen at `t`. Write `t` in output seconds, like every other overlay time. +Prefer text targets for terminal clips. Measured coordinates drift, but the cells don't. The composition places the card itself: it picks the side and distance where the card and its line cover the least text while the callout shows. Matching is case-sensitive and takes the first occurrence on screen, top to bottom, so pick text that's unique on screen at `t`. The render fails if no shown `.cast` clip has that text on screen at `t`. Write `t` in output seconds, like every other overlay time. ### When to use effects From c0663f8516811b8713c8bef7c116ee741c13f0cc Mon Sep 17 00:00:00 2001 From: "Factory: factory-dev" <138933559+factory-droid[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:55:53 +0000 Subject: [PATCH 4/5] refactor(droid-control): retrospective cleanup Callout tracking and clip decoding share Stage::source_frame, so the two can't drift apart. frame_of moves to timing.rs on FPS. The key-name helper becomes cap_label (keycap draws), clip_rects is worked out once per callout, and the card's spacing and key release get named constants. Co-authored-by: factory-ain3sh --- plugins/droid-control/fframes/src/callout.rs | 46 +++++++++++-------- plugins/droid-control/fframes/src/content.rs | 18 +++++--- plugins/droid-control/fframes/src/overlays.rs | 5 +- plugins/droid-control/fframes/src/timing.rs | 5 ++ 4 files changed, 44 insertions(+), 30 deletions(-) diff --git a/plugins/droid-control/fframes/src/callout.rs b/plugins/droid-control/fframes/src/callout.rs index 7b54e3c..b50e9ad 100644 --- a/plugins/droid-control/fframes/src/callout.rs +++ b/plugins/droid-control/fframes/src/callout.rs @@ -7,22 +7,26 @@ use crate::cast::{Cast, Span}; use crate::content::Stage; use crate::fonts::Font; use crate::motion::{cubic_out, interpolate_eased, quad_in, smoothstep}; -use crate::overlays::{Overlays, frame_of}; +use crate::overlays::Overlays; use crate::props::Target; use crate::svg::{Anchor, Card, Edge, Rect, Rgba, Similarity, TextStyle, faded, rounded_rect_d}; -use crate::timing::FPS; +use crate::timing::{FPS, frame_of}; use fframes::{Svgr, svgr}; use std::collections::HashSet; const TEXT: TextStyle = TextStyle::new(Font::sans(300), 30.); const LINE_HEIGHT: f32 = 38.; const TAG: TextStyle = TextStyle::new(Font::mono(400), 12.).spaced(0.25); -const TAG_HEIGHT: f32 = 24.; +const TAG_LINE_HEIGHT: f32 = 16.; +/// Between the tag and the text under it. +const TAG_GAP: f32 = 8.; const KEY: TextStyle = TextStyle::new(Font::mono(400), 30.); const KEY_HEIGHT: f32 = 2.4 * KEY.size; const KEY_GAP: f32 = 10.; /// Around the card's content. const PADDING: (f32, f32) = (30., 20.); +/// Above and below the keycaps, which run taller than the text beside them. +const KEY_MARGIN: f32 = 12.; /// Between the keycaps and the text. const GUTTER: f32 = 26.; /// How far the frame around pointed-at targets darkens. @@ -35,9 +39,11 @@ const EDGE: f32 = 24.; const INSET: f32 = 40.; /// Frames a box takes to follow its text to a new place. const GLIDE: f32 = 8.; -/// Frames into a callout when its keycaps press, and how long they stay lit. +/// Frames into a callout when its keycaps press, how long they stay lit, and how long they +/// take to rise back up. const PRESS: f32 = 18.; const LIT: f32 = 18.; +const RELEASE: f32 = 7.5; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Side { @@ -84,15 +90,19 @@ impl Callout { stage: &Stage<'_>, ) -> Result { let (w, h) = stage.size; - let keys: Vec = key.into_iter().flat_map(|key| key.split('+')).filter_map(keycap).collect(); + let keys: Vec = key.into_iter().flat_map(|key| key.split('+')).filter_map(cap_label).collect(); let lines = TEXT.font.wrap(text, TEXT.size, 0., (0.3 * w).max(240.)); let text_w = lines.iter().map(|line| TEXT.width(line)).fold(0., f32::max); let keyed = !keys.is_empty(); - let height = (text_height(keyed, lines.len()) + 2. * PADDING.1).max(if keyed { KEY_HEIGHT + 24. } else { 0. }); + let height = (text_height(keyed, lines.len()) + 2. * PADDING.1).max(if keyed { + KEY_HEIGHT + 2. * KEY_MARGIN + } else { + 0. + }); let size = (2. * PADDING.0 + keys_width(&keys) + text_w, height); let (enter, exit) = (frame_of(t), frame_of(t + dur)); + let rects = stage.clip_rects(enter as usize); let pointing = |clip: Option, track: Vec<(f32, Rect)>, ink: Vec| { - let rects = stage.clip_rects(enter as usize); let to_screen = |r: Rect| clip.map_or(r, |i| within(r, rects[i])); let target = boxed(to_screen(track[0].1)); let ink: Vec = ink.into_iter().map(to_screen).collect(); @@ -106,17 +116,15 @@ impl Callout { pointing(None, vec![(enter, rect)], Vec::new()) } Target::Text { text: needle } => { - let shown = stage.clip_rects(enter as usize).len(); - let casts = stage.clips[..shown] + let casts = stage.clips[..rects.len()] .iter() .enumerate() .filter_map(|(i, clip)| Some((i, clip, clip.cast.as_ref()?))); let found = casts.clone().find_map(|(i, clip, cast)| { - // The clip frame shown at `f` (see `Stage::clip_frame`), in cast seconds. - let time = - |f: f32| (f64::from(f) * stage.speed + 1e-9).floor().min(clip.last_frame as f64) / FPS as f64; + // The clip frame shown at `f`, in cast seconds. + let time = |f: usize| stage.source_frame(clip, f) as f64 / FPS as f64; let mut replay = cast.replay(); - replay.advance(time(enter)); + replay.advance(time(enter as usize)); let first = replay.find(needle)?; // Text where it is relative to the target (the card moves with the target); // text that would sit off the grid is out of the card's reach anyway. @@ -132,7 +140,7 @@ impl Callout { seen(first, replay.ink()); let mut track = vec![(enter, first)]; for f in (enter as usize + 1)..exit as usize { - if !replay.advance(time(f as f32)) { + if !replay.advance(time(f)) { continue; } let at = replay.find(needle).unwrap_or(track[track.len() - 1].1); @@ -155,7 +163,7 @@ impl Callout { } /// A key name as its cap reads, e.g. `"shift"` -> `"Shift"`, `"k"` -> `"K"`. -fn keycap(name: &str) -> Option { +fn cap_label(name: &str) -> Option { let mut chars = name.trim().chars(); let first = chars.next()?; Some(first.to_uppercase().chain(chars).collect()) @@ -176,7 +184,7 @@ fn keys_width(keys: &[String]) -> f32 { /// The text column: a tag over the wrapped text when there are keycaps. fn text_height(keyed: bool, lines: usize) -> f32 { - let tag = if keyed { TAG_HEIGHT } else { 0. }; + let tag = if keyed { TAG_LINE_HEIGHT + TAG_GAP } else { 0. }; tag + lines as f32 * LINE_HEIGHT } @@ -356,7 +364,7 @@ impl Overlays<'_> { let (ink, muted) = (Rgba::hex(palette.text), Rgba::hex(palette.muted)); let keyed = !callout.keys.is_empty(); let lit = (PRESS..PRESS + LIT).contains(&local); - let press = if lit { (1. - (local - PRESS) / 7.5).max(0.) } else { 0. }; + let press = if lit { (1. - (local - PRESS) / RELEASE).max(0.) } else { 0. }; let mut x = rect.x + PADDING.0; let caps: Svgr = callout .keys @@ -370,8 +378,8 @@ impl Overlays<'_> { let text_x = rect.x + PADDING.0 + keys_width(&callout.keys); let mut top = rect.cy() - text_height(keyed, callout.lines.len()) / 2.; let tag = if keyed { - let tag = TAG.draw("SHORTCUT".to_owned(), text_x, TAG.baseline(top, TAG_HEIGHT - 8.), muted, Anchor::Start); - top += TAG_HEIGHT; + let tag = TAG.draw("SHORTCUT".to_owned(), text_x, TAG.baseline(top, TAG_LINE_HEIGHT), muted, Anchor::Start); + top += TAG_LINE_HEIGHT + TAG_GAP; tag } else { Svgr::empty() diff --git a/plugins/droid-control/fframes/src/content.rs b/plugins/droid-control/fframes/src/content.rs index 6dd7964..1fb8bc0 100644 --- a/plugins/droid-control/fframes/src/content.rs +++ b/plugins/droid-control/fframes/src/content.rs @@ -66,6 +66,12 @@ impl Stage<'_> { canvas.draw(self.spotlights(frame)); } + /// The frame of `clip` shown `frame` frames after playback started: `speed` source frames + /// per output frame, holding the last frame once the clip ends. + pub fn source_frame(&self, clip: &Clip, frame: usize) -> usize { + ((frame as f64 * self.speed + 1e-9).floor() as usize).min(clip.last_frame) + } + /// Where each shown clip's whole frame lands on screen at `frame`, in clip order. pub fn clip_rects(&self, frame: usize) -> Vec { self.windows(frame) @@ -163,8 +169,8 @@ impl Stage<'_> { .collect() } - /// The clip frame shown `frame` frames after playback started (`speed` source frames per - /// output frame, holding the last frame once the clip ends), fitted into `window` as drawn + /// The clip frame shown `frame` frames after playback started (see `source_frame`), fitted + /// into `window` as drawn /// on screen: its visible part resampled to the whole screen pixels it /// covers inside the content box, and those pixels. A partly covered edge pixel keeps the /// window fill. A frame identical to the last one the window showed at the same place @@ -177,10 +183,9 @@ impl Stage<'_> { video: &Frame, ctx: &FFramesContext<'_, '_>, ) -> Option { - let Window { id, inner, to_screen, .. } = window; - let (inner, to_screen) = (*inner, *to_screen); + let Window { ref id, inner, to_screen, .. } = *window; let mut at = video.clone(); - at.index = ((frame as f64 * self.speed + 1e-9).floor() as usize).min(clip.last_frame); + at.index = self.source_frame(clip, frame); let input = SyncVideoFrameInput { start_from: 0., looping: false, editor_fallback_image: None }; let decoded = at.get_synced_video_frame(ctx, &clip.name, &input)?.into_image().href(); let size = (decoded.width as f32, decoded.height as f32); @@ -254,8 +259,7 @@ impl Stage<'_> { video: &Frame, ctx: &FFramesContext<'_, '_>, ) { - let Window { id, inner, title, to_screen, opacity } = window; - let (inner, to_screen, opacity) = (*inner, *to_screen, *opacity); + let Window { ref id, inner, ref title, to_screen, opacity } = *window; if opacity <= 0. { return; } diff --git a/plugins/droid-control/fframes/src/overlays.rs b/plugins/droid-control/fframes/src/overlays.rs index 1ba99a5..753a50d 100644 --- a/plugins/droid-control/fframes/src/overlays.rs +++ b/plugins/droid-control/fframes/src/overlays.rs @@ -8,15 +8,12 @@ use crate::props::{CodeAnnotation, CodePosition, Keystroke, Section}; use crate::raster::Backdrop; use crate::svg::{Anchor, Card, Edge, Frost, Rect, Rgba, Shadow, Similarity, TextStyle, blur, faded, stops}; use crate::theme::{Palette, PresetConfig}; +use crate::timing::frame_of; use fframes::{Svgr, svgr}; /// Overshooting pop-in for keystrokes. const POP: Bezier = Bezier(0.34, 1.56, 0.64, 1.); -pub fn frame_of(seconds: f32) -> f32 { - (seconds * 30.).round() -} - pub struct Overlays<'s> { pub palette: &'s Palette, pub config: &'s PresetConfig, diff --git a/plugins/droid-control/fframes/src/timing.rs b/plugins/droid-control/fframes/src/timing.rs index 4245905..abc426a 100644 --- a/plugins/droid-control/fframes/src/timing.rs +++ b/plugins/droid-control/fframes/src/timing.rs @@ -1,6 +1,11 @@ //! The showcase timeline: title card, clip content, outro, joined by two transitions. pub const FPS: usize = 30; + +/// The frame nearest `seconds`. +pub fn frame_of(seconds: f32) -> f32 { + (seconds * FPS as f32).round() +} pub const TITLE_FRAMES: usize = 4 * FPS; /// 3.5 s. pub const OUTRO_FRAMES: usize = 7 * FPS / 2; From f4a0dfd6a55351bf26c5a11b9f51a6f9c3d4e665 Mon Sep 17 00:00:00 2001 From: "Factory: factory-dev" <138933559+factory-droid[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:41:57 +0000 Subject: [PATCH 5/5] feat(droid-control): dim the frame to 25% behind callouts At 50% the rest of the screen was hard to read while a callout showed. Co-authored-by: factory-ain3sh --- plugins/droid-control/fframes/src/callout.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/droid-control/fframes/src/callout.rs b/plugins/droid-control/fframes/src/callout.rs index b50e9ad..b0df506 100644 --- a/plugins/droid-control/fframes/src/callout.rs +++ b/plugins/droid-control/fframes/src/callout.rs @@ -30,7 +30,7 @@ const KEY_MARGIN: f32 = 12.; /// Between the keycaps and the text. const GUTTER: f32 = 26.; /// How far the frame around pointed-at targets darkens. -const DIM: f32 = 0.5; +const DIM: f32 = 0.25; /// The least space between the target's box and the card. const GAP: f32 = 40.; /// Between the card and the screen edge.