@docyrus/animaguru 0.32.1 → 0.34.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,13 @@ 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
+
62
+ CLI 0.34.0 adds `"thumbnail"` to `film.config.json` for a designed cover. It names a PNG, JPEG or WebP in the film's folder, such as generated key art at `production/thumbnail.png`. `npm run export:mp4` saves that image as `FILM.jpg` instead of the `"poster"` frame, cropped to the film's shape, so every re-export keeps the cover and `youtube upload` sets it. A wrong path or an unreadable image stops the export before it renders. `submit --file FILM.mp4` now uses the `FILM.jpg` beside it as the AnimaGuru cover; `--poster FILE` chooses another and `--poster none` skips it. `videos poster VIDEO_ID --file IMAGE --yes` replaces the poster of a video already uploaded, and MCP's `set_video_poster` does the same from a saved project image, a public URL or base64.
63
+
60
64
  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
65
 
62
- CLI 0.5.2 includes a complete local PixiJS video workspace, now with 59 visual styles:
66
+ CLI 0.5.2 includes a complete local PixiJS video workspace, now with 60 visual styles:
63
67
 
64
68
  ```sh
65
69
  animaguru templates
@@ -75,7 +79,7 @@ Open the generated folder in **Codex or Claude Code**, then describe the video y
75
79
  - 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
80
  - `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
81
 
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.
82
+ 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
83
 
80
84
  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
85
 
@@ -611,7 +615,7 @@ animaguru submit --file production/exports/film.zip --dry-run
611
615
  animaguru submit --workspace team_… --yes # Keeps it in a team's shared library
