@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 +5 -3
- package/dist/animaguru.js +41 -16
- package/package.json +1 -1
- package/skills/animaguru/references/sharing.md +3 -3
- package/skills/animaguru-video/SKILL.md +2 -2
- package/templates/video/AGENTS.md +1 -1
- package/templates/video/CLAUDE.md +1 -1
- package/templates/video/README.md +2 -2
- package/templates/video/WORKSPACE-AGENTS.md +1 -1
- package/templates/video/scripts/export-mp4.mjs +11 -20
- package/templates/video/scripts/mp4-tools.mjs +59 -1
- package/templates/video/src/player-types.ts +2 -0
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
48482
|
-
|
|
48483
|
-
|
|
48484
|
-
|
|
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)
|
|
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
|
|
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("
|
|
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
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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). */
|