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..b0df506 --- /dev/null +++ b/plugins/droid-control/fframes/src/callout.rs @@ -0,0 +1,483 @@ +//! 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 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, quad_in, smoothstep}; +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, 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_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. +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. +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, 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 { + 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(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 + 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 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 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`, 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 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. + 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)) { + 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 cap_label(name: &str) -> Option { + let mut chars = name.trim().chars(); + let first = chars.next()?; + 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| 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_LINE_HEIGHT + TAG_GAP } 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 +/// 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 - 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. +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: 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 => (x, target.bottom() + gap), + Side::Above => (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 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); + 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 [(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)) + }; + 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 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) + }; + 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)], + } +} + +/// 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) +} + +/// 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`, 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> { + 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) -> Option> { + if f < callout.enter || f >= callout.exit { + return None; + } + 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 rect = Rect::new(x - w / 2., y - h / 2., w, h); + 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 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 card = self.card(callout, card, local, shown); + Some((Some((target, pointed)), svgr!({pointing}{card}))) + } + + /// 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 (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) / RELEASE).max(0.) } else { 0. }; + let mut x = rect.x + PADDING.0; + let caps: Svgr = callout + .keys + .iter() + .map(|key| { + let cap = self.keycap(key, x, rect.cy(), lit, press); + x += cap_width(key) + KEY_GAP; + cap + }) + .collect(); + 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_LINE_HEIGHT), muted, Anchor::Start); + top += TAG_LINE_HEIGHT + TAG_GAP; + 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, 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: 6., + fill: Rgba::hex(palette.surface).alpha(0.96), + border: Rgba::hex(palette.border), + accent: Some((Edge::Top, 2., Rgba::hex(palette.accent))), + shadows: &[], + frost: None, + to_screen: place, + screen: self.size, + }; + 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)} + + ) + } +} + +#[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 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., 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., 100.), SCREEN); + assert_eq!(card.bottom(), SCREEN.1 - EDGE); + assert_eq!(pointer(low, card, Side::Right), [(340., card.bottom() - INSET), (300., 1055.)]); + } + + #[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/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 diff --git a/plugins/droid-control/fframes/src/content.rs b/plugins/droid-control/fframes/src/content.rs index 051086e..1fb8bc0 100644 --- a/plugins/droid-control/fframes/src/content.rs +++ b/plugins/droid-control/fframes/src/content.rs @@ -60,6 +60,30 @@ 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)); + } + + /// 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) + .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 +96,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 +116,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. @@ -141,23 +169,23 @@ 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 /// 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 { 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); @@ -225,13 +253,13 @@ 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 { ref id, inner, ref title, to_screen, opacity } = *window; if opacity <= 0. { return; } @@ -265,10 +293,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 +339,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/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 fadb8e4..753a50d 100644 --- a/plugins/droid-control/fframes/src/overlays.rs +++ b/plugins/droid-control/fframes/src/overlays.rs @@ -1,22 +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 crate::timing::frame_of; 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 { - (seconds * 30.).round() -} - pub struct Overlays<'s> { pub palette: &'s Palette, pub config: &'s PresetConfig, @@ -30,65 +27,6 @@ impl<'s> Overlays<'s> { 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/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/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; 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..55158bc 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)" }` | 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, 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 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 | Effect | Use when… | Don't use when… |