612
616
  ```
613
617
 
614
- An optional positional directory selects the workspace; file and poster paths are relative to it. The video is kept in the library of the workspace selected with `workspaces use`, or of the synced project's workspace; `--workspace personal` or `--workspace TEAM_ID` chooses another for one upload, and the server refuses one that differs from a synced project's. Personal libraries hold 2 GiB; a team's members share 10 GiB. HTML5 exports upload the compiled animation and local media; source code, project instructions, credentials and generation receipts are not submitted. MP4 and AnimaGuru HTML5 ZIP are both supported. The CLI prints the video ID and full watch URL. Public HTML5 videos offer embed markup on the watch page; private sharing requires the selected AnimaGuru users to sign in.
618
+ An optional positional directory selects the workspace; file and poster paths are relative to it. An MP4's cover is `FILM.jpg` (or `.jpeg`/`.png`/`.webp`) beside it by default, the thumbnail `npm run export:mp4` saves; `--poster FILE` chooses another PNG, JPEG or WebP up to 2 MiB and `--poster none` uploads none. The video is kept in the library of the workspace selected with `workspaces use`, or of the synced project's workspace; `--workspace personal` or `--workspace TEAM_ID` chooses another for one upload, and the server refuses one that differs from a synced project's. Personal libraries hold 2 GiB; a team's members share 10 GiB. HTML5 exports upload the compiled animation and local media; source code, project instructions, credentials and generation receipts are not submitted. MP4 and AnimaGuru HTML5 ZIP are both supported. The CLI prints the video ID and full watch URL. Public HTML5 videos offer embed markup on the watch page; private sharing requires the selected AnimaGuru users to sign in.
615
619
 
616
620
  MP4 subtitles: CLI 0.10.0 uploads an MP4's subtitles with it, and the AnimaGuru player shows them over the picture with a CC button to turn them off and a menu when there are several languages. The MP4 itself stays clean. With the default `--subtitles auto`, `submit --file FILM.mp4` adds `FILM.vtt` (or else `FILM.srt`), which `npm run export:mp4` writes beside it. `--subtitles FILE` chooses another `.vtt`/`.srt` file and `--subtitles none` skips them. The language comes from `--subtitle-language`, a WebVTT `Language:` header, the export receipt (`FILM.json`) or `film.config.json`. The file is checked before any video bytes are sent, and the dry run lists it. An MP4's width, height and duration now come from the MP4 itself and its default title from the export receipt, so another language's cut no longer takes the current `film.config.json` values. Burned-in `--burn-captions` exports have no subtitle files, so nothing is added. To add, replace or remove one language on an uploaded MP4:
617
621
 
@@ -632,7 +636,7 @@ animaguru submit --file production/exports/film.mp4 --poster production/exports/
632
636
 
633
637
  To request public gallery publication, upload privately, open the video in My videos on the website, choose Share → Public, and accept the public video sharing agreement. Staff must approve the submission before anyone else can access its public link, download or embed. Direct CLI public-sharing requests without agreement acceptance are refused by the API.
634
638
 
635
- `videos list --scope mine|shared|public` lists films. Owners can edit metadata, change visibility and replace recipients on the website, or use `videos share VIDEO_ID --visibility private --share "alex@example.com" --yes`. `videos delete VIDEO_ID --yes` removes the film and files. Making a film private or removing a recipient revokes access to existing playback links.
639
+ `videos list --scope mine|shared|public` lists films. Owners can edit metadata, change visibility and replace recipients on the website, or use `videos share VIDEO_ID --visibility private --share "alex@example.com" --yes`. `videos poster VIDEO_ID --file production/exports/film.jpg --yes` replaces the poster with a PNG, JPEG or WebP up to 2 MiB, such as the designed thumbnail an MP4 export saves; a public video goes back to staff review and is hidden from the public until it is approved again. MCP's `set_video_poster` does the same with `video_id` and `image`: `{type: 'file', project_id, sha}` for a saved image from `list_assets`, `{type: 'url', url}` for a public HTTPS image up to 32 MiB, or `{type: 'base64', mediaType, data}` up to 8 MiB; an image over 2 MiB is saved as a 1280-pixel JPEG. `videos delete VIDEO_ID --yes` removes the film and files. Making a film private or removing a recipient revokes access to existing playback links.
636
640
 
637
641
  CLI 0.20.0 lists and moves videos between libraries. `videos list` shows the selected workspace's library, the one `submit` uploads to; `--workspace personal` or `--workspace TEAM_ID` lists another. `videos move VIDEO_ID… --workspace TEAM_ID --yes` moves your own videos into a team you belong to, or back with `--workspace personal`:
638
642
 
@@ -683,7 +687,7 @@ The optional positional directory sets the root for relative paths. The default
683
687
 
684
688
  Subtitles: `npm run export:mp4` writes `FILM.srt` and `FILM.vtt` beside `FILM.mp4`. With the default `--captions auto`, the upload adds the `.srt` (or else the `.vtt`) as a caption track once the video arrives. `--captions FILE` chooses another `.srt`/`.vtt` file and `--captions none` skips subtitles. The language comes from `--caption-language`, a WebVTT `Language:` header, the export receipt or `film.config.json`. The track has no name by default, like YouTube Studio uploads; `--caption-name` sets one. `--dry-run` lists the subtitle file and language. Without caption permission, a found subtitle file is skipped with a note, and an explicitly chosen one is refused before any video bytes are sent. If adding captions fails after the video arrives, the video stays, the JSON includes `captionsError`, and `youtube uploads resume UPLOAD_ID --yes` adds them later. Burned-in `--burn-captions` exports have no subtitle files, so nothing is added to them.
685
689
 
686
- Thumbnail: when `film.config.json` sets `"poster"` (seconds), the export also saves that frame as `FILM.jpg`. With the default `--thumbnail auto`, the upload sets `FILM.jpg` (or `.jpeg`/`.png`) from beside the MP4 as the video's custom thumbnail; `--thumbnail FILE` chooses another JPEG or PNG up to 2 MiB and `--thumbnail none` skips it. This uses the upload permission, not `--captions`. YouTube allows custom thumbnails only on verified channels (https://www.youtube.com/verify); if it refuses, the video stays, the JSON includes `thumbnailError`, and `youtube uploads resume UPLOAD_ID --yes` retries after verification. Repeating an upload command for an already uploaded video adds subtitles or a thumbnail it does not have yet.
690
+ Thumbnail: when `film.config.json` sets `"poster"` (seconds), the export also saves that frame as `FILM.jpg`; when it sets `"thumbnail"` to a PNG, JPEG or WebP in the film's folder, such as `production/thumbnail.png`, the export saves that image as `FILM.jpg` instead. With the default `--thumbnail auto`, the upload sets `FILM.jpg` (or `.jpeg`/`.png`) from beside the MP4 as the video's custom thumbnail; `--thumbnail FILE` chooses another JPEG or PNG up to 2 MiB and `--thumbnail none` skips it. This uses the upload permission, not `--captions`. YouTube allows custom thumbnails only on verified channels (https://www.youtube.com/verify); if it refuses, the video stays, the JSON includes `thumbnailError`, and `youtube uploads resume UPLOAD_ID --yes` retries after verification. Repeating an upload command for an already uploaded video adds subtitles or a thumbnail it does not have yet.
687
691
 
688
692
  Uploads run while the CLI is running. Progress and instructions go to stderr; the final JSON includes the YouTube video ID, watch URL and actual returned privacy. Google may still be processing the video. A saved SHA-256 fingerprint guards resumption; restore the original file if it changes. Retries query Google's saved byte position, including after a lost final response. Interrupted transfers reuse their upload ID and session; starting another upload of the same unfinished file is refused. Repeating a completed upload with identical metadata returns its receipt. An expired session requires inspecting YouTube Studio before intentionally starting a new upload. See [Google's resumable upload protocol](https://developers.google.com/youtube/v3/guides/using_resumable_upload_protocol).
689
693
 
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.1",
19764
+ version: "0.34.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",
@@ -47403,6 +47421,7 @@ var videoSubmission = videoDetails.and(external_exports.object({
47403
47421
  showcase: showcaseEntry.optional()
47404
47422
  })).refine((value) => value.bytes <= VIDEO_MAX_BYTES[value.format], "HTML5 ZIPs support up to 64 MiB; MP4s support up to 512 MiB.").refine((value) => !value.animation || value.project, "Name the project of the animation.");
47405
47423
  var videoMove = external_exports.object({ workspace: videoWorkspace }).strict();
47424
+ var VIDEO_POSTER_BYTES = 2 * 1024 * 1024;
47406
47425
  function videoPosterType(bytes) {
47407
47426
  if (bytes.length >= 8 && bytes.slice(0, 8).every((b, i2) => b === [137, 80, 78, 71, 13, 10, 26, 10][i2])) return "image/png";
47408
47427
  if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return "image/jpeg";
@@ -47478,7 +47497,7 @@ async function readSubtitleFile(file2, maxBytes) {
47478
47497
  if (text2.includes("\uFFFD") || !/\d\d:\d\d[,.]\d{3} --> /.test(text2)) throw new Error("Use a UTF-8 .srt or .vtt subtitle file with timed captions.");
47479
47498
  return { content, sha256: createHash2("sha256").update(content).digest("hex"), language: /^\uFEFF?WEBVTT[^\n]*\n(?:[^\n]+\n)*?Language:[ \t]*([^\s]+)/.exec(text2)?.[1] };
47480
47499
  }
47481
- async function subtitleFile(root, mp4, choice, extensions) {
47500
+ async function companionFile(root, mp4, choice, extensions) {
47482
47501
  if (choice === "none") return null;
47483
47502
  if (choice !== "auto") return { file: resolve7(root, choice), explicit: true };
47484
47503
  for (const extension of extensions) {
@@ -48452,7 +48471,7 @@ async function submitVideo(client, options) {
48452
48471
  let subtitles = null;
48453
48472
  const choice = options.subtitles ?? "auto";
48454
48473
  if (format === "html5" && choice !== "auto" && choice !== "none") throw new Error("Subtitle files are for MP4 uploads; HTML5 films show their own captions.");
48455
- const found = format === "mp4" ? await subtitleFile(root, file2, choice, [".vtt", ".srt"]) : null;
48474
+ const found = format === "mp4" ? await companionFile(root, file2, choice, [".vtt", ".srt"]) : null;
48456
48475
  if (found) {
48457
48476
  const { content, language: header } = await readSubtitleFile(found.file, SUBTITLE_MAX_BYTES);
48458
48477
  const language = subtitleLanguage.safeParse(options["subtitle-language"] ?? header ?? receipt.language ?? metadata.language);
@@ -48460,15 +48479,10 @@ async function submitVideo(client, options) {
48460
48479
  parseSubtitles(content.toString("utf8"), body.duration);
48461
48480
  subtitles = { file: found.file, language: language.data, content };
48462
48481
  }
48463
- let poster;
48464
- if (options.poster) {
48465
- const path = resolve10(root, options.poster);
48466
- const info2 = await stat4(path);
48467
- if (!info2.isFile() || info2.size > 2 * 1024 * 1024) throw new Error("Poster must be a PNG, JPEG or WebP up to 2 MiB.");
48468
- poster = await readFile9(path);
48469
- videoPosterType(poster);
48470
- }
48471
- if (options["dry-run"]) return { dryRun: true, file: file2, submission: body, poster: options.poster ?? null, subtitles: subtitles && { file: subtitles.file, language: subtitles.language } };
48482
+ const posterChoice = options.poster ?? "auto";
48483
+ const posterFile = format === "html5" && posterChoice === "auto" ? null : await companionFile(root, file2, posterChoice, [".jpg", ".jpeg", ".png", ".webp"]);
48484
+ const poster = posterFile ? await readPoster(posterFile.file, posterFile.explicit ? "" : `; ${basename4(posterFile.file)} beside the MP4 is not, so pass --poster FILE or --poster none`) : void 0;
48485
+ if (options["dry-run"]) return { dryRun: true, file: file2, submission: body, poster: posterFile?.file ?? null, subtitles: subtitles && { file: subtitles.file, language: subtitles.language } };
48472
48486
  const draft = await client.request("/api/v1/videos", { method: "POST", body });
48473
48487
  if (!Number.isInteger(draft.partBytes) || draft.partBytes < 1 || draft.partBytes > 8 * 1024 * 1024 || !/^vid_[a-f0-9]{32}$/.test(draft.id)) throw new Error("The server returned an invalid video upload session.");
48474
48488
  const handle = await open4(file2, "r");
@@ -48491,7 +48505,10 @@ async function submitVideo(client, options) {
48491
48505
  }
48492
48506
  }
48493
48507
  }
48494
- if (poster) await client.request(`/api/v1/videos/${draft.id}/poster`, { method: "PUT", body: new Blob([new Uint8Array(poster)]) });
48508
+ if (poster) {
48509
+ if (!posterFile.explicit) options.log?.(`Adding the cover from ${posterFile.file}\u2026`);
48510
+ await client.request(`/api/v1/videos/${draft.id}/poster`, { method: "PUT", body: new Blob([new Uint8Array(poster)]) });
48511
+ }
48495
48512
  if (subtitles) {
48496
48513
  options.log?.(`Adding ${subtitles.language} subtitles from ${subtitles.file}\u2026`);
48497
48514
  await client.request(`/api/v1/videos/${draft.id}/subtitles/${subtitles.language}`, { method: "PUT", body: subtitlesBody(subtitles.content) });
@@ -48522,6 +48539,28 @@ async function changeVideoSubtitles(client, id2, options) {
48522
48539
  parseSubtitles(content.toString("utf8"));
48523
48540
  return client.request(path + language.data, { method: "PUT", body: subtitlesBody(content) });
48524
48541
  }
48542
+ async function readPoster(file2, hint = "") {
48543
+ const info = await stat4(file2).catch(() => {
48544
+ throw new Error(`Cannot read the poster ${file2}.`);
48545
+ });
48546
+ const unusable = new Error(`Poster must be a PNG, JPEG or WebP up to 2 MiB${hint}.`);
48547
+ if (!info.isFile() || info.size > VIDEO_POSTER_BYTES) throw unusable;
48548
+ const poster = await readFile9(file2);
48549
+ try {
48550
+ videoPosterType(poster);
48551
+ } catch {
48552
+ throw unusable;
48553
+ }
48554
+ return poster;
48555
+ }
48556
+ async function changeVideoPoster(client, id2, options) {
48557
+ if (!options.yes) throw new Error("Pass --yes to replace this video's poster.");
48558
+ if (!options.file) throw new Error("Pass --file with a PNG, JPEG or WebP image up to 2 MiB.");
48559
+ const file2 = resolve10(options.file);
48560
+ const poster = await readPoster(file2, ", such as the .jpg npm run export:mp4 saves beside the MP4");
48561
+ const { video = null } = await client.request(`/api/v1/videos/${encodeURIComponent(id2)}/poster`, { method: "PUT", body: new Blob([new Uint8Array(poster)]) });
48562
+ return { video, poster: file2, ...video?.visibility === "public" ? { review: "The new poster sent this public video back to staff review; it is hidden from the public until approved." } : {} };
48563
+ }
48525
48564
  async function listVideos(client, options) {
48526
48565
  const scope = options.scope ?? "mine";
48527
48566
  const page = options.page ?? 1;
@@ -49505,7 +49544,7 @@ async function inspectMp4(file2, signal) {
49505
49544
  var readCaptionFile = (file2) => readSubtitleFile(file2, 5 * 1024 * 1024);
49506
49545
  async function planCaptions(root, mp4, options, fallbackLanguage) {
49507
49546
  const choice = options.captions ?? "auto";
49508
- const found = await subtitleFile(root, mp4, choice, [".srt", ".vtt"]);
49547
+ const found = await companionFile(root, mp4, choice, [".srt", ".vtt"]);
49509
49548
  if (!found) return null;
49510
49549
  const { file: file2 } = found;
49511
49550
  const { content, sha256, language: header } = await readCaptionFile(file2);
@@ -50895,7 +50934,7 @@ function createCli() {
50895
50934
  { name: "description", type: "string" },
50896
50935
  { name: "visibility", type: "string", choices: ["private", "public"], default: "private" },
50897
50936
  { name: "share", type: "string", description: "Comma-separated emails of verified AnimaGuru users (private videos)" },
50898
- { name: "poster", type: "string", description: "PNG, JPEG or WebP cover image" },
50937
+ { name: "poster", type: "string", default: "auto", description: "PNG, JPEG or WebP cover image up to 2 MiB; auto uses FILM.jpg (or .jpeg/.png/.webp) beside FILM.mp4, the thumbnail npm run export:mp4 saves; none skips" },
50899
50938
  { name: "subtitles", type: "string", default: "auto", description: "MP4 subtitle file (.vtt or .srt) the player shows over the picture; auto uses FILM.vtt or FILM.srt beside FILM.mp4; none skips" },
50900
50939
  { name: "subtitle-language", type: "string", description: "Subtitle language code such as en or tr; defaults to the MP4's export receipt or film.config.json" },
50901
50940
  { name: "showcase", type: "string", choices: [...SHOWCASE_SECTORS], description: "Admins: add the video to the showcase under this sector; it shows on lead pages once public and approved" },
@@ -50922,7 +50961,11 @@ function createCli() {
50922
50961
  const result = await moveVideos(new Client(flags.url, signal), args.ids ?? [], flags);
50923
50962
  out(stdout, result);
50924
50963
  if (result.error) throw new Error(result.error);
50925
- }))).add(defineCommand("subtitles", { description: "Add or replace an MP4's subtitles in one language, or remove a language" }, (command) => command.args({ name: "id", type: "string", required: true }).flags(
50964
+ }))).add(defineCommand("poster", { description: "Replace a video's poster (cover image); a public video goes back to staff review until approved" }, (command) => command.args({ name: "id", type: "string", required: true }).flags(
50965
+ { name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } },
50966
+ { name: "file", type: "string", description: "PNG, JPEG or WebP image up to 2 MiB, such as the .jpg npm run export:mp4 saves beside the MP4" },
50967
+ { name: "yes", type: "boolean", required: true }
50968
+ ).action(async ({ args, flags, stdout, signal }) => out(stdout, await changeVideoPoster(new Client(flags.url, signal), args.id, flags))))).add(defineCommand("subtitles", { description: "Add or replace an MP4's subtitles in one language, or remove a language" }, (command) => command.args({ name: "id", type: "string", required: true }).flags(
50926
50969
  { name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } },
50927
50970
  { name: "file", type: "string", description: ".srt or .vtt subtitle file" },
50928
50971
  { name: "language", type: "string", description: "Language code such as en or tr; defaults to a WebVTT Language header or the export receipt" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docyrus/animaguru",
3
- "version": "0.32.1",
3
+ "version": "0.34.0",
4
4
  "description": "Agent-friendly AnimaGuru CLI built with CrustJS",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,7 +4,7 @@ Read from the `animaguru` skill only when the user asks to host, share or upload
4
4
 
5
5
  ## Submit a finished animation to AnimaGuru
6
6
 
7
- `submit` hosts a finished animation, separate from paid asset generation and `files export`. In a scaffolded video project, `animaguru submit --yes` builds its portable HTML5 ZIP and uploads it privately. Use `--file` for an existing AnimaGuru HTML5 export or MP4; only the export is uploaded, never the source workspace. `--dry-run` inspects the file, metadata and audience locally without signing in or uploading. The video is kept in the selected workspace's library, or a synced project's workspace; pass `--workspace personal` or `--workspace TEAM_ID` when the user names the library to use.
7
+ `submit` hosts a finished animation, separate from paid asset generation and `files export`. In a scaffolded video project, `animaguru submit --yes` builds its portable HTML5 ZIP and uploads it privately. Use `--file` for an existing AnimaGuru HTML5 export or MP4; only the export is uploaded, never the source workspace. An MP4's `FILM.jpg` thumbnail beside it becomes its cover unless `--poster FILE` or `--poster none` says otherwise. `--dry-run` inspects the file, cover, metadata and audience locally without signing in or uploading. The video is kept in the selected workspace's library, or a synced project's workspace; pass `--workspace personal` or `--workspace TEAM_ID` when the user names the library to use.
8
8
 
9
9
  ```sh
