@docyrus/animaguru 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ # Synthwave & Y2K production recipe
2
+
3
+ The workspace includes an executable original study in `src/studies/scenes.ts`, selected with `synthwave-y2k`. Its drawing commands are consumed by `src/studies/pixi-renderer.ts`; geometry and time sampling are shared with the gallery renderer. All copy lives in `shared/locales/en/style-studies.json`.
4
+
5
+ In an untouched starter, run `npm run example -- synthwave-y2k`, then `npm run dev`. The command refuses to replace an authored film, retains starter backups in `production/example-backup/`, and writes an 8-second study with its own resource tracks. `npm run export:html` and `npm run export:mp4` use this installed film normally. To use the recipe inside an existing film, import `studyFrame()` and `StudyRenderer`, then compose the selected scene into the project's timeline instead of running the installer.
6
+
7
+ ## Assets and licences
8
+
9
+ The look needs no media: the grid, sun, mountains, palms, chrome type, windows and tape damage are all drawn. Generated media fits around it, never in place of editable type: a generated synthwave track, a narration, or a painted sky plate behind the code-drawn grid. Fonts must be OFL or bundled with the system that renders the film; record each in `production/CREDITS.md` with its licence. Do not reproduce real operating system interfaces, logos, startup sounds or cursors from commercial products; invent the desktop, its file names and its colours.
10
+
11
+ ## Adaptation decisions
12
+
13
+ - Draw the floor grid as a `Mesh` or a full-screen `Filter` that intersects each pixel's ray with the ground plane (`z = eyeHeight · focal / (y − horizon)`), measures the distance to the nearest world-space line in screen pixels and fades lines into their average once a cell is under about 4 px. Scroll by adding camera travel to world `z`, not by moving sprites.
14
+ - Keep emissive parts (grid, horizon line, sun, crests, neon script, title outline) in one container rendered to a `RenderTexture`. Blur two copies (`BlurFilter` of about 4 and, at quarter resolution, about 30) and add them with `blendMode: "add"`; mask the emission under solid objects (palms, chrome face, windows) before blurring, so glow wraps their edges instead of washing over them.
15
+ - Build the chrome title once as a texture: stepped extrusion copies, a `FillGradient` face with a hard dark band at its middle, a thin light stroke for the bevel. Animate the slam by scaling that texture (average a few sub-frame scales for motion blur) and run the shine as a masked gradient sprite clipped to the face.
16
+ - Write the VHS chain as one custom `Filter` on the stage: per-row horizontal offsets from a hash of row and absolute time, YIQ chroma blur and delay (or an RGB split), luma noise, a moving tracking band, bottom-row skew and every-other-row darkening. Feed it a `glitch` uniform from a cue table so tears happen on purpose. Draw the on-screen display after the filter's damage stage, before scanlines.
17
+ - Quantise the scene clock for stepped passages (`Math.floor(t * 15) / 15`) and map a hit's next two frames to earlier scene times for the reverse stutter; the tape noise keeps reading the true time.
18
+ - Drive windows and the cursor from one cue table: pop (overshoot spring), move, press, drag outline, release, click (button down, ripple) and collapse (scale Y to a line, then X to a point).
19
+
20
+ The study is silent, original procedural art. It demonstrates material, transforms and layout rather than any real product or operating system. Configure an original subject, palette, scene sequence, duration and soundtrack from the brief.
21
+
22
+ Use `src/studies/timing.ts` for deterministic easing, seeded variation, measured stroke truncation and cue helpers. `window.animaguru.texts` exposes visible study text bounds for `npm run review`; text declarations alone do not count as visual proof. Inspect the actual frames as well.
23
+
24
+ ## Sources and attribution
25
+
26
+ Inspired by the following pinned reference; no source code, assets or audio are bundled. New study art and scene geometry are original. The workspace's `VIDEO-STYLE-NOTICES.txt` preserves the repository notices and clarifies third-party asset scope.
27
+
28
+ - [prompts/10-synthwave.md](https://github.com/Vincentwei1021/mg-styles-15/blob/49052d80bfaf9fa8412cc9710530e5e95a3b8d8c/prompts/10-synthwave.md)
29
+ - [demos/10-synthwave/index.html](https://github.com/Vincentwei1021/mg-styles-15/blob/49052d80bfaf9fa8412cc9710530e5e95a3b8d8c/demos/10-synthwave/index.html)
@@ -0,0 +1,36 @@
1
+ ---
2
+ name: animaguru-variety-captions
3
+ description: Build Variety Captions visuals and motion in an AnimaGuru PixiJS video workspace. Use when this visual style is chosen for an explainer, advertisement or general video.
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ # Variety Captions
8
+
9
+ TV variety-show packaging: chunky rounded captions in layered outlines pop onto the footage a beat after every gag, and each feeling gets its own treatment, from bouncing question marks to a burst frame on a freeze. It turns a vlog, a pet clip, an interview or a product demo into an episode. Subject, audience, emotional tone and story come from the user's brief. This style supports Explainer, Advertisement and General Video.
10
+
11
+ ## Establish direction
12
+
13
+ Follow sibling `animaguru-video` for purpose → style → compact interview before new production. Reuse explicit choices and completed briefs; honor a requested interview skip. Share [the style gallery](https://animaguru.com/animation-styles#variety-captions). Fold missing visual questions into the shared interview:
14
+
15
+ - Which footage carries the film, and which three to five moments are the gags or turns the captions answer?
16
+ - Which language and voice do the captions speak in (the narrator, the subject's inner voice, a commentator), and how cheeky may they be?
17
+
18
+ ## Footage and assets
19
+
20
+ Ask for the user's own footage first; the captions are written for real moments, so the clip decides the gags. Otherwise use licence-clean stock (CC0 or public domain, or a licence the user confirms) and record each file in `production/CREDITS.md`. A generated clip is allowed only when the user agrees, and it must never pose as a real recorded moment of a real person or animal. Bundle every font locally with its licence: heavy rounded faces such as Nunito Black, Baloo 2, M PLUS Rounded 1c, ZCOOL KuaiLe or Smiley Sans (all OFL), and a hand-lettering face such as Caveat or Patrick Hand for asides.
21
+
22
+ ## Build the look and movement
23
+
24
+ Read `pixijs` and the relevant Text, Graphics, Container and Filters skills. Read `animaguru` before generating assets: generation can supply music, sound effects or narration; the captions themselves stay authored, editable canvas text.
25
+
26
+ - Every caption is three layers plus a shadow: a fill with a two-stop gloss (a hard step at about half height), a white outline of about 0.18 em and a coloured outer outline of about 0.34 em, over an offset drop shadow (about 0.04 em right, 0.08 em down, half opacity). Draw each layer across the whole line before the next, so no letter's outline covers its neighbour's fill.
27
+ - Entrances overshoot: scale 0 → 1.15 → 1.0 over about eight frames at 24 fps, or letter by letter with a two-frame stagger and a short hop. Exits are a three-to-four-frame pop. Long holds keep a gentle wiggle (±2–3°, ±2–3 px).
28
+ - A caption lands two to three frames after its beat (the visual moment or the sound cue), never before, and the edit supports it: a two-frame snap punch-in, a freeze with a slow push, a jump cut to the reaction.
29
+ - Match each feeling to one treatment and one palette: curiosity gets bouncing question marks; shock a freeze, a two-frame white flash, a jagged burst frame, converging speed lines redrawn every two frames and a decaying shake; awkwardness a sliding sweat drop, gloom lines and cool lavender; delight twinkling sparkles in pink and magenta; a verdict a worn rubber stamp slammed from about 2.3× to 1× in three frames; a snarky aside small slanted hand lettering in parentheses.
30
+ - Keep faces and the gag itself clear, keep a show bug in one corner, and stay inside about 5 % safe margins. Use idiomatic copy and correct punctuation in the captions' language, including CJK punctuation.
31
+
32
+ Use the [production recipe](references/recipe.md) for the bundled original study and port-specific decisions. It is a technique example, not a prescribed film. Keep motions a function of absolute seconds; separate drawing cadence from camera/light cadence when the material calls for it. Prepare assets locally, preserve async seeking and register actual resource/cue times. Keep essential type editable and localized.
33
+
34
+ ## Review and delivery
35
+
36
+ Inspect each caption's landing against its beat (two to three frames late, never early), reading time on every caption, overlap with faces and the show bug, outline order between neighbouring letters, fallback fonts and phone-size legibility. Check representative full-resolution frames, moving playback, desktop/mobile fit and cold seeks. Follow `animaguru-video` for production records, audio mixing and HTML5 delivery; export MP4 when requested. Describe the actual 2D/2.5D rendering method accurately. Physically rendered 3D remains deferred.
@@ -0,0 +1,27 @@
1
+ # Variety Captions production recipe
2
+
3
+ The workspace includes an executable original study in `src/studies/scenes.ts`, selected with `variety-captions`. Its drawing commands are consumed by `src/studies/pixi-renderer.ts`; geometry and time sampling are shared with the gallery renderer. All copy lives in `shared/locales/en/style-studies.json`.
4
+
5
+ In an untouched starter, run `npm run example -- variety-captions`, then `npm run dev`. The command refuses to replace an authored film, retains starter backups in `production/example-backup/`, and writes an 8-second study with its own resource tracks. `npm run export:html` and `npm run export:mp4` use this installed film normally. To use the recipe inside an existing film, import `studyFrame()` and `StudyRenderer`, then compose the selected scene into the project's timeline instead of running the installer.
6
+
7
+ ## Footage, fonts and sound
8
+
9
+ The user's own clip comes first. Otherwise: Wikimedia Commons (CC0 or public domain; read each file page, and not CC BY-SA or "no known restrictions"), Pexels or Pixabay only under a licence the user accepts, and the Internet Archive for public-domain material. Fonts come from Google Fonts or the foundry under the SIL Open Font License, bundled in `public/` with the licence text. Comedy sound effects (boing, pop, ding, slide whistle, record scratch) are generated or taken from CC0 libraries such as Freesound's CC0 filter. Record every asset's source, author and licence in `production/CREDITS.md`, and write a cue sheet in `production/` listing each gag beat and the caption that answers it.
10
+
11
+ ## Adaptation decisions
12
+
13
+ - Build each caption line from prebuilt per-glyph `Text` objects measured after the fonts load, in four sibling containers (shadow, outer outline, white outline, fill) so every outline sits under every fill. Animate the glyph containers' position, rotation and scale, never the font size, and use a `FillGradient` with a hard stop for the gloss.
14
+ - Draw the burst frame, the converging speed lines, sweat drops and sparkles as `Graphics`, reseeded from the frame index every two frames so the hand-drawn flicker repeats exactly on seeks.
15
+ - Make the freeze by holding the footage at its source time (a still captured to a `RenderTexture` for video), push in with a container transform, and run the white flash and the shake on the same footage container, so captions stay locked above the picture.
16
+ - Prepare the rubber stamp once as an ink-worn `Sprite` texture and slam it with a scale tween and a short shake of the footage container.
17
+
18
+ The study is silent, original procedural art: a flat stand-in cat, mug and counter, labelled as illustrative footage. It demonstrates the caption layers, timing and reaction graphics, not a finished episode; replace its stand-ins with the user's footage. The gallery preview uses a procedurally rendered stand-in cat and kitchen, labelled as not real footage, to show the system at full finish. Configure the subject, palette, scene sequence, duration and soundtrack from the brief.
19
+
20
+ Use `src/studies/timing.ts` for deterministic easing, seeded variation, measured stroke truncation and cue helpers. `window.animaguru.texts` exposes visible study text bounds for `npm run review`; text declarations alone do not count as visual proof. Inspect the actual frames as well.
21
+
22
+ ## Sources and attribution
23
+
24
+ Inspired by the following pinned references; no source code, fonts, footage or audio from them is bundled. New study art and scene geometry are original. The workspace's `VIDEO-STYLE-NOTICES.txt` preserves the repository notices and clarifies third-party asset scope.
25
+
26
+ - [prompts/18-hanazi.md](https://github.com/Vincentwei1021/mg-styles-15/blob/49052d80bfaf9fa8412cc9710530e5e95a3b8d8c/prompts/18-hanazi.md)
27
+ - [demos/18-hanazi/index.html](https://github.com/Vincentwei1021/mg-styles-15/blob/49052d80bfaf9fa8412cc9710530e5e95a3b8d8c/demos/18-hanazi/index.html)
@@ -102,7 +102,7 @@ The installed style skills cover visual treatment and motion technique across su
102
102
 
103
103
  ## Original studies and production review
104
104
 
105
- Browse `animaguru.templates.json` for all 48 styles and purpose workflows. The 37 expanded, evidence, Machine Anatomy and documentary & broadcast styles include a runnable original study; use `npm run example -- blueprint-drafting` in an untouched starter. Evidence- and documentary-style studies use illustrative values, labelled as such; a production film replaces them with real data, imagery, footage or scans. The command backs up the starter, refuses to replace an authored film, and connects the study to the normal player, timeline and HTML5/MP4 exporters. To compose studies in an existing film, import their scene and renderer instead.
105
+ Browse `animaguru.templates.json` for all 55 styles and purpose workflows. The 44 expanded, evidence, Machine Anatomy, documentary & broadcast and contemporary motion-design styles include a runnable original study; use `npm run example -- blueprint-drafting` in an untouched starter. Evidence- and documentary-style studies use illustrative values, labelled as such; a production film replaces them with real data, imagery, footage or scans. The command backs up the starter, refuses to replace an authored film, and connects the study to the normal player, timeline and HTML5/MP4 exporters. To compose studies in an existing film, import their scene and renderer instead.
106
106
 
107
107
  `npm run review` starts a local browser review session. Open the printed URL; it saves representative JPEG frames, actual text bounds, reading-hold notes and repeated-seek comparisons in `production/review/`. The numerical report complements frame inspection and full-speed listening. Optional media trim/time mapping and cue helpers are in `src/studies/`. All procedural example art is original; source inspirations and MIT notices are recorded in `VIDEO-STYLE-NOTICES.txt`.
108
108
 
@@ -76,3 +76,27 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
76
76
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
77
77
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
78
78
  SOFTWARE.
79
+
80
+
81
+ mg-styles-15
82
+ MIT License
83
+
84
+ Copyright (c) 2026 Vincentwei1021
85
+
86
+ Permission is hereby granted, free of charge, to any person obtaining a copy
87
+ of this software and associated documentation files (the "Software"), to deal
88
+ in the Software without restriction, including without limitation the rights
89
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
90
+ copies of the Software, and to permit persons to whom the Software is
91
+ furnished to do so, subject to the following conditions:
92
+
93
+ The above copyright notice and this permission notice shall be included in all
94
+ copies or substantial portions of the Software.
95
+
96
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
97
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
98
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
99
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
100
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
101
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
102
+ SOFTWARE.
@@ -300,6 +300,62 @@
300
300
  "Procedural graphics",
301
301
  "Rundown · tick · flip"
302
302
  ]
303
+ },
304
+ "aurora-glass": {
305
+ "name": "Aurora & Glass",
306
+ "lines": [
307
+ "Light, held in glass.",
308
+ "Original aurora and glass study · invented product",
309
+ "Halcyon"
310
+ ]
311
+ },
312
+ "liquid-motion": {
313
+ "name": "Liquid Motion",
314
+ "lines": [
315
+ "One drop becomes the mark.",
316
+ "Original liquid study",
317
+ "Pour · neck · snap · drip"
318
+ ]
319
+ },
320
+ "bauhaus-geometry": {
321
+ "name": "Bauhaus Geometry",
322
+ "lines": [
323
+ "Every move lands on the grid.",
324
+ "Original geometric study",
325
+ "Kreis · Dreieck · Quadrat"
326
+ ]
327
+ },
328
+ "synthwave-y2k": {
329
+ "name": "Synthwave & Y2K",
330
+ "lines": [
331
+ "Rewind to the future.",
332
+ "Original scene / tape variant",
333
+ "LASER COAST"
334
+ ]
335
+ },
336
+ "sticker-explainer": {
337
+ "name": "Sticker Explainer",
338
+ "lines": [
339
+ "One board, one camera, one fact per line.",
340
+ "Procedural stickers · illustrative figures",
341
+ "Every line brings a new sticker or chart"
342
+ ]
343
+ },
344
+ "variety-captions": {
345
+ "name": "Variety Captions",
346
+ "lines": [
347
+ "Every gag gets its caption.",
348
+ "Illustrative footage",
349
+ "...what mug?"
350
+ ]
351
+ },
352
+ "one-line-art": {
353
+ "name": "One-Line Art",
354
+ "lines": [
355
+ "One line, never lifted.",
356
+ "Invented brand",
357
+ "Sun · arches · profile · ring"
358
+ ]
303
359
  }
304
360
  }
305
361
  }
@@ -775,6 +775,397 @@ const scenes: Record<StudyId, (d: Drawing, t: number) => void> = {
775
775
  d.footer("#8b9097");
776
776
  d.text(d.lines[2], 480, 40, 18, "#f2cf1d", "center").opacity = progress(t, 7, .4);
777
777
  },
778
+ "aurora-glass": (d, t) => {
779
+ d.background(gradient("#07061a", "#0c0a28", 0, 0, 0, 540));
780
+ // Aurora: stacked translucent ellipses stand in for blurred colour fields; each drifts on its own slow loop.
781
+ const fields: [string, number, number, number, number][] = [["#7b4dff", 230, 170, 230, .3], ["#3c5bff", 560, 330, 260, 1.9], ["#2aa7ff", 790, 160, 200, 3.1], ["#1fd3c0", 380, 470, 230, 4.4]];
782
+ for (const [color, x, y, r, phase] of fields) {
783
+ const cx = x + Math.sin(t * .2 + phase) * 60, cy = y + Math.cos(t * .16 + phase) * 28;
784
+ for (let i = 0; i < 16; i++) d.ellipse(cx, cy, r * (1 - i * .058), r * .68 * (1 - i * .058), color, .045);
785
+ }
786
+ // Sine easing only: nothing overshoots. The cards fade back as the lens becomes the title capsule.
787
+ const calm = (at: number, duration: number) => .5 - .5 * Math.cos(Math.PI * clamp((t - at) / duration));
788
+ const widen = calm(6, .9), fade = 1 - .6 * widen, words = 1 - .95 * widen;
789
+ // Frosted cards: a lifted shadow, a faint white fill and a hairline edge; the nearer card drifts further.
790
+ const glass = (x: number, y: number, w: number, h: number, at: number, depth: number) => {
791
+ const k = calm(at, 1.2) * fade, px = x + Math.sin(t * .33 + depth) * 5 * depth, py = y + 12 * (1 - calm(at, 1.2));
792
+ d.rect(px + 4, py + 14, w - 8, h, "#040312", 24, .3 * k);
793
+ d.rect(px, py, w, h, "#ffffff", 24, .1 * k);
794
+ const edge = d.rect(px, py, w, h, "none", 24, .4 * k);
795
+ edge.stroke = "#ffffff";
796
+ edge.strokeWidth = 1;
797
+ return [px, py] as const;
798
+ };
799
+ // Text enters only with opacity and an 8 px rise.
800
+ const rise = (value: string, x: number, y: number, size: number, at: number, fill = "#ffffff", weight: "400" | "700" = "400") => {
801
+ const k = calm(at, .7), node = d.text(value, x, y + 8 * (1 - k), size, fill);
802
+ node.weight = weight;
803
+ node.opacity = k * words;
804
+ return node;
805
+ };
806
+ const [ax, ay] = glass(120, 150, 280, 210, .2, .5);
807
+ rise("TODAY", ax + 24, ay + 22, 11, .8, "#c9c2ff", "700");
808
+ ["Deep work 09:30", "Design review 13:00", "Evening walk 18:15"].forEach((row, i) => rise(row, ax + 24, ay + 58 + i * 46, 17, .9 + i * .18, "#e4e0ff"));
809
+ const [bx, by] = glass(360, 190, 420, 190, .7, 1);
810
+ rise("What matters most today?", bx + 26, by + 40, 25, 1.3);
811
+ rise("Two quiet hours before noon.", bx + 26, by + 84, 17, 2.6, "#d6d2f5");
812
+ for (let i = 0; i < 22; i++) {
813
+ const h = 4 + 16 * (1 - .75 * calm(2.6, .8)) * (.5 + .5 * Math.sin(t * 5 + i * .6)) * Math.sin(Math.PI * (i + .5) / 22);
814
+ d.rect(bx + 30 + i * 7, by + 150 - h / 2, 3, h, "#9fc0ff", 1.5, calm(1.8, .7) * words);
815
+ }
816
+ // A clear lens glides across the question, then slows and widens into the title capsule.
817
+ if (t > 3.8) {
818
+ const glide = calm(3.8, 2.2), cx = lerp(-80, 480, glide), cy = lerp(240, 270, widen), hw = lerp(70, 210, widen), hh = lerp(70, 48, widen);
819
+ d.rect(cx - hw + 4, cy - hh + 12, hw * 2 - 8, hh * 2, "#040312", Math.min(hw, hh), .25);
820
+ const lens = d.rect(cx - hw, cy - hh, hw * 2, hh * 2, "#ffffff", Math.min(hw, hh), .07 + .1 * widen);
821
+ lens.stroke = "#ffffff";
822
+ lens.strokeWidth = 1.5;
823
+ d.ellipse(cx - hw * .42, cy - hh * .58, hw * .26, hh * .08, "#ffffff", .4 * (1 - widen));
824
+ const title = calm(6.4, .8), name = d.text(d.lines[2], 480, 248 + 8 * (1 - title), 34, "#ffffff", "center");
825
+ name.weight = "400";
826
+ name.opacity = title;
827
+ }
828
+ d.title("#f2efff");
829
+ d.footer("#a9a3d9");
830
+ },
831
+ "liquid-motion": (d, t) => {
832
+ // Drops hit a pool, three blobs tear free on thinning necks and fuse, two wave fronts flood across, and the
833
+ // fused drop stretches into a pill and pours into a word, then a drip snaps off its p.
834
+ const flung = (k: number) => 1 - (1 - clamp(k)) ** 4, violet = "#7a36ff", pool = 420;
835
+ const drops: [number, number, string][] = [[280, .4, "#ff3f8c"], [640, .7, violet], [470, .95, "#ffae2a"]];
836
+ d.background(gradient("#2c0f45", "#12061e", 0, 0, 0, 540));
837
+ const surface: Point[] = [[0, 540]];
838
+ for (let x = 0; x <= 960; x += 16) {
839
+ let y = pool + Math.sin(x * .012 + t * 1.4) * 5;
840
+ for (const [at, hit] of drops) if (t > hit) y += 16 * Math.exp(-(((x - at) / 34) ** 2) - 5 * (t - hit)) * Math.cos(14 * (t - hit));
841
+ surface.push([x, y]);
842
+ }
843
+ surface.push([960, 540]);
844
+ d.poly(surface, "#ff7b1c");
845
+ for (const [x, hit, color] of drops) {
846
+ const k = hit - t;
847
+ if (k > 0 && k < .5) d.ellipse(x, pool - 18 - 1800 * k * k, 14, 20, color);
848
+ // Crown droplets follow the splash a few frames late and fall back into the pool.
849
+ if (k <= -.08 && k > -.5) for (const side of [-1, 1]) d.ellipse(x + side * 110 * (-k - .08), pool - 8 - 150 * (-k - .08) + 400 * (k + .08) ** 2, 4.5, 4, color);
850
+ }
851
+ const blobs: [number, number, number, string, number][] = [[480, 225, 58, violet, 1.2], [370, 250, 34, "#ff3f8c", 1.32], [590, 246, 30, "#ffae2a", 1.4]];
852
+ const fuse = flung((t - 2.1) / .5);
853
+ // Followers are drawn first so they sink into the leader as they fuse.
854
+ if (t < 3.3) [2, 1, 0].forEach((i) => {
855
+ const [x, y, r, color, launch] = blobs[i];
856
+ if (t <= launch) return;
857
+ const k = flung((t - launch) / .8), after = t - launch - .3, base = lerp(x, 480, .5);
858
+ const cx = lerp(base, x, k), cy = lerp(pool + r * .4, y, k), size = i ? lerp(r, r * .2, fuse) : lerp(r, 72, fuse);
859
+ // The neck thins until it snaps; the blob then rings back from a stretch to round.
860
+ if (after < 0) d.line([[base, pool], [cx, cy + r * .6]], color, lerp(r * .5, r * .12, (t - launch) / .3));
861
+ const ring = after < 0 ? 1.15 : 1 + .12 * Math.exp(-9 * after) * Math.cos(26 * after);
862
+ d.ellipse(i ? lerp(cx, 480 + (i === 1 ? -14 : 14), fuse) : cx, i ? lerp(cy, 228, fuse) : cy, size / Math.sqrt(ring), size * ring, color).opacity = i ? 1 - fuse : 1;
863
+ });
864
+ // Two fronts with four bulges each that swell out of phase; the second one becomes the new ground.
865
+ for (const [color, start] of [["#ff4b45", 2.62], ["#f6ecdf", 2.86]] as const) {
866
+ if (t < start) continue;
867
+ const sweep = -240 + 1500 * flung((t - start) / 1), front: Point[] = [[-20, 0]];
868
+ for (let y = 0; y <= 540; y += 10) front.push([sweep + .2 * (y - 270) + [80, 210, 345, 470].reduce((sum, at, b) => sum + 70 * Math.exp(-(((y - at) / 45) ** 2)) * (.6 + .4 * Math.sin(8 * t + b * 1.7)), 0), y]);
869
+ front.push([-20, 540]);
870
+ d.poly(front, color);
871
+ }
872
+ if (t >= 3.3) {
873
+ // Drop, then a pill, then the word: a simple intermediate shape keeps the pour readable.
874
+ const pill = flung((t - 3.58) / .4), word = flung((t - 3.9) / .7), squash = progress(t, 3.36, .22) * (1 - pill);
875
+ if (word < 1) d.rect(lerp(408, 300, pill), lerp(156, 196, pill) + 20 * squash, lerp(144, 360, pill), lerp(144, 92, pill) * (1 - .3 * squash), violet, lerp(72, 46, pill), 1 - word);
876
+ const mark = d.text("pour.", 0, -60, 128, violet, "center");
877
+ mark.opacity = word;
878
+ mark.transform = { x: 480, y: 250, scaleX: 1 + .06 * Math.exp(-6 * Math.max(0, t - 4.6)) * Math.cos(14 * (t - 4.6)), scaleY: 1 };
879
+ if (t > 5.6) {
880
+ const sag = clamp((t - 5.85) / 1) ** 2.2 * 60, fall = Math.max(0, t - 6.9), y = 342 + sag + 1600 * fall * fall;
881
+ if (t < 6.9) d.line([[337, 328], [337, y - 8]], violet, lerp(9, 2.4, sag / 60));
882
+ if (y < 560) d.ellipse(337, y, 10, 12 + Math.min(6, fall * 30), violet);
883
+ }
884
+ d.text(d.lines[2], 480, 430, 18, "#3b1658", "center").opacity = progress(t, 5.15, .5);
885
+ }
886
+ d.title(t < 3.1 ? "#f3e9ff" : "#3b1658");
887
+ d.footer(t < 3.1 ? "#b9a4cf" : "#7a6488");
888
+ },
889
+ "bauhaus-geometry": (d, t) => {
890
+ d.background("#f1e9da");
891
+ d.title("#151413");
892
+ d.footer("#6d6558");
893
+ // A 16 × 8 grid of 40 px modules centred on the frame; one action per half-second beat, eased without overshoot.
894
+ const M = 40, gx = (c: number) => 160 + c * M, gy = (r: number) => 110 + r * M, beat = (k: number) => k * .5;
895
+ const quad = (x: number) => { const k = clamp(x); return k < .5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2; };
896
+ const act = (k: number, length = .3) => quad((t - beat(k)) / length);
897
+ for (let r = 0; r <= 8; r++) d.line([[gx(0), gy(r)], [gx(16), gy(r)]], "#cdc2ae", 1, clamp((t - r * .03) / .3));
898
+ for (let c = 0; c <= 16; c++) d.line([[gx(c), gy(0)], [gx(c), gy(8)]], "#cdc2ae", 1, clamp((t - .1 - c * .02) / .3));
899
+ // Positions are the finished poster; until beat 8 everything is shown turned 180° about the frame centre.
900
+ const spin = (p: Point, a: number, o: Point): Point => [o[0] + (p[0] - o[0]) * Math.cos(a) - (p[1] - o[1]) * Math.sin(a), o[1] + (p[0] - o[0]) * Math.sin(a) + (p[1] - o[1]) * Math.cos(a)];
901
+ const turn = Math.PI * (1 - act(8, .5)), place = (points: Point[]) => points.map((p) => spin(p, turn, [gx(8), gy(4)]));
902
+ const quarter = (o: Point, r: number, from: number): Point[] => [o, ...Array.from({ length: 13 }, (_, i): Point => [o[0] + Math.cos(from + i / 24 * Math.PI) * r, o[1] + Math.sin(from + i / 24 * Math.PI) * r])];
903
+ const box = (x: number, y: number, w: number, h: number): Point[] => [[x, y], [x + w, y], [x + w, y + h], [x, y + h]];
904
+ // Beat 1 the circle slides eight modules in, beat 6 two more; beat 2 a bar runs out at constant speed.
905
+ if (t >= beat(1)) {
906
+ const [c] = place([[gx(t < beat(6) ? lerp(-6, 2, act(1)) : lerp(2, 4, act(6))), gy(3)]]);
907
+ d.ellipse(c[0], c[1], 2 * M, 2 * M, "#e03c31");
908
+ }
909
+ if (t >= beat(2)) d.poly(place(box(gx(-4), gy(5), (gx(10) - gx(-4)) * clamp((t - beat(2)) / .32), M / 2)), "#151413");
910
+ // Beat 3 the triangle swings about its right angle; beat 4 the quarter disc sweeps about its corner.
911
+ if (t >= beat(3)) {
912
+ const a: Point = [gx(10), gy(5)], swing = Math.PI / 2 * (1 - act(3));
913
+ d.poly(place([a, spin([gx(6), gy(5)], swing, a), spin([gx(10), gy(1)], swing, a)]), "#f2b705");
914
+ }
915
+ if (t >= beat(4)) d.poly(place(quarter([gx(0), gy(8)], 2 * M, -Math.PI / 2 - Math.PI / 2 * (1 - act(4)))), "#1e4fa3");
916
+ // Beat 5 an array of quarter discs lights column by column; beats 9–11 send three phase-offset waves through it.
917
+ const inks = ["#e03c31", "#f2b705", "#1e4fa3", "#151413"];
918
+ for (let j = 0; j < 2; j++) for (let i = 0; i < 8; i++) {
919
+ if (t < beat(5) + (i * 2 + j) * .028) continue;
920
+ let turns = 0, colour = ([[0, 1, 3, 3, 2, 0, 1, 1], [3, 2, 0, 1, 1, 3, 2, 0]][j][i] + 3) % 4;
921
+ for (const [k, dir, recolour] of [[9, 1, 0], [10, 1, 1], [11, -1, 0]]) {
922
+ const q = quad((t - beat(k) - (dir > 0 ? i : 7 - i) * .042 - j * .021) / .16);
923
+ turns += q;
924
+ if (recolour && q >= .5) colour++;
925
+ }
926
+ const corner = ([[2, 3, 1, 0, 2, 3, 1, 0], [1, 0, 3, 2, 1, 0, 3, 2]][j][i] + 1) % 4, x = gx(3 + i), y = gy(6 + j);
927
+ const corners: Point[] = [[x, y], [x + M, y], [x + M, y + M], [x, y + M]], centre: Point = [x + M / 2, y + M / 2];
928
+ const disc = quarter(corners[corner], M, corner * Math.PI / 2).map((p) => spin(p, turns * Math.PI / 2, centre));
929
+ d.poly(place(disc), inks[colour % 4]);
930
+ }
931
+ // Beat 7 a rule fences off the type column; beat 12 the title cuts in; beat 13 the small caps and a red bar.
932
+ if (t >= beat(7)) d.poly(place(box(gx(11.5) - 2, gy(0), 4, (gy(8) - gy(0)) * clamp((t - beat(7)) / .3))), "#151413");
933
+ if (t >= beat(12)) d.text("FORM", 0, 0, 92, "#151413").transform = { x: gx(12) + 34, y: gy(8), rotation: -Math.PI / 2 };
934
+ if (t >= beat(13)) {
935
+ d.text(d.lines[2].toUpperCase(), 0, 0, 13, "#151413", "left", "mono").transform = { x: gx(12) + 4, y: gy(8), rotation: -Math.PI / 2 };
936
+ d.rect(lerp(gx(3), gx(6), act(13)), gy(1) - 14, 3 * M, 6, "#e03c31");
937
+ }
938
+ },
939
+ "synthwave-y2k": (d, t) => {
940
+ // A neon grid runs at a slatted sun, a chrome title slams in, then a pop-up is dragged aside and the tape glitches.
941
+ const hz = 318, st = t > 5.4 && t < 6 ? sampled(t, 15) : t, sunY = hz + 30 - 150 * progress(st, .2, 2.4);
942
+ d.background(gradient("#05021a", "#c42b7f", 0, 0, 0, hz));
943
+ for (let i = 0; i < 70; i++)
944
+ d.ellipse(hash(i + 7) * 960, hash(i + 70) * 250, .8 + hash(i) * 1.1, .8 + hash(i) * 1.1, "#f4e8ff", (.3 + .5 * hash(i + 3)) * (.6 + .4 * Math.sin(st * 2 + i)));
945
+ d.ellipse(480, sunY, 112, 112, gradient("#fff4a3", "#ff2b9c", 0, sunY - 112, 0, sunY + 112));
946
+ // Slats cut into the sun's lower half, sliding down; each is painted in the sky colour behind it.
947
+ for (let i = 0; i < 6; i++) {
948
+ const y = sunY + 10 + i * 17 + (st * 10) % 17;
949
+ if (y < hz) d.rect(360, y, 240, 2 + i * 1.5, "#8a1f73");
950
+ }
951
+ d.poly([[0, hz], [0, 250], [70, 228], [150, 262], [230, 216], [330, hz]], "#14062d", "#38d8ff", 1.5);
952
+ d.poly([[960, hz], [960, 236], [880, 210], [800, 258], [720, 232], [630, hz]], "#14062d", "#38d8ff", 1.5);
953
+ d.rect(0, hz, 960, 540 - hz, gradient("#3d0d55", "#07021a", 0, hz, 0, 540));
954
+ for (let i = -12; i <= 12; i++)
955
+ d.line([[480 + i * 16, hz], [480 + i * 150, 540]], "#ff35c8", 1.4);
956
+ for (let k = 0; k < 14; k++) {
957
+ const z = k + 1 - (st * 2.4) % 1, y = hz + 230 / z;
958
+ if (y < 540) d.line([[0, y], [960, y]], "#ff35c8", 1.2 + 6 / z);
959
+ }
960
+ d.rect(0, hz - 1, 960, 2, "#ff9ad2");
961
+ // Chrome title: a stepped extrusion, a bright face and a hot outline, scaled down hard into place.
962
+ const slam = st < 2.8 ? 0 : spring(st - 2.8, 3, 1, 14), visible = progress(st, 2.8, .1);
963
+ for (let i = 7; i >= 0; i--) {
964
+ const n = d.text(d.lines[2], i * .8, i * 1.1 - 34, 62, i === 0 ? "#e8f6ff" : i === 7 ? "#ff2fb8" : "#3a1060", "center");
965
+ n.transform = { x: 480, y: 210, scaleX: slam, scaleY: slam };
966
+ n.opacity = visible;
967
+ }
968
+ if (st > 3.3 && st < 3.9)
969
+ d.ellipse(300 + 360 * progress(st, 3.3, .6), 182, 18, 2, "#ffffff", Math.sin((st - 3.3) / .6 * Math.PI));
970
+ // A pop-up window: dragged aside as a dashed outline, then closed like a tube switching off.
971
+ const pop = st < 4.6 ? 0 : spring(st - 4.6, 0, 1, 16), drag = progress(st, 5.4, .5), close = 1 - progress(st, 6.6, .25);
972
+ if (pop > .01 && close > .01) {
973
+ const x = 330 - 210 * drag, y = 330 + 40 * drag;
974
+ const win = d.rect(-150, -70, 300, 140, "#ece8ff", 8);
975
+ const bar = d.rect(-150, -70, 300, 22, gradient("#c8ff2e", "#8a2bff", -150, 0, 150, 0), 8);
976
+ const label = d.text("TAPE.EXE · Play the night?", -138, -34, 14, "#1b0d42");
977
+ const play = d.rect(30, 26, 100, 28, "#b6f22a", 14), go = d.text("PLAY", 80, 31, 14, "#1d3300", "center");
978
+ for (const n of [win, bar, label, play, go]) n.transform = { x: x + 150, y: y + 70, scaleX: pop, scaleY: pop * close };
979
+ }
980
+ d.title("#f4e8ff");
981
+ d.footer("#c9a8e8");
982
+ // The tape: scanlines everywhere, and a tracking tear once a second.
983
+ for (let y = 0; y < 540; y += 4)
984
+ d.rect(0, y, 960, 1.5, "#000000", 0, .12);
985
+ const tear = (t % 1) < .06 ? 1 : 0;
986
+ if (tear) d.rect(0, 380 + hash(Math.floor(t)) * 120, 960, 10, "#f4e8ff", 0, .35);
987
+ },
988
+ "sticker-explainer": (d, t) => {
989
+ d.background("#e6ebef");
990
+ // One board, one camera: a die-cut sticker, a chart, then the board pulled back to the sum. Board nodes share
991
+ // the camera transform; the title, footer and narration line stay fixed on screen.
992
+ const cup = [480, 270, 1], chart = [1300, 270, 1], board = [880, 460, .5];
993
+ const move = (from: number[], to: number[], at: number, length: number, dip: number) => {
994
+ const k = clamp((t - at) / length), e = k * k * (3 - 2 * k);
995
+ return [lerp(from[0], to[0], e), lerp(from[1], to[1], e), lerp(from[2], to[2], e) * (1 - dip * Math.sin(Math.PI * k))];
996
+ };
997
+ const [cx, cy, z] = t < 4.6 ? move(cup, chart, 2.2, 1, .3) : move(chart, board, 4.6, 1.2, 0);
998
+ const view = { x: 480 - cx * z, y: 270 - cy * z, scaleX: z, scaleY: z };
999
+ const on = (node: StudyNode) => { node.transform = view; return node; };
1000
+ for (let i = 0; i < 220; i++)
1001
+ on(d.ellipse((i % 22) * 80 + 20, Math.floor(i / 22) * 80 + 20, 2, 2, "#b9c4ce", .7));
1002
+ // The cup sticker drops onto the board: a white die-cut margin, a soft shadow, then the "photo" inside it.
1003
+ const s = t < .15 ? 0 : spring(t - .15, 1.3, 1, 14);
1004
+ if (s > 0) {
1005
+ const local = { x: view.x + 300 * z, y: view.y + 290 * z, rotation: -.06, scaleX: z * s, scaleY: z * s };
1006
+ const art = (node: StudyNode) => { node.transform = local; return node; };
1007
+ art(d.ellipse(7, 10, 100, 100, "#1d2834", .16));
1008
+ art(d.ellipse(0, 0, 98, 98, "#ffffff"));
1009
+ art(d.ellipse(0, 0, 86, 86, "#f3f1ec"));
1010
+ art(d.rect(44, -9, 34, 18, "#f7f5f0", 9));
1011
+ art(d.ellipse(-4, -4, 56, 56, "#fbfaf7"));
1012
+ art(d.ellipse(-4, -4, 47, 47, "#9a5a2e"));
1013
+ art(d.ellipse(-13, -10, 11, 11, "#f2e6d3"));
1014
+ art(d.ellipse(5, -10, 11, 11, "#f2e6d3"));
1015
+ art(d.poly([[-23, -6], [15, -6], [-4, 18]], "#f2e6d3"));
1016
+ }
1017
+ on(d.line([[384, 336], [440, 360]], "#1d2834", 2, progress(t, .6, .3)));
1018
+ on(d.text("cappuccino · one shot", 448, 350, 18, "#1d2834")).opacity = progress(t, .8, .3);
1019
+ on(d.text("How many beans", 470, 196, 44, "#1d2834")).opacity = progress(t, .3, .4);
1020
+ on(d.text("make one cup?", 470, 246, 44, "#1d2834")).opacity = progress(t, .4, .4);
1021
+ // A thread links the facts; the camera travels along it.
1022
+ on(d.line([[850, 250], [930, 200], [1010, 205], [1090, 240]], "#93a1ae", 3, progress(t, 2, 1.2)));
1023
+ // Exact bars on one zero-based scale, growing one after another.
1024
+ on(d.text("Top growers · million bags", 1110, 150, 20, "#1d2834")).opacity = progress(t, 2.8, .3);
1025
+ ([["Brazil", 66], ["Vietnam", 27], ["Colombia", 12], ["Ethiopia", 8]] as const).forEach(([name, value], i) => {
1026
+ const grow = progress(t, 3 + i * .12, .6), y = 190 + i * 44;
1027
+ on(d.text(name, 1100, y + 4, 17, "#1d2834", "right")).opacity = grow;
1028
+ on(d.rect(1110, y, value * 5 * grow, 30, i === 0 ? "#ff5a36" : "#1d2834", 2));
1029
+ on(d.text(String(Math.round(value * grow)), 1120 + value * 5 * grow, y + 4, 17, i === 0 ? "#ff5a36" : "#1d2834")).opacity = grow;
1030
+ });
1031
+ // The sum waits in the middle of the board until the camera pulls back to it.
1032
+ const sum = progress(t, 5.6, 1);
1033
+ on(d.text(`≈ ${Math.round(6250 * sum).toLocaleString("en-US")}`, 880, 560, 110, "#1d2834", "center")).opacity = progress(t, 5.5, .3);
1034
+ on(d.rect(700, 690, 360 * progress(t, 6.4, .5), 10, "#ff5a36"));
1035
+ on(d.text("espressos from one 60 kg sack", 880, 712, 30, "#5a6b7c", "center")).opacity = progress(t, 6.2, .4);
1036
+ d.rect(250, 418, 460, 36, "#16202b", 18, .88);
1037
+ d.text(d.lines[2], 480, 426, 17, "#ffffff", "center");
1038
+ d.title("#1d2834");
1039
+ d.footer("#5a6b7c");
1040
+ },
1041
+ "variety-captions": (d, t) => {
1042
+ // Stand-in vlog footage (a cat, a mug, a counter edge) with a caption landing a beat after every gag.
1043
+ const freeze = t >= 3 && t < 4, ft = freeze ? 3 : t, beat = 2.5 / 24;
1044
+ const pop = (k: number) => k <= 0 ? 0 : k >= 1 ? 1 : k < .55 ? 1.15 * ease(k / .55) : 1.15 - .15 * ((k - .55) / .45);
1045
+ const wiggle = (seed: number) => Math.sin(t * 7.1 + seed * 2.3);
1046
+ // Three layers per caption: a coloured outline, a white outline and the fill, outlines as rings of offset copies.
1047
+ const caption = (text: string, x: number, y: number, size: number, fill: string, outer: string, k: number, rotation = 0) => {
1048
+ const s = pop(k);
1049
+ if (s <= 0) return;
1050
+ const transform = { x, y, rotation, scaleX: s, scaleY: s };
1051
+ for (const [color, r] of [[outer, size * .17], ["#ffffff", size * .09]] as const)
1052
+ for (let i = 0; i < 12; i++) d.text(text, Math.cos(i / 12 * Math.PI * 2) * r, Math.sin(i / 12 * Math.PI * 2) * r - size * .55, size, color, "center").transform = transform;
1053
+ d.text(text, 0, -size * .55, size, fill, "center").transform = transform;
1054
+ };
1055
+ d.background(gradient("#f1e9dc", "#dccfba", 0, 0, 0, 540));
1056
+ // Defocused kitchen and the counter.
1057
+ d.ellipse(150, 170, 80, 56, "#b9d39d", .55);
1058
+ d.ellipse(260, 240, 60, 60, "#e8a463", .5);
1059
+ d.ellipse(800, 220, 90, 44, "#f0b55c", .45);
1060
+ d.rect(0, 372, 960, 70, gradient("#e3c497", "#c9a476", 0, 372, 0, 442));
1061
+ d.rect(0, 440, 960, 100, "#7f978a");
1062
+ // The cat; its paw reaches for the mug, then goes up to its mouth after the cut.
1063
+ const groom = t >= 5.2, blink = groom ? .8 : hash(Math.floor(t * 2)) > .9 ? .9 : 0;
1064
+ d.ellipse(380, 320, 78, 72, "#f0a24a");
1065
+ d.ellipse(380, 336, 40, 52, "#fbf2e4");
1066
+ d.poly([[336, 168], [346, 112], [380, 150]], "#f0a24a");
1067
+ d.poly([[424, 168], [414, 112], [380, 150]], "#f0a24a");
1068
+ d.ellipse(380, 196, 56, 48, "#f0a24a");
1069
+ for (const ex of [358, 402]) {
1070
+ d.ellipse(ex, 192, 11, 12 * (1 - blink) + .5, "#d8df4a");
1071
+ d.ellipse(ex, 192, 2.5, 9 * (1 - blink) + .5, "#1b1408");
1072
+ }
1073
+ d.ellipse(380, 214, 6, 4, "#f2a3a6");
1074
+ const reach = t >= 4 ? 0 : clamp(Math.sin(Math.max(0, ft - 1.5) * 10.5) * .6 + (ft > 2.6 ? 1 : 0), 0, 1);
1075
+ const paw: Point = groom ? [392, 232 + Math.sin(t * 19) * 4] : [lerp(420, 492, reach), lerp(390, 352, reach)];
1076
+ d.line([[410, 300], paw], "#f0a24a", 22);
1077
+ d.ellipse(paw[0], paw[1], 13, 10, "#fbf2e4");
1078
+ // The mug slides to the edge and tips over; it is gone after the freeze.
1079
+ if (t < 4) {
1080
+ const slide = progress(ft, 2.62, .14), tip = clamp((ft - 2.76) / .3);
1081
+ const m = d.rect(-30, -70, 60, 70, "#e9897c", 8);
1082
+ m.transform = { x: 520 + slide * 60 + tip * 30, y: 410 + tip * tip * 40, rotation: tip * .7 };
1083
+ d.rect(-30, -44, 60, 9, "#f6e8d6").transform = m.transform;
1084
+ }
1085
+ if (freeze) {
1086
+ // Freeze frame: a white flash, converging speed lines, then the burst frame with the shock line.
1087
+ for (let i = 0; i < 40; i++) {
1088
+ const a = hash(i + Math.floor(t * 12) * 41) * Math.PI * 2, w = .012;
1089
+ d.poly([[560 + Math.cos(a) * 200, 330 + Math.sin(a) * 200], [560 + Math.cos(a + w) * 700, 330 + Math.sin(a + w) * 700], [560 + Math.cos(a - w) * 700, 330 + Math.sin(a - w) * 700]], "#ffffff");
1090
+ }
1091
+ d.rect(0, 0, 960, 540, "#ffffff", 0, clamp(1 - (t - 3) / .12));
1092
+ const k = clamp((t - 3 - beat) / .33), burst: Point[] = [];
1093
+ for (let i = 0; i < 36; i++) {
1094
+ const a = i / 36 * Math.PI * 2, r = i % 2 ? .82 : 1.12 + hash(i) * .2;
1095
+ burst.push([Math.cos(a) * 270 * r, Math.sin(a) * 82 * r]);
1096
+ }
1097
+ const shake = 10 * Math.exp(-(t - 3 - beat) * 9) * Math.sin(t * 90);
1098
+ d.poly(burst, "#ffe14a", "#ff2d55", 8).transform = { x: 480 + shake, y: 120, rotation: -.06, scaleX: pop(k), scaleY: pop(k) };
1099
+ caption("NOT THE MUG!!", 480 + shake, 146, 52, "#ff3d6a", "#a8001c", k, -.06);
1100
+ }
1101
+ // Curiosity: a question mark a beat after each tap.
1102
+ [1.68, 1.98, 2.28].forEach((tap, i) => { if (t < 2.62) caption("?", 600 + i * 70, 250 - i * 30, 46 + i * 8, "#3fd6ff", "#1d2b6b", clamp((t - tap - beat) / .33), (i % 2 ? .15 : -.18) + wiggle(i) * .05); });
1103
+ // Awkward: the deadpan line and a sliding sweat drop.
1104
+ if (t >= 4 && t < 5.2) {
1105
+ caption(d.lines[2], 480, 470, 44, "#c9b6ff", "#4a2f9e", clamp((t - 4.3 - beat) / .33), wiggle(3) * .03);
1106
+ const drop = clamp((t - 4.4) / .8);
1107
+ d.path(`M${466},${120 + drop * 50} C${476},${136 + drop * 50} ${478},${148 + drop * 50} ${466},${152 + drop * 50} C${454},${148 + drop * 50} ${456},${136 + drop * 50} ${466},${120 + drop * 50} Z`, "#7fd0ff", "#2a5bd7", 2).opacity = clamp((t - 4.35) * 6);
1108
+ }
1109
+ // Delight: sparkles, then the stamp slams down.
1110
+ if (groom) {
1111
+ caption("ZERO REGRETS", 720, 300, 50, "#ff5cb0", "#4b1c7a", clamp((t - 5.45) / .33), wiggle(5) * .04);
1112
+ for (let i = 0; i < 5; i++) {
1113
+ const r = 10 * pop(clamp((t - 5.7 - i * .08) / .3)) * (.7 + .3 * Math.sin(t * 10 + i));
1114
+ const [x, y] = [[580, 230], [880, 250], [600, 360], [860, 350], [500, 140]][i];
1115
+ d.path(`M${x},${y - r} Q${x},${y} ${x + r},${y} Q${x},${y} ${x},${y + r} Q${x},${y} ${x - r},${y} Q${x},${y} ${x},${y - r} Z`, "#fff6a8", "#ffffff", 1.5);
1116
+ }
1117
+ const ks = clamp((t - 6.6) / .12), sc = 1.8 - .8 * ease(ks);
1118
+ if (ks > 0) {
1119
+ const stamp = { x: 740, y: 140, rotation: -.16, scaleX: sc, scaleY: sc };
1120
+ const frame = d.rect(-110, -38, 220, 76, "#ffffff", 14, ks);
1121
+ frame.fill = "none"; frame.stroke = "#d4232b"; frame.strokeWidth = 6; frame.transform = stamp;
1122
+ d.text("CERTIFIED MENACE", 0, -12, 22, "#d4232b", "center").transform = stamp;
1123
+ }
1124
+ }
1125
+ d.title("#3a2a5a");
1126
+ d.footer("#6b5f7a");
1127
+ },
1128
+ "one-line-art": (d, t) => {
1129
+ d.background(gradient("#101a2b", "#070b13"));
1130
+ // One champagne line that never lifts: a sun on the horizon, three arches, a profile, then a ring that fills with gold.
1131
+ const path: Point[] = [[40, 400], [180, 400]];
1132
+ const arc = (cx: number, cy: number, r: number, a0: number, a1: number) => {
1133
+ for (let i = 1; i <= 40; i++) path.push([cx + Math.cos(lerp(a0, a1, i / 40)) * r, cy + Math.sin(lerp(a0, a1, i / 40)) * r]);
1134
+ };
1135
+ arc(180, 345, 55, Math.PI / 2, -Math.PI * 1.5);
1136
+ path.push([300, 400]);
1137
+ for (let i = 0; i < 3; i++) {
1138
+ const x = 300 + i * 80;
1139
+ path.push([x, 335]);
1140
+ arc(x + 30, 335, 30, Math.PI, Math.PI * 2);
1141
+ path.push([x + 60, 400]);
1142
+ if (i < 2) path.push([x + 80, 400]);
1143
+ }
1144
+ path.push([560, 400], [586, 398], [600, 388], [606, 370], [603, 352]);
1145
+ arc(592, 340, 12, 0.8, 0.8 - Math.PI * 2);
1146
+ arc(640, 312, 46, Math.PI * 0.95, Math.PI * 1.9);
1147
+ path.push([688, 305], [685, 313], [699, 329], [693, 333], [695, 341], [690, 343], [692, 349], [691, 358], [686, 366], [671, 371], [675, 400],
1148
+ [790, 400], [814, 398], [834, 388], [850, 365]);
1149
+ arc(850, 300, 65, Math.PI / 2, -Math.PI * 1.5);
1150
+ const letter: Point[] = [[850, 365], [838, 350], [836, 262], [844, 258], [842, 270], [842, 345], [880, 345], [888, 336]];
1151
+ path.push(...letter.slice(1));
1152
+ const drawn = clamp((t - 0.05) / 5.8), visible = strokePoints(path, drawn), tip = visible[visible.length - 1];
1153
+ d.line(path, "#e9d7a5", 2, drawn);
1154
+ const fill = progress(t, 5.9, 0.8);
1155
+ if (fill > 0) {
1156
+ d.ellipse(850, 300, 62 * fill, 62 * fill, gradient("#f6e6b8", "#9a7838", 790, 240, 910, 360));
1157
+ d.line(letter, "#0b1320", 2, 1).opacity = fill;
1158
+ }
1159
+ if (drawn < 1) {
1160
+ d.ellipse(tip[0], tip[1], 12, 12, "#fff4d6", 0.16);
1161
+ d.ellipse(tip[0], tip[1], 3, 3, "#fff8e6", 1);
1162
+ }
1163
+ const mark = progress(t, 6.5, 0.7);
1164
+ d.text("MAISON LIGNÉE", 480, 462, 24, "#ecdcb0", "center", "serif").opacity = mark;
1165
+ d.text(d.lines[2], 480, 486, 11, "#cdbb8e", "center").opacity = progress(t, 6.9, 0.6) * 0.8;
1166
+ d.title("#ecdcb0");
1167
+ d.footer("#8f8566");
1168
+ },
778
1169
  };
779
1170
  function ninety(t: number) { return 87 + 9 * (1 - progress(t, .2)); }
780
1171
  export function studyFrame(id: StudyId, seconds: number): StudyFrame {
@@ -13,5 +13,9 @@
13
13
  "url": "https://github.com/wheresryan22/anatomy",
14
14
  "commit": "1482e8469b0a9e0d8cc18ea2740a01423d29ae80"
15
15
  },
16
- "scope": "Material and motion guidance; original scene study implementations. No source demo assets, data or creator footage bundled. The Machine Anatomy kit file src/kit/anatomy/iso-kit.ts is vendored unchanged from anatomy. editDocumentaryCommit pins lemo-opuscar's Edit & Documentary category (caption-news, newsreel, countdown, city-symphony, visual-investigation, kino-montage, telestrator, news-package); none of its archive footage, documents or audio are bundled, only guidance."
16
+ "mg-styles-15": {
17
+ "url": "https://github.com/Vincentwei1021/mg-styles-15",
18
+ "commit": "49052d80bfaf9fa8412cc9710530e5e95a3b8d8c"
19
+ },
20
+ "scope": "Material and motion guidance; original scene study implementations. No source demo assets, data or creator footage bundled. The Machine Anatomy kit file src/kit/anatomy/iso-kit.ts is vendored unchanged from anatomy. editDocumentaryCommit pins lemo-opuscar's Edit & Documentary category (caption-news, newsreel, countdown, city-symphony, visual-investigation, kino-montage, telestrator, news-package); none of its archive footage, documents or audio are bundled, only guidance. mg-styles-15 supplied the signature features of aurora-glass, liquid-motion, bauhaus-geometry, synthwave-y2k, sticker-explainer, variety-captions and one-line-art; none of its code, films, photos, fonts or audio samples are bundled, only guidance."
17
21
  }