@docyrus/animaguru 0.33.0 → 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/README.md CHANGED
@@ -59,6 +59,8 @@ CLI 0.28.0 also adds four 3D styles in a new 3D scenes family, each with a runna
59
59
 
60
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
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
+
62
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.
63
65
 
64
66
  CLI 0.5.2 includes a complete local PixiJS video workspace, now with 60 visual styles:
@@ -613,7 +615,7 @@ animaguru submit --file production/exports/film.zip --dry-run
613
615
  animaguru submit --workspace team_… --yes # Keeps it in a team's shared library
614
616
  ```
615
617
 
616
- 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.
617
619
 
618
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:
619
621
 
@@ -634,7 +636,7 @@ animaguru submit --file production/exports/film.mp4 --poster production/exports/
634
636
 
635
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.
636
638
 
637
- `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.
638
640
 
639
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`:
640
642
 
@@ -685,7 +687,7 @@ The optional positional directory sets the root for relative paths. The default
685
687
 
686
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.
687
689
 
688
- 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.
689
691
 
690
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).
691
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.33.0",
19764
+ version: "0.34.0",
19765
19765
  description: "Agent-friendly AnimaGuru CLI built with CrustJS",
19766
19766
  type: "module",
19767
19767
  bin: {
@@ -47421,6 +47421,7 @@ var videoSubmission = videoDetails.and(external_exports.object({
47421
47421
  showcase: showcaseEntry.optional()
47422
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.");
47423
47423
  var videoMove = external_exports.object({ workspace: videoWorkspace }).strict();
47424
+ var VIDEO_POSTER_BYTES = 2 * 1024 * 1024;
47424
47425
  function videoPosterType(bytes) {
47425
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";
47426
47427
  if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return "image/jpeg";
@@ -47496,7 +47497,7 @@ async function readSubtitleFile(file2, maxBytes) {
47496
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.");
47497
47498
  return { content, sha256: createHash2("sha256").update(content).digest("hex"), language: /^\uFEFF?WEBVTT[^\n]*\n(?:[^\n]+\n)*?Language:[ \t]*([^\s]+)/.exec(text2)?.[1] };
47498
47499
  }
47499
- async function subtitleFile(root, mp4, choice, extensions) {
47500
+ async function companionFile(root, mp4, choice, extensions) {
47500
47501
  if (choice === "none") return null;
47501
47502
  if (choice !== "auto") return { file: resolve7(root, choice), explicit: true };
47502
47503
  for (const extension of extensions) {
@@ -48470,7 +48471,7 @@ async function submitVideo(client, options) {
48470
48471
  let subtitles = null;
48471
48472
  const choice = options.subtitles ?? "auto";
48472
48473
  if (format === "html5" && choice !== "auto" && choice !== "none") throw new Error("Subtitle files are for MP4 uploads; HTML5 films show their own captions.");
48473
- 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;
48474
48475
  if (found) {
48475
48476
  const { content, language: header } = await readSubtitleFile(found.file, SUBTITLE_MAX_BYTES);
48476
48477
  const language = subtitleLanguage.safeParse(options["subtitle-language"] ?? header ?? receipt.language ?? metadata.language);
@@ -48478,15 +48479,10 @@ async function submitVideo(client, options) {
48478
48479
  parseSubtitles(content.toString("utf8"), body.duration);
48479
48480
  subtitles = { file: found.file, language: language.data, content };
48480
48481
  }
48481
- let poster;
48482
- if (options.poster) {
48483
- const path = resolve10(root, options.poster);
48484
- const info2 = await stat4(path);
48485
- if (!info2.isFile() || info2.size > 2 * 1024 * 1024) throw new Error("Poster must be a PNG, JPEG or WebP up to 2 MiB.");
48486
- poster = await readFile9(path);
48487
- videoPosterType(poster);
48488
- }
48489
- 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 } };
48490
48486
  const draft = await client.request("/api/v1/videos", { method: "POST", body });
48491
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.");
48492
48488
  const handle = await open4(file2, "r");
@@ -48509,7 +48505,10 @@ async function submitVideo(client, options) {
48509
48505
  }
48510
48506
  }
48511
48507
  }
48512
- 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
+ }
48513
48512
  if (subtitles) {
48514
48513
  options.log?.(`Adding ${subtitles.language} subtitles from ${subtitles.file}\u2026`);
48515
48514
  await client.request(`/api/v1/videos/${draft.id}/subtitles/${subtitles.language}`, { method: "PUT", body: subtitlesBody(subtitles.content) });
@@ -48540,6 +48539,28 @@ async function changeVideoSubtitles(client, id2, options) {
48540
48539
  parseSubtitles(content.toString("utf8"));
48541
48540
  return client.request(path + language.data, { method: "PUT", body: subtitlesBody(content) });
48542
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
+ }
48543
48564
  async function listVideos(client, options) {
48544
48565
  const scope = options.scope ?? "mine";
48545
48566
  const page = options.page ?? 1;
@@ -49523,7 +49544,7 @@ async function inspectMp4(file2, signal) {
49523
49544
  var readCaptionFile = (file2) => readSubtitleFile(file2, 5 * 1024 * 1024);
49524
49545
  async function planCaptions(root, mp4, options, fallbackLanguage) {
49525
49546
  const choice = options.captions ?? "auto";
49526
- const found = await subtitleFile(root, mp4, choice, [".srt", ".vtt"]);
49547
+ const found = await companionFile(root, mp4, choice, [".srt", ".vtt"]);
49527
49548
  if (!found) return null;
49528
49549
  const { file: file2 } = found;
49529
49550
  const { content, sha256, language: header } = await readCaptionFile(file2);
@@ -50913,7 +50934,7 @@ function createCli() {
50913
50934
  { name: "description", type: "string" },
50914
50935
  { name: "visibility", type: "string", choices: ["private", "public"], default: "private" },
50915
50936
  { name: "share", type: "string", description: "Comma-separated emails of verified AnimaGuru users (private videos)" },
50916
- { 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" },
50917
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" },
50918
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" },
50919
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" },
@@ -50940,7 +50961,11 @@ function createCli() {
50940
50961
  const result = await moveVideos(new Client(flags.url, signal), args.ids ?? [], flags);
50941
50962
  out(stdout, result);
50942
50963
  if (result.error) throw new Error(result.error);
50943
- }))).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(
50944
50969
  { name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } },
50945
50970
  { name: "file", type: "string", description: ".srt or .vtt subtitle file" },
50946
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.33.0",
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.
@@ -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
 
@@ -114,7 +114,7 @@ Browse `animaguru.templates.json` for all 60 styles and purpose workflows. The 4
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
 
@@ -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
+ }
@@ -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). */