10
10
  animaguru submit --share "reviewer@example.com" --yes
@@ -18,7 +18,7 @@ animaguru videos get VIDEO_ID
18
18
 
19
19
  Submissions default to private. `--share` is a comma-separated list of verified AnimaGuru account emails; selected viewers sign in to watch. `--visibility public` lists the animation at [Videos](https://animaguru.com/videos) and permits anyone to watch, download its export, and embed an HTML5 player. Choose the audience from the user's request; a request to make or export a film alone does not authorize sharing it. `--yes` acknowledges the upload and selected audience, and spends no generation credits.
20
20
 
21
- The JSON result returns the video ID and watch URL. Owners manage details and recipients on that page, or run `animaguru videos share VIDEO_ID --visibility private --share "reviewer@example.com" --yes`; this replaces the entire recipient list. Public sharing uses `--visibility public` without `--share`. Removing a recipient or making a public film private blocks previous playback URLs immediately. `animaguru videos delete VIDEO_ID --yes` removes the hosted files.
21
+ The JSON result returns the video ID and watch URL. Owners manage details and recipients on that page, or run `animaguru videos share VIDEO_ID --visibility private --share "reviewer@example.com" --yes`; this replaces the entire recipient list. Public sharing uses `--visibility public` without `--share`. Removing a recipient or making a public film private blocks previous playback URLs immediately. `animaguru videos poster VIDEO_ID --file production/exports/film.jpg --yes` replaces the poster of an uploaded video with a PNG, JPEG or WebP up to 2 MiB, such as the designed thumbnail the MP4 export saves. A public video's new poster goes back to staff review and the video is hidden from the public until it is approved, so tell the user before replacing it. `animaguru videos delete VIDEO_ID --yes` removes the hosted files.
22
22
 
23
23
  `videos list` shows the selected workspace's library; add `--workspace personal` or `--workspace TEAM_ID` for another (CLI 0.20.0). When the user asks to move videos between their personal library and a team, run `animaguru videos move VIDEO_ID… --workspace TEAM_ID --yes` (or `--workspace personal`). Moving keeps each video's ID, links, subtitles, recipients and review, and every team member can watch it, so move only videos the user names. A video made from a project's animation stays with its project: the user transfers the project to the team on the website first. If one video can't be moved, the command stops and reports `moved`, `error` and `notMoved`; fix the cause, then rerun it with the same IDs, since a video already in the target library is left as it is.
24
24
 
@@ -31,6 +31,6 @@ Uploads use checksum-verified parts. After an interrupted submission, inspect th
31
31
 
32
32
  CLI 0.9.0 adds local YouTube uploads independently of AnimaGuru hosting. Discover `youtube` in `animaguru schema`. Run `animaguru youtube auth login --client-file PATH --yes --no-browser` only when channel authorization is requested; the user approves the printed Google URL on the same computer. Use a Google **Desktop app** OAuth JSON file. Add `--captions` only when the user wants subtitles uploaded, and tell them it grants Google's broader `youtube.force-ssl` permission, which also allows managing their videos. Never read or print saved tokens or upload session URLs.
33
33
 
34
- After MP4 export, inspect with `animaguru youtube upload --file FILE --title TITLE --privacy private --made-for-kids no --synthetic-media yes --dry-run`. Choose the audience and realistic synthetic-content declarations from the actual film and the user's answers; those example values are not defaults. A request to create/export a film does not authorize sending it to YouTube. Use `--yes` only when the user authorizes the file, channel and visibility. `youtube auth status` identifies the connected channel and whether caption uploads are allowed; `--channel CHANNEL_ID` can pin the destination. By default the upload adds `FILM.srt` (or `FILM.vtt`) from beside the MP4 as a caption track, with the language from the export receipt; the dry run lists it. Use `--captions FILE`, `--captions none` or `--caption-language CODE` when needed. It also sets `FILM.jpg` from beside the MP4 (the export's `poster` frame) as the custom thumbnail; `--thumbnail FILE` or `--thumbnail none` change that, and custom thumbnails need a verified channel. A `captionsError` or `thumbnailError` in the result means the video is uploaded but that part is not; resume the printed upload ID with `youtube uploads resume UPLOAD_ID --yes`.
34
+ After MP4 export, inspect with `animaguru youtube upload --file FILE --title TITLE --privacy private --made-for-kids no --synthetic-media yes --dry-run`. Choose the audience and realistic synthetic-content declarations from the actual film and the user's answers; those example values are not defaults. A request to create/export a film does not authorize sending it to YouTube. Use `--yes` only when the user authorizes the file, channel and visibility. `youtube auth status` identifies the connected channel and whether caption uploads are allowed; `--channel CHANNEL_ID` can pin the destination. By default the upload adds `FILM.srt` (or `FILM.vtt`) from beside the MP4 as a caption track, with the language from the export receipt; the dry run lists it. Use `--captions FILE`, `--captions none` or `--caption-language CODE` when needed. It also sets `FILM.jpg` from beside the MP4 (the export's `poster` frame, or the designed image `"thumbnail"` names in film.config.json) as the custom thumbnail; `--thumbnail FILE` or `--thumbnail none` change that, and custom thumbnails need a verified channel. A `captionsError` or `thumbnailError` in the result means the video is uploaded but that part is not; resume the printed upload ID with `youtube uploads resume UPLOAD_ID --yes`.
35
35
 
