@docyrus/animaguru 0.32.0 → 0.33.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.
package/NOTICE.md CHANGED
@@ -1473,7 +1473,7 @@ SOFTWARE.
1473
1473
 
1474
1474
  Video style sources and notices
1475
1475
 
1476
- The executable studies and preview art are original AnimaGuru procedural 2D/2.5D work. Material/motion guidance was adapted from the pinned repositories below. The Machine Anatomy geometry kit (src/kit/anatomy/iso-kit.ts) is a verbatim copy from anatomy; its MIT notice is also in src/kit/anatomy/LICENSE. No external creator video, sample music or demo font is bundled. Third-party demo assets retain their own licenses.
1476
+ The executable studies and preview art are original AnimaGuru procedural 2D/2.5D work. Material/motion guidance was adapted from the pinned repositories below. The Machine Anatomy geometry kit (src/kit/anatomy/iso-kit.ts) is a verbatim copy from anatomy; its MIT notice is also in src/kit/anatomy/LICENSE. No external creator video, sample music or demo font is bundled. Third-party demo assets retain their own licenses. Cartoon Host Explainer adapts the look, timings and structure of a free explainer-reel prompt shared in a public post on X (https://x.com/finnardley/status/2116010421740007894), restated in its own words; no code, footage, characters or prompt text from it are included, so it has no notice below.
1477
1477
 
1478
1478
  lemo-opuscar
1479
1479
  MIT License
package/README.md CHANGED
@@ -57,9 +57,11 @@ CLI 0.28.0 also adds `animaguru skills install`, which installs every AnimaGuru
57
57
 
58
58
  CLI 0.28.0 also adds four 3D styles in a new 3D scenes family, each with a runnable three.js study (`npm run example -- product-turntable-3d`): 3D Product Turntable, 3D Character Stage, 3D Toy Diorama and 3D Exploded Teardown. Their skills build on `animaguru-3d-models` for generated and rigged models; the studies are modelled in code.
59
59
 
60
+ CLI 0.33.0 adds **Cartoon Host Explainer** (`cartoon-host-explainer`): the talking-host explainer reel. A drawn presenter in the corner talks the whole time while each spoken word appears in the caption, a condensed title slams in with a red underline, and halftone-shadowed cards show each point before a demo acts it out. Its skill writes and approves a 30–60 s script first, cues everything from real word timestamps, drives the host's ten mouth shapes from the voice's loudness, and defaults to 9:16. `npm run example -- cartoon-host-explainer` installs its original study.
61
+
60
62
  CLI 0.16.2 also installs `animaguru-art-motion` for Codex and Claude Code: one skill with 36 English art-treatment references and nine explainer motion grammars, plus AnimaGuru generation, timing, camera and review guidance. Request it explicitly for a custom film treatment; its references are separate from the gallery presets. Source coverage and the upstream MIT notice are included in the skill.
61
63
 
62
- CLI 0.5.2 includes a complete local PixiJS video workspace, now with 59 visual styles:
64
+ CLI 0.5.2 includes a complete local PixiJS video workspace, now with 60 visual styles:
63
65
 
64
66
  ```sh
65
67
  animaguru templates
@@ -75,7 +77,7 @@ Open the generated folder in **Codex or Claude Code**, then describe the video y
75
77
  - Agent instructions that first ask **Explainer, Advertisement or General Video**, then offer the available visual styles and [the preview gallery](https://animaguru.com/animation-styles), then gather a short creative brief. Each choice is saved before continuing.
76
78
  - `film.config.json`, editable TypeScript scenes, localized copy, local asset/production folders, portable HTML5 ZIP export for iframe embedding, and `npm run export:mp4` with exact frame capture and output verification.
77
79
 
78
- New projects leave category and template unset in `animaguru.project.json`. `animaguru.templates.json` lists categories, styles and their installed skills. The 55 visual styles cover cutout, type, vector, technical, evidence, documentary, character, print, paper, material and cinematic treatments. Every style skill ships with the project, including its relevant visual interview and review criteria. Categories describe purpose and templates describe appearance and motion. The brief establishes audience, tone, message, story, duration and any CTA.
80
+ New projects leave category and template unset in `animaguru.project.json`. `animaguru.templates.json` lists categories, styles and their installed skills. The 60 visual styles cover cutout, type, vector, technical, evidence, documentary, character, print, paper, material and cinematic treatments. Every style skill ships with the project, including its relevant visual interview and review criteria. Categories describe purpose and templates describe appearance and motion. The brief establishes audience, tone, message, story, duration and any CTA.
79
81
 
80
82
  CLI 0.23.0 also adds **Machine Anatomy** (`machine-anatomy`): an idea explained as a working isometric machine of many small shaded parts in a framed figure card with a live readout. Adapted from the MIT-licensed [Anatomy](https://github.com/wheresryan22/anatomy) skill, it bundles that skill's geometry kit in `src/kit/anatomy/` with a PixiJS adapter, and `npm run example -- machine-anatomy` installs its original study.
81
83
 
package/dist/animaguru.js CHANGED
@@ -19761,7 +19761,7 @@ var require_ignore2 = __commonJS({
19761
19761
  // cli/package.json
19762
19762
  var package_default = {
19763
19763
  name: "@docyrus/animaguru",
19764
- version: "0.32.0",
19764
+ version: "0.33.0",
19765
19765
  description: "Agent-friendly AnimaGuru CLI built with CrustJS",
19766
19766
  type: "module",
19767
19767
  bin: {
@@ -46425,6 +46425,23 @@ var animation_styles_default = {
46425
46425
  previewCaption: "An invented handheld console comes apart along its assembly axes as the camera orbits, every part keeping its label. Modelled and rendered in code.",
46426
46426
  family: "3d",
46427
46427
  example: true
46428
+ },
46429
+ "cartoon-host-explainer": {
46430
+ name: "Cartoon Host Explainer",
46431
+ description: "A drawn host in the corner talks the whole time while every spoken word appears in the caption, a condensed title slams in with a red underline, and halftone-shadowed cards show each point, then exit for a demo that acts it out.",
46432
+ materials: [
46433
+ "An original host with glasses, flat colours and brown outlines",
46434
+ "Cream paper, mono labels and red accents",
46435
+ "White cards with halftone shadows and red stamps"
46436
+ ],
46437
+ motion: [
46438
+ "Words turn from grey to ink as they are spoken",
46439
+ "Slammed titles, rising cards and halftone wipes",
46440
+ "A host who talks, blinks, nods and points"
46441
+ ],
46442
+ previewCaption: "Juno, an invented caf\xE9 owner, states the problem, promises three free fixes and gives the first. A bookings card is struck through, small cards pop in and the cost is ringed; a halftone wipe brings the title, which slams in on its word, then a card that counts up, a spinning badge and a typed search. Everything exits upward for a demo where DOUBLE is stamped down, and a last wipe loops back to the start. Invented caf\xE9, illustrative figures.",
46443
+ family: "character",
46444
+ example: true
46428
46445
  }
46429
46446
  },
46430
46447
  browseStyles: "Browse animation styles",
@@ -46578,7 +46595,8 @@ var VIDEO_STYLE_IDS = [
46578
46595
  "product-turntable-3d",
46579
46596
  "character-stage-3d",
46580
46597
  "toy-diorama-3d",
46581
- "exploded-teardown-3d"
46598
+ "exploded-teardown-3d",
46599
+ "cartoon-host-explainer"
46582
46600
  ];
46583
46601
  var VIDEO_WORKFLOW_IDS = [
46584
46602
  "product-proof",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docyrus/animaguru",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Agent-friendly AnimaGuru CLI built with CrustJS",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: animaguru-cartoon-host-explainer
3
+ description: Build Cartoon Host Explainer 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
+ # Cartoon Host Explainer
8
+
9
+ The talking-host explainer reel: a drawn presenter, cropped at the chest in the lower right, talks the whole time while the screen spells out what they say. Captions appear word by word as they are spoken, a condensed two-word title slams in on its word with a red swash underline, and white cards with halftone shadows show each point and then act out its explanation. It is a 30–60 second vertical reel by default and reads like a curious friend explaining something, not like an ad. 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#cartoon-host-explainer). Fold missing questions into the shared interview:
14
+
15
+ - What is the topic, and what must the film mention (a product, an offer, a link)?
16
+ - Who is the host: a drawn version of the user (from a photo they supply), the brand's mascot, or a new character? Name, look and colours.
17
+ - Whose voice: the user's own recording, or a generated voice?
18
+ - Where will it run: 9:16 for Reels, Shorts and TikTok (the default), or 16:9?
19
+
20
+ Then write the script yourself and show it before anything is recorded: a two-line hook (line 1 states the problem in under six words, line 2 promises the payoff), three to seven points, and one closing line that pays the hook off. Revise until the user approves it.
21
+
22
+ ## Keep the host original
23
+
24
+ Design the host for this brief. Never copy another creator's presenter, mascot or signature look, and never draw a real person other than the user without that person's agreement. When the host is the user, work from their own photo and say so in the production notes. A statistic, review or result shown on a card comes from the user or a cited source in `production/FACTS.md`; invented examples say so on screen.
25
+
26
+ ## Build the look and movement
27
+
28
+ Read `pixijs` and the relevant Container, Graphics, Text, Sprite and Ticker skills. Read `animaguru` before generating assets; discover current models and estimates, respect the user's budget, and reuse completed media. Captions, titles, labels, counters and card text stay authored canvas content, never baked into a picture.
29
+
30
+ Sizes below are for 1080 × 1920; scale them with the frame.
31
+
32
+ - **Paper and colour.** Warm cream paper (#EBE7E1) with fine grain, ink #1E1C1A, red #D7372C, grey #8A857E. Cards are #FBFAF8 with a 3 px ink border, 20 px corners and a halftone shadow: 1.7 px ink dots on an 8 px grid, offset 13 px left and 12 px down. The host's clothes can follow the brand; the furniture stays paper, ink and red.
33
+ - **Type.** Captions in Instrument Sans 500 at 62 px, top left (x 120, y 196, 850 px wide). One key word per line in Instrument Serif italic at 1.16× size, in red. Titles in Anton up to 176 px, first word ink and second red, with a hand-drawn red swash underline. Labels in JetBrains Mono 25 px grey at y 132: "// 01 · name" on the left, a stat on the right with its number in red bold. Stamps are Anton 52 px red in a 5 px red box, tilted −6°. All four families are under the SIL Open Font License: bundle the files and their licence in `public/fonts/` and load them with `loadFonts` from `src/kit/fonts.ts`.
34
+ - **The host.** An original cartoon bust with flat colours and 6 px dark-brown outlines, about 650 × 770 px from x 430, y 1170, built from parts: head, brows, eyelids, glasses or other features, ten mouth shapes and two hands. They never cover captions, titles or card text.
35
+ - **Never:** gradients, glow, soft drop shadows, stock photos, emoji, full stops in on-screen text, or any second where nothing moves.
36
+
37
+ **Structure.** The hook's line 1 brings a UI card showing the problem and a red strike-through draws across it; line 2 pops small cards in 0.16 s apart and draws a red ring around the payoff. A halftone wipe runs 0.3 s before point one and again before the last point. Each point: the label updates, the title slams in on its spoken word, the underline draws 0.12 s later, the main card rises 0.18 s after that with a number counting up over 1.15 s, a red #N badge spins in about 0.6 s later, and a terminal or search bar types one line in under 0.9 s. 0.3 s before the explanation everything exits upward and a demo acts out exactly what the voice says, with one stamp landing on its key word. The closing line pays off the hook, and the last frame matches the first so the reel loops.
38
+
39
+ **Motion vocabulary.** Pop: scale 0.55 → 1 in 0.34 s with a back-out overshoot (2.2). Rise: 70 px up in 0.4 s, cubic ease-out. Exit: 50 px up and fade out in 0.22 s. Slam: scale 1.22 → 1 in 0.24 s, quartic ease-out. Stamp: scale 1.75 → 1 and −18° → −6° in 0.2 s. Wipe: a 13 × 23 grid of ink dots grows to about 62 px from the centre out, then shrinks away, 0.9 s in all.
40
+
41
+ **Voice drives everything.** Generate line 1 in three voices and let the user pick, then record the whole script at about 1.1× speed. Cut silences longer than 0.24 s down to 0.24 s, only where the voice is truly silent. Take word timestamps from the final voice track and match them to the script: every caption word, title, stamp and gesture starts on a real word's start time, never an estimate. Each caption word appears in light grey #B5AFA7 on its frame and turns ink (or red) in 0.2 s.
42
+
43
+ **The host acts.** Pick the mouth shape every frame from the voice's loudness: closed at −42 dB, wide open at −16 dB, instant attack and a 0.55-per-frame release. Blink for 0.14 s every 2.4–4.2 s on a fixed seed. Sway the head slightly all the time and nod for 0.5 s on each key word. Raise a pointing hand from below for 1.7 s at every new point, and an open palm on every other explanation.
44
+
45
+ **Sound.** One sound effect per event: a pop on cards, a whoosh on scene changes, a tick roll under counters, typing under the terminal and a thud on stamps, each placed so its loudest point lands on its frame. Music sits far under the voice (about 0.16 of its level) and fades out over 0.8 s; master around −14 to −15 LUFS.
46
+
47
+ Use the [production recipe](references/recipe.md) for the tools, the host rig, word timing, the bundled original study and port-specific decisions. It is a technique example, not a prescribed film. Keep motions a function of absolute seconds. Prepare assets locally, preserve async seeking and register actual resource/cue times. Keep essential type editable and localized.
48
+
49
+ ## Review and delivery
50
+
51
+ Check that every caption word, title, stamp and gesture lands on its spoken word, that the mouth is shut in every pause and at the end, that the badge number matches the point, that no on-screen text has a full stop, that nothing holds still for over a second, that no text sits under the host or outside the safe area (`"safeArea": "reels"` keeps text clear of feed buttons and captions), and that the last frame matches the first. Before the final render, look at a frame from the start of every point. Check moving playback with sound, 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 rendering method accurately.
@@ -0,0 +1,43 @@
1
+ # Cartoon Host Explainer production recipe
2
+
3
+ The workspace includes an executable original study in `src/studies/scenes.ts`, selected with `cartoon-host-explainer`. 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 -- cartoon-host-explainer`, 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
+ ## Frame, fonts and layers
8
+
9
+ Set `film.config.json` to 1080 × 1920 at 30 fps with `"safeArea": "reels"`, and read `animaguru-video`'s `references/short-form-ad.md` for the vertical command sequence. Download Instrument Sans, Instrument Serif (italic), Anton and JetBrains Mono from Google Fonts into `public/fonts/` with their `OFL.txt`, and load them with `loadFonts` before the first frame. Stack five layers from the bottom: paper, scenes, presenter, captions, wipes. Give each point its own scene `Container` with its own timing function of absolute seconds, so a point can be moved or cut without retiming the others.
10
+
11
+ ## Voice and word timing
12
+
13
+ 1. Generate line 1 in three voices with `animaguru assets generate … --type speech` and let the user choose; then generate one segment per sentence in that voice (`assets batch`, see `animaguru`).
14
+ 2. In `production/audio/vo.json` set `"tempo": 1.1` and `"maxPause": 0.24`, then run `npm run audio:vo`. It caps only real silences and writes `production/audio/work/vo-track.wav` and caption times in `production/audio/timing.json`.
15
+ 3. Take word start times from that final track, not from the script. With the user's agreement, install faster-whisper (`pip install faster-whisper`; it runs locally and free), transcribe `vo-track.wav` with `word_timestamps=True`, match the recognised words to the script in order, and save `production/audio/words.json` as `[{ "word", "start", "end" }]`. Fix any word the matcher could not place by listening, and say so. Only if no word timestamps can be made, spread each caption's words over its `timing.json` span by length and tell the user the sync is approximate.
16
+ 4. Cue everything from `words.json`: caption words, the title slam, the stamp, the badge and every gesture start on a word's `start`.
17
+
18
+ ## The presenter rig
19
+
20
+ - Draw the host once as separate parts. Either generate an SVG with `animaguru assets generate … --type vector --shapes simple` (a front-facing bust on a plain background, flat colours, one outline weight) and load it as geometry with `parseAsGraphicsContext`, or draw the parts in code with `Graphics`. Keep a `Container` hierarchy: body (breathes), head (sways, nods and tilts about the neck), brows, eyelids, glasses, ten mouth shapes as children with one visible, and two arms.
21
+ - Mouth: decode `vo-track.wav` with `decodePcm` and take `peakLevels` at one hop per film frame (both in `scripts/audio-tools.mjs`). Convert each level to dB, map −42 dB to shape 0 and −16 dB to shape 9, apply an instant attack and multiply by 0.55 per frame on release, and save the shape indices as `production/audio/mouth.json`. Pick the shape from that array by frame.
22
+ - Blinks from a seeded random sequence (0.14 s every 2.4–4.2 s), head sway as sums of sines whose periods divide the film length so the loop closes, and a 0.5 s nod on every key word.
23
+ - Arms: a pose table of elbow and wrist positions, hand angle, raised fingers and thumb (down, pointing up, open palm). Draw each arm per frame as two `Graphics` strokes along shoulder → elbow → wrist, an outline stroke wider than a coloured core with round caps, so the outline stays even. Raise the pointing hand from below the frame for 1.7 s at each point and the palm on alternate explanations, snapping in about 0.25 s with an overshoot.
24
+ - Halftone: render one dot tile to a texture and fill an offset copy of a silhouette with it (a `TilingSprite` masked by the shape, or a pattern-filled `Graphics`). Cards and the host halo share the tile.
25
+
26
+ ## Captions, titles, cards and wipes
27
+
28
+ - Lay each caption out once: measure every word with `CanvasTextMetrics.measureText` in its style (sans, or the red serif italic for the key word) and place one `Text` per word, so each can change colour on its frame without reflowing the line. Hidden words still hold their space. Put captions in plain lines, never in a flex layout that drops the spaces.
29
+ - The title is two `Text` objects; the swash underline is a slightly wavy `Graphics` stroke under the red word, drawn on by arc length with `strokePoints` from `src/studies/timing.ts`. Counters use fixed-width digit cells.
30
+ - Hide every element until its cue by computing visibility from time, not by setting it at frame zero, or it flashes on the first frame.
31
+ - The wipe is a 13 × 23 grid of ink circles (23 × 13 in 16:9) whose radius rises and falls with a delay proportional to each dot's distance from the centre; switch scenes at its fullest moment.
32
+
33
+ ## Sound
34
+
35
+ Take effects from `animaguru library search … --type sfx` or generate them with `--type sfx`, one file per event. Find each file's loudest point with `peakLevels` and offset it so that point lands on its event frame. Run `npm run audio:mix` with `"musicDb"` set low (about −16 dB) for the voice and music, then mix the effects in with FFmpeg (`adelay` per effect, `amix`), keep them under the voice, and measure loudness again.
36
+
37
+ The study is silent, original procedural art with an invented café and illustrative figures, labelled as such; it demonstrates the furniture, word-timed captions, host rig, motions and wipe, not a real subject. Its captions use Arial Bold advance estimates because the study's node model cannot measure text; a production film measures real text. The gallery preview draws its host, Juno, entirely in code, with macOS stand-ins for the four fonts. Replace the stand-ins with the user's host, points and sourced figures described in the skill. Configure the subject, palette, beats, duration and soundtrack from the brief.
38
+
39
+ 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.
40
+
41
+ ## Sources and attribution
42
+
43
+ Adapted from a free explainer-reel prompt posted on X by @finnardley on 30 October 2026 (https://x.com/finnardley/status/2116010421740007894). Its palette, type, motion timings, structure and presenter rules are restated here in our own words; its HyperFrames, Kokoro and faster-whisper toolchain is replaced by this workspace's PixiJS film, AnimaGuru speech and audio tools, with faster-whisper kept as the optional word-timing step. No footage, characters, code, fonts or audio from the post are bundled, and the host, study and preview art are original. The workspace's `VIDEO-STYLE-NOTICES.txt` clarifies third-party asset scope.
@@ -73,7 +73,7 @@ npm run export:mp4 -- --watermark
73
73
 
74
74
  Open the local capture URL printed by the command and keep it open until complete. The script runs a separate loopback Vite server (default port 5196), streams high-quality JPEG frames from the Pixi canvas to FFmpeg, and mixes the configured soundtrack. An AAC soundtrack at 48 kHz that lasts the whole film, such as `npm run audio:mix` writes, is copied into the MP4 as it is; any other soundtrack is encoded to AAC and padded to length. The initial starter exports with a silent AAC track. Exporting local media does not call AnimaGuru or spend credits. The export stops before capturing when FFmpeg or FFprobe is missing or older than 7.1.
75
75
 
76
- Completed H.264/AAC MP4s and JSON verification reports appear in `production/exports/`. Captions are not drawn into the MP4 picture: the film's `captions` resource track is saved beside it as `SLUG-LANGUAGE.srt` (for YouTube and desktop players, which load a same-named file automatically) and `SLUG-LANGUAGE.vtt` (for an HTML5 `<track>`). `--burn-captions` instead draws them into the picture and writes no subtitle files, so players cannot show them twice. `"poster"` in `film.config.json` (seconds) picks the frame saved as `SLUG-LANGUAGE.jpg`, a caption-free thumbnail 1280 pixels on its longer side and under YouTube's 2 MB limit; without it, no thumbnail is saved. The live player and HTML5 export keep their CC toggle. `--watermark` also saves `SLUG-LANGUAGE-watermarked.mp4`, the same film with AnimaGuru's "Made with AnimaGuru" badge in its bottom-right corner, drawn with the font, logo and layout AnimaGuru's web app uses; it is the preview AnimaGuru's lead pipeline plays before a lead claims the clean film. The picture is encoded again with the badge and verified like the film; the sound is copied. Existing files are preserved. Frame retries are deduplicated. Ctrl+C removes only this session's unfinished output. The verification checks duration, exact frame count, dimensions, pixel format, codecs and audio language tag. It also measures the MP4's own sound (integrated loudness and true peak, saved as `soundtrack` in the JSON report) and warns when its true peak is above -1 dBTP. The exporter supports even dimensions up to 4096 pixels, 24/25/30/60 fps and films up to ten minutes. Set duration to an exact number of frames.
76
+ Completed H.264/AAC MP4s and JSON verification reports appear in `production/exports/`. Captions are not drawn into the MP4 picture: the film's `captions` resource track is saved beside it as `SLUG-LANGUAGE.srt` (for YouTube and desktop players, which load a same-named file automatically) and `SLUG-LANGUAGE.vtt` (for an HTML5 `<track>`). `--burn-captions` instead draws them into the picture and writes no subtitle files, so players cannot show them twice. `"poster"` in `film.config.json` (seconds) picks the frame saved as `SLUG-LANGUAGE.jpg`, a caption-free thumbnail 1280 pixels on its longer side and under YouTube's 2 MB limit; without it, no thumbnail is saved. The live player and HTML5 export keep their CC toggle. `--watermark` also saves `SLUG-LANGUAGE-watermarked.mp4`, the same film with AnimaGuru's "Made with AnimaGuru" badge in its bottom-right corner, drawn with the font, logo and layout AnimaGuru's web app uses; it is the preview AnimaGuru's lead pipeline plays before a lead claims the clean film. The picture is encoded again with the badge and verified like the film; the sound is copied. Existing files are preserved. Frame retries are deduplicated. Ctrl+C removes only this session's unfinished output. The verification checks duration, exact frame count, dimensions, pixel format, codecs and audio language tag. It also measures the MP4's own sound (integrated loudness and true peak, saved as `soundtrack` in the JSON report) and warns when its true peak is above -1 dBTP. The exporter supports even dimensions up to 4096 pixels, 24/25/30/60 fps and films up to fifteen minutes. Set duration to an exact number of frames.
77
77
 
78
78
  ## Capture API
79
79
 
@@ -108,7 +108,7 @@ The installed style skills cover visual treatment and motion technique across su
108
108
 
109
109
  ## Original studies and production review
110
110
 
111
- Browse `animaguru.templates.json` for all 59 styles and purpose workflows. The 48 expanded, evidence, Machine Anatomy, documentary & broadcast, contemporary motion-design and 3D styles include a runnable original study; use `npm run example -- blueprint-drafting` (or a 3D one such as `product-turntable-3d`, which renders with three.js) 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.
111
+ Browse `animaguru.templates.json` for all 60 styles and purpose workflows. The 49 expanded, evidence, Machine Anatomy, documentary & broadcast, contemporary motion-design, 3D and Cartoon Host Explainer styles include a runnable original study; use `npm run example -- blueprint-drafting` (or a 3D one such as `product-turntable-3d`, which renders with three.js) 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.
112
112
 
113
113
  `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`.
114
114
 
@@ -1,6 +1,6 @@
1
1
  Video style sources and notices
2
2
 
3
- The executable studies and preview art are original AnimaGuru procedural 2D/2.5D work. Material/motion guidance was adapted from the pinned repositories below. The Machine Anatomy geometry kit (src/kit/anatomy/iso-kit.ts) is a verbatim copy from anatomy; its MIT notice is also in src/kit/anatomy/LICENSE. No external creator video, sample music or demo font is bundled. Third-party demo assets retain their own licenses.
3
+ The executable studies and preview art are original AnimaGuru procedural 2D/2.5D work. Material/motion guidance was adapted from the pinned repositories below. The Machine Anatomy geometry kit (src/kit/anatomy/iso-kit.ts) is a verbatim copy from anatomy; its MIT notice is also in src/kit/anatomy/LICENSE. No external creator video, sample music or demo font is bundled. Third-party demo assets retain their own licenses. Cartoon Host Explainer adapts the look, timings and structure of a free explainer-reel prompt shared in a public post on X (https://x.com/finnardley/status/2116010421740007894), restated in its own words; no code, footage, characters or prompt text from it are included, so it has no notice below.
4
4
 
5
5
  lemo-opuscar
6
6
  MIT License
@@ -92,6 +92,29 @@ export function placeVoiceover(segments, { start = 0.25, gap = 0.35, tempo = 1,
92
92
  return { tempo, segments: placed, captions, voEnd: round(previousEnd ?? start) };
93
93
  }
94
94
 
95
+ /**
96
+ * The filter graph that cuts each placed segment's pieces from its input (input N is segment N) and mixes them into one
97
+ * mono track of the film's length, labelled [out].
98
+ */
99
+ export function voiceTrackGraph(segments, { tempo = 1, duration }) {
100
+ const filters = [], labels = [];
101
+ for (const [index, segment] of segments.entries()) {
102
+ const parts = segment.pieces.map((_, piece) => `[s${index}p${piece}]`);
103
+ filters.push(`[${index}:a]aresample=${SAMPLE_RATE},aformat=channel_layouts=mono,asplit=${parts.length}${parts.join("")}`);
104
+ for (const [pieceIndex, piece] of segment.pieces.entries()) {
105
+ const label = `[v${labels.length}]`;
106
+ // Silence padded onto the reversed piece becomes its lead-in. FFmpeg 9's adelay leaves its own lead-in untimed
107
+ // after atrim, so amix started at the first real sound and every line came early by the first line's delay.
108
+ // atempo drops its first 1024 samples (21 ms), so it runs only when the tempo changes.
109
+ const lead = Math.round(piece.at * SAMPLE_RATE), padLead = lead ? `apad=pad_len=${lead},` : "", speed = tempo === 1 ? "" : `atempo=${tempo},`;
110
+ filters.push(`[s${index}p${pieceIndex}]atrim=${piece.trim[0]}:${piece.trim[1]},asetpts=PTS-STARTPTS,${speed}afade=t=in:d=0.015,areverse,afade=t=in:d=0.04,${padLead}areverse${label}`);
111
+ labels.push(label);
112
+ }
113
+ }
114
+ filters.push(`${labels.join("")}amix=inputs=${labels.length}:normalize=0,apad=whole_len=${Math.round(duration * SAMPLE_RATE)},atrim=0:${duration}[out]`);
115
+ return filters.join(";");
116
+ }
117
+
95
118
  async function existing(...files) {
96
119
  for (const file of files) { try { await access(file); return file; } catch { /* try the next candidate */ } }
97
120
  return null;
@@ -129,22 +152,10 @@ async function main() {
129
152
  const lastPiece = timing.segments.at(-1).pieces.at(-1);
130
153
  if (lastPiece.end > duration) throw new Error(`The voiceover runs to ${lastPiece.end.toFixed(2)} s, past the ${duration} s film. Shorten gaps, raise tempo or trim the script.`);
131
154
 
132
- const inputs = [], filters = [], labels = [];
133
- for (const [index, segment] of timing.segments.entries()) {
134
- inputs.push("-i", segments[index].file);
135
- const parts = segment.pieces.map((_, piece) => `[s${index}p${piece}]`);
136
- filters.push(`[${index}:a]aresample=${SAMPLE_RATE},aformat=channel_layouts=mono,asplit=${parts.length}${parts.join("")}`);
137
- for (const [pieceIndex, piece] of segment.pieces.entries()) {
138
- const label = `[v${labels.length}]`;
139
- filters.push(`[s${index}p${pieceIndex}]atrim=${piece.trim[0]}:${piece.trim[1]},asetpts=PTS-STARTPTS,atempo=${tempo},afade=t=in:d=0.015,areverse,afade=t=in:d=0.04,areverse,adelay=${Math.round(piece.at * 1000)}:all=1${label}`);
140
- labels.push(label);
141
- }
142
- }
143
- filters.push(`${labels.join("")}amix=inputs=${labels.length}:normalize=0,apad=whole_len=${Math.round(duration * SAMPLE_RATE)},atrim=0:${duration}[out]`);
144
155
  const work = path.join(base, "production/audio/work");
145
156
  await mkdir(work, { recursive: true });
146
157
  const track = path.join(work, "vo-track.wav");
147
- await ffmpeg(["-y", "-loglevel", "error", ...inputs, "-filter_complex", filters.join(";"), "-map", "[out]", "-ac", "1", "-ar", String(SAMPLE_RATE), "-c:a", "pcm_s16le", track]);
158
+ await ffmpeg(["-y", "-loglevel", "error", ...segments.flatMap((segment) => ["-i", segment.file]), "-filter_complex", voiceTrackGraph(timing.segments, { tempo, duration }), "-map", "[out]", "-ac", "1", "-ar", String(SAMPLE_RATE), "-c:a", "pcm_s16le", track]);
148
159
  const timingFile = path.join(base, "production/audio/timing.json");
149
160
  await writeJson(timingFile, { tempo, start: timing.segments[0].start, segments: timing.segments, captions: timing.captions, voEnd: timing.voEnd });
150
161
 
@@ -38,7 +38,7 @@ if (context.story) throw new Error("This film is a branching story (story.json):
38
38
  if (context.quiz) throw new Error("This film has a quiz (quiz.json): it plays as HTML5 only, because its questions wait for a viewer's answers. Deliver it with npm run export:html or animaguru submit.");
39
39
  const config = context.config;
40
40
  const { width, height, fps, duration, language } = config;
41
- if (![width, height].every((n) => Number.isInteger(n) && n >= 2 && n <= 4096 && n % 2 === 0) || ![24, 25, 30, 60].includes(fps) || !Number.isFinite(duration) || duration <= 0 || duration > 600 || Math.abs(duration * fps - Math.round(duration * fps)) > 0.000001) throw new Error("Use even dimensions up to 4096px, 24/25/30/60 fps, and a frame-aligned duration up to 600s in film.config.json.");
41
+ if (![width, height].every((n) => Number.isInteger(n) && n >= 2 && n <= 4096 && n % 2 === 0) || ![24, 25, 30, 60].includes(fps) || !Number.isFinite(duration) || duration <= 0 || duration > 900 || Math.abs(duration * fps - Math.round(duration * fps)) > 0.000001) throw new Error("Use even dimensions up to 4096px, 24/25/30/60 fps, and a frame-aligned duration up to 900s in film.config.json.");
42
42
  if (typeof language !== "string" || !/^[a-z]{2,3}(?:-[A-Za-z]{2,4})?$/.test(language)) throw new Error("Set a language code in film.config.json.");
43
43
  if (typeof config.slug !== "string" || !/^[a-z0-9][a-z0-9-]{0,79}$/.test(config.slug)) throw new Error("Use a lowercase, hyphenated film slug (1–80 characters).");
44
44
  const languageTag = mp4Language(language);
@@ -357,6 +357,14 @@
357
357
  "Invented brand",
358
358
  "Sun · arches · profile · ring"
359
359
  ]
360
+ },
361
+ "cartoon-host-explainer": {
362
+ "name": "Cartoon Host Explainer",
363
+ "lines": [
364
+ "One host, one card, one point per cut",
365
+ "Invented café · illustrative figures",
366
+ "Every spoken word lands on screen"
367
+ ]
360
368
  }
361
369
  },
362
370
  "threeStyles": {
@@ -1166,7 +1166,229 @@ const scenes: Record<StudyId, (d: Drawing, t: number) => void> = {
1166
1166
  d.title("#ecdcb0");
1167
1167
  d.footer("#8f8566");
1168
1168
  },
1169
+ "cartoon-host-explainer": (d, t) => {
1170
+ d.background("#ebe7e1");
1171
+ d.grain("#1e1c1a", .05, 180);
1172
+ // One countdown point and its explanation. The title slams on its spoken word; the underline, card, counter,
1173
+ // badge and a typed search bar follow on fixed offsets. Everything exits upward, a demo acts the explanation out
1174
+ // with a stamp on its key word, and a halftone wipe returns to the first frame so the study loops.
1175
+ const INK = "#1e1c1a", RED = "#d7372c", GREY = "#8a857e", MUTED = "#b5afa7", CARD = "#fbfaf8";
1176
+ d.text("// 01 · stamp card", 48, 30, 13, GREY, "left", "mono").weight = "400";
1177
+ d.text("TIP 1/3", 912, 30, 13, RED, "right", "mono");
1178
+ // Captions: one sentence at a time; each word appears grey on its spoken time and turns ink (key word red).
1179
+ const lines: [string, number, number?][][] = [
1180
+ [["One:", .2], ["a", .5], ["stamp", .62, 1], ["card", .94]],
1181
+ [["that", 1.5], ["brings", 1.66], ["regulars", 1.92, 1], ["back", 2.3]],
1182
+ [["Give", 4.4], ["double", 4.58, 1], ["stamps", 4.9], ["on", 5.18], ["Mondays", 5.3]],
1183
+ [["and", 5.9], ["Monday", 6.02], ["fills", 6.3], ["up", 6.46], ["first", 6.58, 1]],
1184
+ ];
1185
+ let open = 0, nod = 0;
1186
+ lines.forEach((line, index) => {
1187
+ const end = lines[index + 1]?.[0][1] ?? 7.6;
1188
+ let x = 48;
1189
+ line.forEach(([word, at, key], i) => {
1190
+ const size = key ? 35 : 30, next = line[i + 1]?.[1] ?? at + .4, length = Math.min(next - at - .03, .4);
1191
+ if (t >= at && t < end) d.text(word, x, 62, size, progress(t, at, .2) < .5 ? MUTED : key ? RED : INK, "left", key ? "serif" : "sans");
1192
+ x += boldWidth(word, size) * (key ? 1.1 : 1) + 7;
1193
+ if (t >= at && t < at + length) open = Math.sin((t - at) / length * Math.PI) ** .6;
1194
+ if (key && t >= at && t < at + .5) nod = 7 * Math.sin((t - at) / .5 * Math.PI);
1195
+ });
1196
+ });
1197
+ // Motions: rise 70 px (here 52) with a cubic ease-out, exit 50 px (here 38) up with a fade over 0.22 s.
1198
+ const exit = progress(t, 3.86, .22), out = { y: -38 * exit * exit, a: 1 - exit };
1199
+ const lift = (node: StudyNode, at: number) => {
1200
+ const k = progress(t, at, .4), tr = node.transform ?? {};
1201
+ node.transform = { ...tr, y: (tr.y ?? 0) + 52 * (1 - ease(k)) + (t < 4 ? out.y : 0) };
1202
+ node.opacity = (node.opacity ?? 1) * clamp(k * 3) * (t < 4 ? out.a : 1);
1203
+ return node;
1204
+ };
1205
+ const shadowed = (x: number, y: number, w: number, h: number, at: number) => {
1206
+ for (let i = 0; i < Math.floor(w / 7); i++) lift(d.ellipse(x - 6 + i * 7, y + h + 4 + (i % 2) * 3, 1.3, 1.3, INK, .6), at);
1207
+ for (let i = 0; i < Math.floor(h / 7); i++) lift(d.ellipse(x - 5 - (i % 2) * 3, y + 10 + i * 7, 1.3, 1.3, INK, .6), at);
1208
+ const panel = lift(d.rect(x, y, w, h, CARD, 10), at);
1209
+ panel.stroke = INK;
1210
+ panel.strokeWidth = 2;
1211
+ };
1212
+ const cup = (cx: number, cy: number, k: number, seed: number, at: number) => {
1213
+ if (k <= 0) return;
1214
+ const transform = { x: cx, y: cy, rotation: (hash(seed) - .5) * .5, scaleX: lerp(1.5, 1, ease(k)), scaleY: lerp(1.5, 1, ease(k)) };
1215
+ const ring = d.ellipse(0, 0, 17, 17, "none", clamp(k * 3) * .9);
1216
+ ring.stroke = RED;
1217
+ ring.strokeWidth = 3;
1218
+ ring.transform = transform;
1219
+ const body = d.path("M-8,-4 L7,-4 L5,8 L-6,8 Z M7,-1 Q12,0 11,4 Q10,6 6,6", RED);
1220
+ body.transform = transform;
1221
+ body.opacity = ring.opacity;
1222
+ lift(ring, at);
1223
+ lift(body, at);
1224
+ };
1225
+ if (t < 4.1) {
1226
+ // The title slams on "stamp" from 122% and from grey to ink; the underline draws 0.12 s later.
1227
+ const slam = progress(t, .62, .24), scale = lerp(1.22, 1, 1 - (1 - slam) ** 4), gap = 16, first = boldWidth("STAMP", 58);
1228
+ if (slam > 0)
1229
+ (["STAMP", "CARD"] as const).forEach((word, i) => {
1230
+ const node = d.text(word, i ? first + gap : 0, 0, 58, slam < .5 ? MUTED : i ? RED : INK);
1231
+ node.transform = { x: 48, y: 98 + out.y, scaleX: scale, scaleY: scale };
1232
+ node.opacity = clamp(slam * 5) * out.a;
1233
+ });
1234
+ const start = 48 + first + gap, end = start + boldWidth("CARD", 58);
1235
+ d.line([[start - 3, 166 + out.y], [lerp(start, end, .35), 162 + out.y], [lerp(start, end, .7), 168 + out.y], [end + 6, 161 + out.y]], RED, 5, progress(t, .74, .28)).opacity = out.a;
1236
+ // The card rises 0.18 s later; its number counts up over 1.15 s while stamps fill.
1237
+ shadowed(48, 184, 440, 150, .92);
1238
+ lift(d.text("monday regulars this month", 66, 200, 12, GREY, "left", "mono"), .92).weight = "400";
1239
+ lift(d.text(String(Math.round(31 * ease(progress(t, .96, 1.15)))), 64, 226, 72, INK), .92);
1240
+ for (let i = 0; i < 10; i++) {
1241
+ const cx = 250 + (i % 5) * 46, cy = 236 + Math.floor(i / 5) * 50;
1242
+ lift(d.ellipse(cx, cy, 18, 18, "none"), .92).stroke = MUTED;
1243
+ cup(cx, cy, progress(t, 1 + i * .105, .1), 60 + i, .92);
1244
+ }
1245
+ // The badge spins in, then a search bar types one line.
1246
+ const spin = progress(t, 1.52, .34);
1247
+ if (spin > 0) {
1248
+ const k = spin >= 1 ? 1 : 1 + 3.2 * (spin - 1) ** 3 + 2.2 * (spin - 1) ** 2;
1249
+ const badge = d.ellipse(0, 0, 22, 22, RED, clamp(spin * 4) * out.a);
1250
+ badge.stroke = INK;
1251
+ badge.transform = { x: 484, y: 188 + out.y, rotation: lerp(-3.8, -.17, k), scaleX: lerp(.55, 1, k), scaleY: lerp(.55, 1, k) };
1252
+ const label = d.text("#1", 0, -11, 18, "#ffffff", "center");
1253
+ label.transform = badge.transform;
1254
+ label.opacity = badge.opacity;
1255
+ }
1256
+ const typed = "double stamps on mondays".slice(0, Math.floor(progress(t, 1.45, .76) * 24));
1257
+ const bar = lift(d.rect(48, 350, 440, 36, CARD, 18), 1.3);
1258
+ bar.stroke = INK;
1259
+ bar.strokeWidth = 2;
1260
+ lift(d.text(typed, 82, 359, 15, INK, "left", "sans"), 1.3).weight = "400";
1261
+ lift(d.ellipse(66, 367, 6, 6, "none"), 1.3).stroke = GREY;
1262
+ if (typed.length < 24 || Math.floor(t * 4) % 2 === 0) lift(d.rect(84 + typed.length * 7.2, 358, 2, 19, RED), 1.3);
1263
+ }
1264
+ else if (t < 7.6) {
1265
+ // The explanation, acted out: DOUBLE lands on its word, stamps land in pairs, then Monday's bar fills first.
1266
+ shadowed(48, 112, 440, 170, 4.1);
1267
+ lift(d.text("KETTLE & CRUMB", 70, 128, 16, INK, "left", "serif"), 4.1);
1268
+ lift(d.text("mondays count double", 70, 152, 12, GREY, "left", "mono"), 4.1).weight = "400";
1269
+ for (let i = 0; i < 10; i++) {
1270
+ const cx = 100 + (i % 5) * 82, cy = 202 + Math.floor(i / 5) * 48;
1271
+ lift(d.ellipse(cx, cy, 19, 19, "none"), 4.1).stroke = MUTED;
1272
+ if (i < 6) cup(cx, cy, progress(t, 4.9 + Math.floor(i / 2) * .22, .1), 80 + i, 4.1);
1273
+ }
1274
+ const hit = progress(t, 4.58, .2), e = 1 - (1 - hit) ** 2;
1275
+ if (hit > 0) {
1276
+ const transform = { x: 412, y: 136, rotation: lerp(-.31, -.1, e), scaleX: lerp(1.75, 1, e), scaleY: lerp(1.75, 1, e) };
1277
+ const box = d.rect(-56, -18, 112, 36, "none", 3, clamp(hit * 3));
1278
+ box.stroke = RED;
1279
+ box.strokeWidth = 3.5;
1280
+ box.transform = transform;
1281
+ const label = d.text("DOUBLE", 0, -12, 22, RED, "center");
1282
+ label.transform = transform;
1283
+ label.opacity = box.opacity;
1284
+ }
1285
+ shadowed(48, 300, 440, 110, 5.9);
1286
+ const fill = ease(progress(t, 6.3, .4));
1287
+ [18, 46, 52, 61, 88, 120, 104].forEach((value, i) => {
1288
+ const v = i ? value : lerp(18, 128, fill), height = v * .62;
1289
+ lift(d.rect(80 + i * 58, 396 - height, 26, height, i ? INK : RED, 2), 5.9);
1290
+ });
1291
+ lift(d.text(String(Math.round(lerp(18, 128, fill))), 93, 396 - lerp(18, 128, fill) * .62 - 18, 14, RED, "center"), 5.9);
1292
+ }
1293
+ // Hands: a pointing hand raised from below for 1.7 s at the new point, an open palm on the explanation.
1294
+ let pose: HostPose = HOST_POSES.down;
1295
+ for (const [at, up, down] of [[.2, HOST_POSES.point, HOST_POSES.down], [4.4, HOST_POSES.palm, HOST_POSES.palmDown]] as const)
1296
+ if (t >= at && t < at + 1.95) pose = t < at + 1.7 ? blendHostPose(down, up, (t - at) / .25) : blendHostPose(up, down, (t - at - 1.7) / .25);
1297
+ const cycle = Math.PI * 2 * t / 8;
1298
+ studyHost(d, 740, 372, .62, { pose, open: t < 7.6 ? open : 0, blink: (t > 2.9 && t < 3.04) || (t > 6.4 && t < 6.54), nod, sway: 3 * Math.sin(cycle), look: t > 4.1 && t < 7.6 ? -.7 : 0 });
1299
+ // The halftone wipe: a grid of ink dots grows from the centre out and shrinks away, over the presenter too.
1300
+ if (t > 7.2) {
1301
+ const cell = 960 / 23;
1302
+ for (let j = 0; j < 13; j++)
1303
+ for (let i = 0; i < 23; i++) {
1304
+ const cx = (i + .5) * cell, cy = (j + .5) * 540 / 13, dist = Math.hypot((cx - 480) / 480, (cy - 270) / 270) / Math.SQRT2;
1305
+ const u = (t - 7.2 - dist * .26) / .54;
1306
+ if (u > 0 && u < 1) d.ellipse(cx, cy, cell * .75 * Math.sin(Math.PI * u) ** .7, cell * .75 * Math.sin(Math.PI * u) ** .7, INK);
1307
+ }
1308
+ }
1309
+ d.footer(GREY);
1310
+ },
1169
1311
  };
1312
+ // Cartoon Host Explainer: approximate Arial Bold advances (em) so caption words can be placed one by one.
1313
+ const BOLD_WIDTHS: Record<string, number> = { " ": .278, ".": .278, ":": .333, "-": .333, "#": .556, "&": .722, "/": .278, "€": .556, i: .278, j: .278, l: .278, f: .333, t: .333, r: .389, m: .889, w: .778, c: .556, k: .556, s: .556, v: .556, x: .556, y: .556, z: .5, I: .278, J: .556, M: .833, W: .944 };
1314
+ const boldWidth = (value: string, size: number) => [...value].reduce((sum, c) => sum + (BOLD_WIDTHS[c] ?? (/[A-Z]/.test(c) ? .722 : .611)), 0) * size;
1315
+ const HOST_LINE = "#2b1b14", HOST_SKIN = "#e7a97c", HOST_HOODIE = "#dfa13a", HOST_HAIR = "#3a2622";
1316
+ type HostPose = { elbow: Point; wrist: Point; angle: number; fingers: number; thumb: boolean; spread: number };
1317
+ const HOST_POSES = {
1318
+ down: { elbow: [-196, 440], wrist: [-204, 380], angle: -.1, fingers: 1, thumb: false, spread: 0 },
1319
+ point: { elbow: [-198, 168], wrist: [-206, -14], angle: -.1, fingers: 1, thumb: false, spread: 0 },
1320
+ palmDown: { elbow: [-214, 440], wrist: [-224, 380], angle: -.24, fingers: 4, thumb: true, spread: 1 },
1321
+ palm: { elbow: [-214, 160], wrist: [-224, -18], angle: -.24, fingers: 4, thumb: true, spread: 1 },
1322
+ } satisfies Record<string, HostPose>;
1323
+ /** Blend two arm poses with an overshooting ease; the hand shape switches halfway. */
1324
+ function blendHostPose(from: HostPose, to: HostPose, amount: number): HostPose {
1325
+ const k = clamp(amount), e = k >= 1 ? 1 : 1 + 2.4 * (ease(k) - 1) ** 3 + 1.4 * (ease(k) - 1) ** 2;
1326
+ const mix = (a: Point, b: Point): Point => [lerp(a[0], b[0], e), lerp(a[1], b[1], e)];
1327
+ return { ...(k < .5 ? from : to), elbow: mix(from.elbow, to.elbow), wrist: mix(from.wrist, to.wrist), angle: lerp(from.angle, to.angle, e) };
1328
+ }
1329
+ /** The study's presenter: glasses, a hoodie, flat colours and one dark-brown outline, posed by an arm table. */
1330
+ function studyHost(d: Drawing, hx: number, hy: number, s: number, state: { pose: HostPose; open: number; blink: boolean; nod: number; sway: number; look: number }) {
1331
+ const at = (x: number, y: number, rotation = 0) => ({ x: hx + x * s, y: hy + y * s, rotation, scaleX: s, scaleY: s });
1332
+ const body = at(0, 0), head = at(state.sway, -70 + state.nod);
1333
+ const put = (node: StudyNode, transform: StudyNode["transform"], stroke = true) => {
1334
+ node.transform = transform;
1335
+ if (stroke) { node.stroke = HOST_LINE; node.strokeWidth = 4.5; }
1336
+ return node;
1337
+ };
1338
+ // Halftone halo: a few rows of dots offset behind the right of the silhouette.
1339
+ for (let row = 0; row < 40; row++)
1340
+ for (let col = 0; col < 3; col++)
1341
+ put(d.ellipse(190 + col * 7 + (row % 2) * 3, -10 + row * 8, 1.6, 1.6, HOST_LINE, .5), body, false);
1342
+ put(d.rect(-25, -84, 50, 90, "#cf8c60"), body);
1343
+ put(d.path("M-200,300 L-200,112 C-200,42 -140,8 -60,-6 L60,-6 C140,8 200,42 200,112 L200,300 Z", HOST_HOODIE), body);
1344
+ put(d.path("M120,22 C168,44 196,80 200,130 L200,300 L150,300 C160,120 150,70 120,22 Z", "#c48624"), body, false);
1345
+ put(d.path("M-84,2 C-70,-26 70,-26 84,2 C70,34 -70,34 -84,2 Z", "#c48624"), body);
1346
+ put(d.path("M-40,-2 C-24,-14 24,-14 40,-2 C26,10 -26,10 -40,-2 Z", "#a96c1c"), body);
1347
+ put(d.path("M-24,14 Q-30,56 -26,98 M24,14 Q30,54 28,92", "none", "#f4efe6", 5), body, false);
1348
+ put(d.rect(-31, 96, 10, 16, HOST_LINE, 3), body, false);
1349
+ put(d.rect(23, 90, 10, 16, HOST_LINE, 3), body, false);
1350
+ // Head, pivoting about the neck: hair behind, bun, ears, face, eyes behind glasses, mouth, fringe, then brows.
1351
+ const h = (node: StudyNode, stroke = true) => put(node, head, stroke);
1352
+ h(d.ellipse(0, -96, 88, 100, HOST_HAIR));
1353
+ h(d.ellipse(8, -196, 34, 34, HOST_HAIR));
1354
+ h(d.rect(-20, -168, 56, 13, "#d7372c", 6.5));
1355
+ h(d.ellipse(-74, -76, 13, 20, HOST_SKIN));
1356
+ h(d.ellipse(74, -76, 13, 20, HOST_SKIN));
1357
+ h(d.ellipse(0, -80, 72, 84, HOST_SKIN));
1358
+ h(d.ellipse(-48, -44, 13, 7, "#ec7f6f", .5), false);
1359
+ h(d.ellipse(48, -44, 13, 7, "#ec7f6f", .5), false);
1360
+ for (const ex of [-30, 30]) {
1361
+ if (state.blink) h(d.path(`M${ex - 12},-80 Q${ex},-74 ${ex + 12},-80`));
1362
+ else {
1363
+ h(d.ellipse(ex, -81, 11.5, 13.5, "#ffffff")).strokeWidth = 3;
1364
+ h(d.ellipse(ex + state.look * 4, -79, 6.5, 6.5, HOST_LINE), false);
1365
+ h(d.ellipse(ex + state.look * 4 + 2, -82, 2.2, 2.2, "#ffffff"), false);
1366
+ }
1367
+ h(d.ellipse(ex, -81, 23, 23, "none")).strokeWidth = 5;
1368
+ }
1369
+ h(d.path("M-7,-84 Q0,-90 7,-84 M-53,-84 L-71,-80 M53,-84 L71,-80", "none", HOST_LINE, 5), false);
1370
+ const w = 13 + 9 * state.open, mh = 3 + 22 * state.open;
1371
+ if (state.open < .06) h(d.path("M-17,-32 Q0,-21 17,-32", "none"));
1372
+ else h(d.path(`M${-w},-32 Q0,-35 ${w},-32 Q${w * .85},${-30 + mh} 0,${-30 + mh} Q${-w * .85},${-30 + mh} ${-w},-32 Z`, "#4a1a17"));
1373
+ h(d.path("M-80,-80 C-82,-162 -16,-192 46,-174 C80,-162 92,-126 82,-80 C70,-116 40,-130 8,-126 C-16,-124 -44,-120 -58,-100 C-64,-114 -74,-98 -80,-80 Z", HOST_HAIR));
1374
+ for (const side of [-1, 1])
1375
+ h(d.path(`M${side * 46},-118 Q${side * 31},-127 ${side * 14},-118`, "none", HOST_HAIR, 7), false);
1376
+ // The arm: outline under the forearm and the hoodie sleeve, then the hand rotated at the wrist.
1377
+ const { elbow, wrist, angle, fingers, thumb, spread } = state.pose, cuff: Point = [lerp(elbow[0], wrist[0], .62), lerp(elbow[1], wrist[1], .62)];
1378
+ const limb = `M-150,52 L${elbow[0]},${elbow[1]} L${wrist[0]},${wrist[1]}`, sleeve = `M-150,52 L${elbow[0]},${elbow[1]} L${cuff[0]},${cuff[1]}`;
1379
+ put(d.path(limb, "none", HOST_LINE, 51), body, false);
1380
+ put(d.path(limb, "none", HOST_SKIN, 42), body, false);
1381
+ put(d.path(sleeve, "none", HOST_LINE, 59), body, false);
1382
+ put(d.path(sleeve, "none", HOST_HOODIE, 50), body, false);
1383
+ const hand = { ...at(wrist[0], wrist[1], angle), scaleX: s * 1.3, scaleY: s * 1.3 };
1384
+ [[15, 44], [4, 48], [-7, 44], [-17, 36]].forEach(([x, length], i) => {
1385
+ const up = i < fingers;
1386
+ put(d.rect(x - 6.5, up ? -32 - length : -43, 13, up ? length + 8 : 19, HOST_SKIN, 6.5), { ...hand, rotation: angle + (up ? (i - 1.5) * -.12 * spread : 0) });
1387
+ });
1388
+ if (thumb) put(d.rect(13, -34, 14, 38, HOST_SKIN, 7), { ...hand, rotation: angle + .9 });
1389
+ put(d.rect(-25, -40, 50, 52, HOST_SKIN, 17), hand);
1390
+ if (!thumb) put(d.rect(-8, -18, 34, 14, HOST_SKIN, 7), hand);
1391
+ }
1170
1392
  function ninety(t: number) { return 87 + 9 * (1 - progress(t, .2)); }
1171
1393
  export function studyFrame(id: StudyId, seconds: number): StudyFrame {
1172
1394
  if (!(id in scenes))
@@ -17,5 +17,9 @@
17
17
  "url": "https://github.com/Vincentwei1021/mg-styles-15",
18
18
  "commit": "49052d80bfaf9fa8412cc9710530e5e95a3b8d8c"
19
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."
20
+ "finnardley-host-reel": {
21
+ "url": "https://x.com/finnardley/status/2116010421740007894",
22
+ "posted": "2026-10-30"
23
+ },
24
+ "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. finnardley-host-reel is a post on X sharing a free explainer-reel prompt and a sample video; cartoon-host-explainer restates the prompt's look, timings and structure in its own words for this workspace's tools. None of its footage, characters, prompt text or audio are bundled."
21
25
  }