36
36
  Uploads run locally while the CLI is open. After interruption, use `youtube uploads list` and `youtube uploads resume UPLOAD_ID --yes` instead of starting another upload. Resumption uses the original file and settings and probes Google's persisted offset. `youtube auth logout --yes` revokes access and removes local credentials and receipts; videos remain on YouTube. No AnimaGuru backend, sign-in or generation credits are used.
@@ -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.
@@ -112,7 +112,7 @@ Run `npm run build`. Review the actual browser at desktop and mobile sizes, play
112
112
 
113
113
  Deliver the playable HTML film first unless the user asked for MP4 too. A branching story (`story.json`) and a quiz film (`quiz.json`) are HTML5 only. For an embeddable HTML5 deliverable, use the download menu or `npm run export:html`. Unzip and test the static package over HTTP(S), including its iframe example; it should play without the authoring workspace or an AnimaGuru account. `npm run build` includes the same download in the static player. Preserve embed mode and hide authoring controls there.
114
114
 
115
- For MP4 on the user's own computer, run `npm run export:mp4 -- --headless`; it captures in a local headless Chrome (installed Chrome or Edge, `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`, or `npx playwright install chromium`). Without `--headless`, open the printed local capture URL in a browser and keep it open until completion. The script verifies H.264/AAC, dimensions, duration and exact frame count and writes a JSON report next to the MP4. Captions stay out of the picture; the `captions` resource track becomes `.srt` (YouTube, desktop players) and `.vtt` (HTML5) subtitle files with the MP4's name, so keep that track's clips equal to the spoken lines. For social feeds that need open captions, add `-- --burn-captions`; it saves `SLUG-LANGUAGE-captioned.mp4` with captions in the picture and no subtitle files. Set `"poster"` in `film.config.json` to the seconds of the film's most representative frame (title, product or end card, never a fade or a blank moment); the export saves it beside the MP4 as a 1280-pixel JPEG thumbnail, which `animaguru youtube upload` and video pages can use. Exporting existing local assets spends no generation credits. If browser control is unavailable, give the user the printed URL and explain the one manual step. The cloud Animator makes no MP4s and `export:mp4` refuses to run there: the user exports the MP4 in their browser from the draft page.
115
+ For MP4 on the user's own computer, run `npm run export:mp4 -- --headless`; it captures in a local headless Chrome (installed Chrome or Edge, `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`, or `npx playwright install chromium`). Without `--headless`, open the printed local capture URL in a browser and keep it open until completion. The script verifies H.264/AAC, dimensions, duration and exact frame count and writes a JSON report next to the MP4. Captions stay out of the picture; the `captions` resource track becomes `.srt` (YouTube, desktop players) and `.vtt` (HTML5) subtitle files with the MP4's name, so keep that track's clips equal to the spoken lines. For social feeds that need open captions, add `-- --burn-captions`; it saves `SLUG-LANGUAGE-captioned.mp4` with captions in the picture and no subtitle files. Set `"poster"` in `film.config.json` to the seconds of the film's most representative frame (title, product or end card, never a fade or a blank moment); the export saves it beside the MP4 as a 1280-pixel JPEG thumbnail, which `animaguru youtube upload` and video pages can use. For a designed cover, such as generated key art, set `"thumbnail"` to its path inside the film's folder (for example `production/thumbnail.png`, a PNG, JPEG or WebP); every export then saves that image as the thumbnail instead of the poster frame, so re-exports keep it. Never edit the exported `.jpg` by hand: the next export writes a new one. Exporting existing local assets spends no generation credits. If browser control is unavailable, give the user the printed URL and explain the one manual step. The cloud Animator makes no MP4s and `export:mp4` refuses to run there: the user exports the MP4 in their browser from the draft page.
116
116
 
117
117
  `npm run dev` starts the preview. The capture API and file map are documented in the generated project README. Publishing a website, package or campaign is separate from creating the local video.
118
118
 
@@ -130,7 +130,7 @@ For visual review, run `npm run frames -- --animation NAME --at 1.3,4.9,…` (or
130
130
 
131
131
  ## Sharing a finished film
132
132
 
133
- When the user requests hosting or sharing, use `animaguru submit --yes` from this workspace; it builds and submits the portable HTML5 animation. For an MP4 use `--file production/exports/film.mp4`; its `.vtt`/`.srt` subtitles beside it are uploaded too and shown by the AnimaGuru player, so submit the clean MP4 rather than a `-captioned.mp4`. Read the local AnimaGuru skill's submission section for audience, recipient and recovery rules. Private is the default; use `--share "reviewer@example.com"` for selected verified accounts or `--visibility public` for [the public Videos section](https://animaguru.com/videos). Preserve the user's audience choice and include a cover using `--poster` when available. Local creation or export alone does not imply publication.
133
+ When the user requests hosting or sharing, use `animaguru submit --yes` from this workspace; it builds and submits the portable HTML5 animation. For an MP4 use `--file production/exports/film.mp4`; its `.vtt`/`.srt` subtitles beside it are uploaded too and shown by the AnimaGuru player, so submit the clean MP4 rather than a `-captioned.mp4`. Read the local AnimaGuru skill's submission section for audience, recipient and recovery rules. Private is the default; use `--share "reviewer@example.com"` for selected verified accounts or `--visibility public` for [the public Videos section](https://animaguru.com/videos). Preserve the user's audience choice. An MP4's exported `.jpg` thumbnail becomes its cover automatically; for HTML5, include a cover using `--poster` when available. Local creation or export alone does not imply publication.
134
134
 
135
135
 
136
136
  ## Cloud versions
@@ -34,7 +34,7 @@ Original 2.5D study recipes are in `src/studies/`. In an untouched starter, `npm
34
34
 
35
35
  ## Submit to AnimaGuru
36
36
 
37
- When asked to host or share the finished film, read the local AnimaGuru skill's submission section. `animaguru submit --yes` builds the portable HTML5 export and uploads it privately; `--file` accepts an existing HTML5 ZIP or MP4; an MP4's `.vtt`/`.srt` subtitles beside it are uploaded with it, so prefer the clean MP4 over a `-captioned.mp4`. `--share "reviewer@example.com"` selects verified AnimaGuru accounts, while `--visibility public` publishes to https://animaguru.com/videos and allows watching, downloading and HTML5 embedding. Include a cover with `--poster public/assets/cover.png` when available. Do not infer public sharing from a request to create or export a film. The CLI returns the hosted watch URL and video ID. Owners can change details and sharing from that page.
37
+ When asked to host or share the finished film, read the local AnimaGuru skill's submission section. `animaguru submit --yes` builds the portable HTML5 export and uploads it privately; `--file` accepts an existing HTML5 ZIP or MP4; an MP4's `.vtt`/`.srt` subtitles beside it are uploaded with it, so prefer the clean MP4 over a `-captioned.mp4`. `--share "reviewer@example.com"` selects verified AnimaGuru accounts, while `--visibility public` publishes to https://animaguru.com/videos and allows watching, downloading and HTML5 embedding. An MP4's `.jpg` thumbnail beside it becomes the cover; for HTML5, include a cover with `--poster public/assets/cover.png` when available. Do not infer public sharing from a request to create or export a film. The CLI returns the hosted watch URL and video ID. Owners can change details and sharing from that page.
38
38
 
39
39
  ## Cloud versions
40
40
 
@@ -16,4 +16,4 @@ Accept schema 2 projects and saved `advertorial` as Advertisement with editorial
16
16
 
17
17
  Original 2.5D study recipes are in `src/studies/`. In an untouched starter, `npm run example -- STYLE_ID` installs one; it refuses to overwrite an authored film. Import the recipe directly for an existing production. `npm run review` checks actual visible text bounds and seek consistency, then saves frames and a report. Keep the HTML5-first delivery and use the client's identity in the film. Physically rendered 3D remains deferred.
18
18
 
19
- For requested hosting or sharing, use `animaguru submit --yes` in this workspace (private by default). `--share "reviewer@example.com"` shares with selected verified AnimaGuru accounts; `--visibility public` publishes to https://animaguru.com/videos. `--file` accepts an existing HTML5 ZIP or MP4; an MP4's `.vtt`/`.srt` subtitles beside it are uploaded with it. Read the local AnimaGuru skill's submission section for audience selection and upload recovery. Creation and local export do not imply publication.
19
+ For requested hosting or sharing, use `animaguru submit --yes` in this workspace (private by default). `--share "reviewer@example.com"` shares with selected verified AnimaGuru accounts; `--visibility public` publishes to https://animaguru.com/videos. `--file` accepts an existing HTML5 ZIP or MP4; an MP4's `.vtt`/`.srt` subtitles and `.jpg` thumbnail beside it are uploaded with it. Read the local AnimaGuru skill's submission section for audience selection and upload recovery. Creation and local export do not imply publication.
@@ -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 fifteen 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. `"thumbnail"` instead names a PNG, JPEG or WebP in the film's folder, such as a designed cover at `production/thumbnail.png`, that is saved as `SLUG-LANGUAGE.jpg` in place of the poster frame, at the same size and cropped to the film's shape; every export saves it again, so a new export never brings the poster frame back. 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,13 +108,13 @@ 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
 
115
115
  ## Share on AnimaGuru
116
116
 
117
- After signing in with `animaguru auth login`, run `animaguru submit --yes` here to build and host the finished HTML5 animation privately. Add `--share "reviewer@example.com"` to invite selected verified AnimaGuru accounts, or `--visibility public` to list it at [Videos](https://animaguru.com/videos). `--poster public/assets/cover.png` adds a cover. Already exported an MP4? Pass `--file production/exports/film.mp4`; the `.vtt` subtitles beside it are uploaded too, and the AnimaGuru player shows them over the picture with a CC button. The CLI returns your hosted player URL; public HTML5 animations can be embedded from that page. Owners manage details and access there. Only the exported player/media are uploaded.
117
+ After signing in with `animaguru auth login`, run `animaguru submit --yes` here to build and host the finished HTML5 animation privately. Add `--share "reviewer@example.com"` to invite selected verified AnimaGuru accounts, or `--visibility public` to list it at [Videos](https://animaguru.com/videos). `--poster public/assets/cover.png` adds a cover. Already exported an MP4? Pass `--file production/exports/film.mp4`; the `.vtt` subtitles beside it are uploaded too, and the AnimaGuru player shows them over the picture with a CC button. Its `.jpg` thumbnail becomes the cover unless `--poster` names another image or `none`. The CLI returns your hosted player URL; public HTML5 animations can be embedded from that page. Owners manage details and access there. Only the exported player/media are uploaded.
118
118
 
119
119
  ## Cloud versions
120
120
 
@@ -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
@@ -16,7 +16,7 @@ Run commands from the workspace root:
16
16
  - `npm run dev`: the workspace hub and separate `/animations/NAME/` players.
17
17
  - `npm run build -- --animation NAME`: typecheck and build that film.
18
18
  - `npm run export:html -- --animation NAME`: portable HTML5 ZIP with local media and common branding.
19
- - `npm run export:mp4 -- --animation NAME` (not for a branching story or a quiz film): verified MP4 without burned-in captions, plus `.srt`/`.vtt` subtitle files from the captions track (`--burn-captions` draws them into the picture instead, as `NAME-LANG-captioned.mp4`), and a `.jpg` thumbnail of the `poster` frame set in film.config.json. `--watermark` also saves `NAME-LANG-watermarked.mp4` with AnimaGuru's "Made with AnimaGuru" badge, for an AnimaGuru lead film's preview. Add `--headless` to capture in local headless Chrome; otherwise open the printed capture page.
19
+ - `npm run export:mp4 -- --animation NAME` (not for a branching story or a quiz film): verified MP4 without burned-in captions, plus `.srt`/`.vtt` subtitle files from the captions track (`--burn-captions` draws them into the picture instead, as `NAME-LANG-captioned.mp4`), and a `.jpg` thumbnail of the `poster` frame set in film.config.json, or of the image its `thumbnail` names (a PNG, JPEG or WebP in the animation's folder, such as a designed cover in `production/`). `--watermark` also saves `NAME-LANG-watermarked.mp4` with AnimaGuru's "Made with AnimaGuru" badge, for an AnimaGuru lead film's preview. Add `--headless` to capture in local headless Chrome; otherwise open the printed capture page.
20
20
  - `npm run review -- --animation NAME --headless`: frame, text, layout (overlap, safe area, text size), seeking and soundtrack checks, one line per finding.
21
21
  - `npm run frames -- --animation NAME --at 1.2,4.8`: labelled contact sheets in `production/review/frames/` plus layout notes for those times.
22
22
  - `npm run audio:vo|audio:music|audio:mix -- --animation NAME`: cut and place the voiceover (`timing.json`), fit the music bed, and master the soundtrack.
@@ -10,7 +10,7 @@ import { projectArguments, projectContext, publicFile } from "./project-context.
10
10
  import { workspacePlugin } from "./workspace-plugin.mjs";
11
11
  import { subtitleCues, toSrt, toVtt } from "./subtitles.mjs";
12
12
  import { captureHeadless, takeHeadlessFlag } from "./headless-browser.mjs";
13
- import { BT709_FRAMES, canCopySoundtrack, isBt709, mp4Language } from "./mp4-tools.mjs";
13
+ import { BT709_FRAMES, canCopySoundtrack, encodeThumbnail, isBt709, jpegDimensions, mp4Language, THUMBNAIL_LIMIT, thumbnailSource } from "./mp4-tools.mjs";
14
14
  import { measureLoudness } from "./audio-tools.mjs";
15
15
 
16
16
  // The cloud Animator makes no MP4s: the user exports the film in their own browser, from the draft page.
@@ -51,8 +51,10 @@ if (config.audio !== null) {
51
51
  }
52
52
  // "poster": seconds in film.config.json picks the frame saved as a 1280-pixel JPEG thumbnail beside the MP4.
53
53
  if (config.poster != null && (typeof config.poster !== "number" || !Number.isFinite(config.poster) || config.poster < 0 || config.poster >= duration)) throw new Error("Set poster in film.config.json to a time within the film, in seconds, or leave it out.");
54
+ // "thumbnail": a PNG, JPEG or WebP inside the animation's folder, such as a designed cover in production/, saved instead of the poster frame.
55
+ const designedThumbnail = await thumbnailSource(context.projectRoot, config.thumbnail);
54
56
  const thumbnailScale = Math.min(1, 1280 / Math.max(width, height));
55
- const thumbnail = config.poster == null ? null : { time: config.poster, width: Math.round(width * thumbnailScale), height: Math.round(height * thumbnailScale) };
57
+ const thumbnail = config.poster == null && !designedThumbnail ? null : { time: config.poster ?? null, width: Math.round(width * thumbnailScale), height: Math.round(height * thumbnailScale) };
56
58
  // The capture page draws the badge with the font and logo AnimaGuru's web app uses, so they are sent to it as base64.
57
59
  const watermarkFiles = watermark ? Object.fromEntries(await Promise.all([["font", "instrument-sans.woff2"], ["logo", "logo-mark-512.png"]]
58
60
  .map(async ([key, file]) => [key, (await readFile(new URL(`./watermark/${file}`, import.meta.url))).toString("base64")]))) : null;
@@ -94,6 +96,8 @@ let lastActivity = Date.now();
94
96
  const run = promisify(execFile);
95
97
  // An AAC soundtrack that already fits the film, such as audio:mix's, is copied rather than encoded a second time.
96
98
  const copySoundtrack = audioSource ? canCopySoundtrack(JSON.parse((await run(binaries.ffprobe, ["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name,profile,sample_rate,start_time,duration:format=duration", "-of", "json", audioSource])).stdout), duration) : false;
99
+ // A designed thumbnail is encoded before the capture starts, so a bad image stops the export before the long render.
100
+ if (designedThumbnail) thumbnailImage = await encodeThumbnail(binaries.ffmpeg, designedThumbnail, thumbnail, config.thumbnail);
97
101
 
98
102
  async function readBody(request, limit = 24 * 1024 * 1024) {
99
103
  let bytes = 0;
@@ -106,20 +110,6 @@ async function readBody(request, limit = 24 * 1024 * 1024) {
106
110
  return Buffer.concat(parts);
107
111
  }
108
112
 
109
- function jpegDimensions(bytes) {
110
- if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
111
- let cursor = 2;
112
- while (cursor + 4 < bytes.length) {
113
- if (bytes[cursor] !== 0xff) return null;
114
- const marker = bytes[cursor + 1];
115
- const size = bytes.readUInt16BE(cursor + 2);
116
- if (size < 2 || cursor + 2 + size > bytes.length) return null;
117
- if ([0xc0, 0xc1, 0xc2].includes(marker) && size >= 8) return { height: bytes.readUInt16BE(cursor + 5), width: bytes.readUInt16BE(cursor + 7) };
118
- cursor += 2 + size;
119
- }
120
- return null;
121
- }
122
-
123
113
  function pngDimensions(bytes) {
124
114
  const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
125
115
  if (bytes.length < 24 || signature.some((byte, index) => bytes[index] !== byte) || bytes.toString("latin1", 12, 16) !== "IHDR") return null;
@@ -229,7 +219,8 @@ async function route(request, response, url) {
229
219
  });
230
220
  encoderDone.catch((error) => { encoderError = error; });
231
221
  console.log(`Rendering ${language.toUpperCase()}: ${settings.frames} frames (${settings.duration}s), ${width} × ${height} / ${fps} fps, ${subtitles.length ? `${subtitles.length} captions ${burnCaptions ? "drawn into the picture" : "as subtitle files"}` : "no captions"}${watermark ? ", plus a watermarked copy" : ""}.`);
232
- send({ fps, frames: settings.frames, burnCaptions, thumbnail, watermark: watermarkFiles }); return;
222
+ // The capture page draws the poster frame only when no designed thumbnail replaces it.
223
+ send({ fps, frames: settings.frames, burnCaptions, thumbnail: designedThumbnail ? null : thumbnail, watermark: watermarkFiles }); return;
233
224
  }
234
225
  if (url.pathname === "/__export/watermark") {
235
226
  if (!watermark || !encoder || encoding || badge || received) throw new Error("Send the watermark once, before the first frame.");
@@ -268,7 +259,7 @@ async function route(request, response, url) {
268
259
  if (url.pathname === "/__export/thumbnail") {
269
260
  if (!encoder || encoding || !thumbnail || thumbnailImage || received !== settings.frames) throw new Error("Capture the thumbnail once, after every frame.");
270
261
  // YouTube accepts custom thumbnails up to 2 MB.
271
- const image = await readBody(request, 2 * 1024 * 1024);
262
+ const image = await readBody(request, THUMBNAIL_LIMIT);
272
263
  const dimensions = jpegDimensions(image);
273
264
  if (request.headers["content-type"] !== "image/jpeg" || dimensions?.width !== thumbnail.width || dimensions?.height !== thumbnail.height) throw new Error("Expected a JPEG thumbnail of the configured size.");
274
265
  thumbnailImage = image;
@@ -299,7 +290,7 @@ async function route(request, response, url) {
299
290
  for (const file of subtitleFiles) await writeFile(file.path, file.text);
300
291
  const thumbnailFile = thumbnailImage ? destination.replace(/\.mp4$/, ".jpg") : null;
301
292
  if (thumbnailFile) await writeFile(thumbnailFile, thumbnailImage);
302
- await writeFile(destination.replace(/\.mp4$/, ".json"), JSON.stringify({ file: path.basename(destination), title: config.title, audio: config.audio, subtitles: subtitleFiles.map((file) => path.basename(file.path)), thumbnail: thumbnailFile && { file: path.basename(thumbnailFile), time: thumbnail.time }, soundtrack: sound, ...(watermark ? { watermarked: { file: path.basename(watermarked), badge: { x: badge.x, y: badge.y, width: badge.width, height: badge.height } } } : {}), settings, verification: probe }, null, 2) + "\n");
293
+ await writeFile(destination.replace(/\.mp4$/, ".json"), JSON.stringify({ file: path.basename(destination), title: config.title, audio: config.audio, subtitles: subtitleFiles.map((file) => path.basename(file.path)), thumbnail: thumbnailFile && { file: path.basename(thumbnailFile), ...(designedThumbnail ? { source: config.thumbnail } : { time: thumbnail.time }) }, soundtrack: sound, ...(watermark ? { watermarked: { file: path.basename(watermarked), badge: { x: badge.x, y: badge.y, width: badge.width, height: badge.height } } } : {}), settings, verification: probe }, null, 2) + "\n");
303
294
  const bytes = (await stat(destination)).size;
304
295
  finished = true;
305
296
  clearInterval(idleCheck);
@@ -307,7 +298,7 @@ async function route(request, response, url) {
307
298
  if (sound) console.log(`Sound: ${sound.file}, ${sound.copied ? "copied without a second AAC encode" : "encoded to AAC"}; ${sound.error ? `not measured (${sound.error})` : Number.isFinite(sound.integratedLufs) ? `the MP4 measures ${sound.integratedLufs.toFixed(1)} LUFS, ${sound.truePeakDb.toFixed(1)} dBTP` : "the MP4's sound is silent"}.`);
308
299
  if (sound?.truePeakDb > -1) console.log("Warning: the MP4's sound peaks above -1 dBTP, where players and platforms can clip it. Lower the soundtrack's true peak (for npm run audio:mix, loudness.tp in production/audio/mix.json) and export again.");
309
300
  if (subtitleFiles.length) console.log(`Subtitles (${subtitles.length} captions, ${language}): ${subtitleFiles.map((file) => file.path).join(", ")}`);
310
- console.log(thumbnailFile ? `Thumbnail (${thumbnail.time}s): ${thumbnailFile}` : 'No thumbnail: set "poster" in film.config.json to the seconds of the frame to save as one.');
301
+ console.log(thumbnailFile ? `Thumbnail (${designedThumbnail ? config.thumbnail : `${thumbnail.time}s`}): ${thumbnailFile}` : 'No thumbnail: set "poster" in film.config.json to the seconds of the frame to save as one, or "thumbnail" to an image in this animation\'s folder.');
311
302
  if (watermark) console.log(`Watermarked copy ("Made with AnimaGuru"): ${watermarked}`);
312
303
  response.once("finish", () => { void server.close(); });
313
304
  send({ path: destination, bytes, subtitles: subtitleFiles.map((file) => file.path), thumbnail: thumbnailFile, watermarked: watermark ? watermarked : null }); return;
@@ -1,4 +1,10 @@
1
- /** Decisions npm run export:mp4 makes about the MP4's streams, apart from its capture server so they can be tested. */
1
+ /** Decisions npm run export:mp4 makes about the MP4's streams and thumbnail, apart from its capture server so they can be tested. */
2
+ import { execFile } from "node:child_process";
3
+ import { realpath, stat } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ const run = promisify(execFile);
2
8
 
3
9
  // MP4 language tags are ISO 639-2 codes: FFmpeg writes a two-letter code such as "sv" as no tag at all.
4
10
  const LANGUAGE_TAGS = { en: "eng", tr: "tur", sv: "swe", de: "deu", fr: "fra", es: "spa" };
@@ -31,3 +37,55 @@ export function canCopySoundtrack(probe, duration) {
31
37
  return stream?.codec_name === "aac" && stream.profile === "LC" && Number(stream.sample_rate) === 48000
32
38
  && Math.abs(Number(stream.start_time ?? Number.NaN)) < 0.001 && length >= duration - 0.05;
33
39
  }
40
+
41
+ /** The width and height in a JPEG's frame header, or null when the bytes are not a complete JPEG. */
42
+ export function jpegDimensions(bytes) {
43
+ if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
44
+ let cursor = 2;
45
+ while (cursor + 4 < bytes.length) {
46
+ if (bytes[cursor] !== 0xff) return null;
47
+ const marker = bytes[cursor + 1];
48
+ const size = bytes.readUInt16BE(cursor + 2);
49
+ if (size < 2 || cursor + 2 + size > bytes.length) return null;
50
+ if ([0xc0, 0xc1, 0xc2].includes(marker) && size >= 8) return { height: bytes.readUInt16BE(cursor + 5), width: bytes.readUInt16BE(cursor + 7) };
51
+ cursor += 2 + size;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ // YouTube accepts custom thumbnails up to 2 MB.
57
+ export const THUMBNAIL_LIMIT = 2 * 1024 * 1024;
58
+
59
+ /**
60
+ * The file film.config.json's "thumbnail" names: a PNG, JPEG or WebP inside the animation's folder, such as a designed
61
+ * cover in production/, saved instead of the poster frame. null when the key is left out; throws for any other value.
62
+ */
63
+ export async function thumbnailSource(projectRoot, value) {
64
+ if (value == null) return null;
65
+ const invalid = () => new Error("Set thumbnail in film.config.json to a PNG, JPEG or WebP path inside this animation's folder, such as production/thumbnail.png, or leave it out.");
66
+ if (typeof value !== "string" || !/\.(png|jpe?g|webp)$/i.test(value) || path.isAbsolute(value) || value.split(/[\\/]/).includes("..")) throw invalid();
67
+ const root = await realpath(projectRoot);
68
+ // realpath follows links, so a link in the folder cannot point the export at a file outside it.
69
+ const file = await realpath(path.join(root, value)).catch((error) => {
70
+ throw ["ENOENT", "ENOTDIR"].includes(error.code) ? new Error(`The thumbnail ${value} in film.config.json does not exist.`) : error;
71
+ });
72
+ if (!file.startsWith(root + path.sep)) throw invalid();
73
+ if (!(await stat(file)).isFile()) throw new Error(`The thumbnail ${value} in film.config.json is not a file.`);
74
+ return file;
75
+ }
76
+
77
+ /**
78
+ * A designed thumbnail as the export saves it: scaled to cover width × height, centre-cropped to the film's shape, and
79
+ * encoded as a JPEG at the best of three qualities that stays within YouTube's limit.
80
+ */
81
+ export async function encodeThumbnail(ffmpeg, source, { width, height }, name = path.basename(source)) {
82
+ for (const quality of [2, 4, 7]) {
83
+ const { stdout } = await run(ffmpeg, ["-v", "error", "-i", source, "-vf", `scale=${width}:${height}:force_original_aspect_ratio=increase:flags=lanczos,crop=${width}:${height}`, "-frames:v", "1", "-pix_fmt", "yuvj420p", "-q:v", String(quality), "-c:v", "mjpeg", "-f", "image2pipe", "pipe:1"], { encoding: "buffer", maxBuffer: 16 * 1024 * 1024 })
84
+ // FFmpeg's first line says what is wrong with the file, such as "Invalid PNG signature"; the rest follows from it.
85
+ .catch((error) => { throw new Error(`FFmpeg could not read the thumbnail ${name} as an image: ${String(error.stderr || error.message).trim().split("\n")[0].replace(/^(\[[^\]]*\]\s*)+/, "")}`); });
86
+ const dimensions = jpegDimensions(stdout);
87
+ if (dimensions?.width !== width || dimensions?.height !== height) throw new Error(`Could not encode the thumbnail ${name} as a ${width} × ${height} JPEG.`);
88
+ if (stdout.length <= THUMBNAIL_LIMIT) return stdout;
89
+ }
90
+ throw new Error(`The thumbnail ${name} stays above YouTube's 2 MB limit as a JPEG; use a simpler image.`);
91
+ }
@@ -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": {
@@ -8,6 +8,8 @@ export type FilmConfig = {
8
8
  duration: number; language: string; audio: string | null;
9
9
  /** Seconds of the frame the MP4 export saves as its JPEG thumbnail. */
10
10
  poster?: number | null;
11
+ /** A PNG, JPEG or WebP in the animation's folder that the MP4 export saves as its thumbnail instead of the poster frame. */
12
+ thumbnail?: string | null;
11
13
  /** Platform UI margins: "reels", "none", or { top, right, bottom, left } in pixels. Review flags text outside it. */
12
14
  safeArea?: { top: number; right: number; bottom: number; left: number } | string | null;
13
15
  /** Smallest readable font size in pixels for review; defaults to 2.2% of the shorter side (24 px at 1080). */
@@ -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
  }