@docyrus/animaguru 0.33.0 → 0.35.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 +7 -3
- package/dist/animaguru.js +84 -27
- package/package.json +1 -1
- package/skills/animaguru/SKILL.md +1 -1
- package/skills/animaguru/references/sharing.md +3 -3
- package/skills/animaguru-3d-models/SKILL.md +40 -1
- package/skills/animaguru-product-turntable-3d/SKILL.md +2 -2
- package/skills/animaguru-product-turntable-3d/references/recipe.md +1 -1
- 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 +3 -3
- package/templates/video/WORKSPACE-AGENTS.md +1 -1
- package/templates/video/package.json +1 -0
- package/templates/video/scripts/export-mp4.mjs +11 -20
- package/templates/video/scripts/headless-browser.mjs +12 -2
- package/templates/video/scripts/mp4-tools.mjs +59 -1
- package/templates/video/scripts/review.mjs +4 -1
- package/templates/video/scripts/workspace-plugin.mjs +5 -2
- package/templates/video/src/kit/film-base.ts +6 -2
- package/templates/video/src/kit/three-stage.ts +112 -7
- package/templates/video/src/player-types.ts +2 -0
- package/templates/video/src/studies/three/index.ts +2 -2
- package/templates/video/src/studies/three-film.ts +2 -2
package/README.md
CHANGED
|
@@ -59,6 +59,10 @@ 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
|
+
|
|
64
|
+
CLI 0.35.0 adds photoreal Gaussian splats: `assets generate --type model --model idol --reference SHA` turns the object in a saved image into splats with Sperid Labs' Idol on fal (`--photo` for a camera photo, `--splats` 32,768–1,048,576, `--seed`), saved as `.spz`, upright and facing +Z. New video workspaces include Spark, and the kit's `stage.splat()` draws splats in a three.js shot, sorted for each frame's camera, with `photoView()` to open on the reference image's exact view and `softShadow()` to ground them. See the `animaguru-3d-models` skill.
|
|
65
|
+
|
|
62
66
|
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
67
|
|
|
64
68
|
CLI 0.5.2 includes a complete local PixiJS video workspace, now with 60 visual styles:
|
|
@@ -613,7 +617,7 @@ animaguru submit --file production/exports/film.zip --dry-run
|
|
|
613
617
|
animaguru submit --workspace team_… --yes # Keeps it in a team's shared library
|
|
614
618
|
```
|
|
615
619
|
|
|
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.
|
|
620
|
+
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
621
|
|
|
618
622
|
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
623
|
|
|
@@ -634,7 +638,7 @@ animaguru submit --file production/exports/film.mp4 --poster production/exports/
|
|
|
634
638
|
|
|
635
639
|
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
640
|
|
|
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.
|
|
641
|
+
`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
642
|
|
|
639
643
|
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
644
|
|
|
@@ -685,7 +689,7 @@ The optional positional directory sets the root for relative paths. The default
|
|
|
685
689
|
|
|
686
690
|
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
691
|
|
|
688
|
-
Thumbnail: when `film.config.json` sets `"poster"` (seconds), the export also saves that frame as `FILM.jpg
|
|
692
|
+
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
693
|
|
|
690
694
|
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
695
|
|
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.35.0",
|
|
19765
19765
|
description: "Agent-friendly AnimaGuru CLI built with CrustJS",
|
|
19766
19766
|
type: "module",
|
|
19767
19767
|
bin: {
|
|
@@ -46879,6 +46879,7 @@ async function createAnimation(options) {
|
|
|
46879
46879
|
options.signal?.throwIfAborted();
|
|
46880
46880
|
const root = await findWorkspace(options.workspace);
|
|
46881
46881
|
const directory = join7(root, "animations", options.name);
|
|
46882
|
+
await mkdir7(join7(root, "animations"), { recursive: true });
|
|
46882
46883
|
try {
|
|
46883
46884
|
await mkdir7(directory);
|
|
46884
46885
|
} catch (error62) {
|
|
@@ -46921,19 +46922,30 @@ var MODEL_GENERATORS = [
|
|
|
46921
46922
|
endpoints: { image: "tripo3d/h3.1/image-to-3d", text: "tripo3d/h3.1/text-to-3d" },
|
|
46922
46923
|
faces: { min: 1e3, max: 2e6 },
|
|
46923
46924
|
/** The direction the generated model faces; saved models are turned to +Z. */
|
|
46924
|
-
facing: "+x"
|
|
46925
|
+
facing: "+x",
|
|
46926
|
+
/** What the generator makes: a GLB mesh, or Gaussian splats saved as SPZ. */
|
|
46927
|
+
output: "glb"
|
|
46925
46928
|
},
|
|
46926
46929
|
{
|
|
46927
46930
|
id: "hunyuan-3d-3.1-pro",
|
|
46928
46931
|
label: "Hunyuan3D 3.1 Pro",
|
|
46929
46932
|
endpoints: { image: "fal-ai/hunyuan-3d/v3.1/pro/image-to-3d", text: "fal-ai/hunyuan-3d/v3.1/pro/text-to-3d" },
|
|
46930
46933
|
faces: { min: 4e4, max: 15e5 },
|
|
46931
|
-
facing: "+z"
|
|
46934
|
+
facing: "+z",
|
|
46935
|
+
output: "glb"
|
|
46936
|
+
},
|
|
46937
|
+
{
|
|
46938
|
+
id: "idol",
|
|
46939
|
+
label: "Idol (Gaussian splat)",
|
|
46940
|
+
endpoints: { image: "speridlabs/idol" },
|
|
46941
|
+
splats: { min: 32768, max: 1048576, default: 262144 },
|
|
46942
|
+
output: "spz"
|
|
46932
46943
|
}
|
|
46933
46944
|
];
|
|
46934
46945
|
var MODEL_GENERATOR_IDS = MODEL_GENERATORS.map((m) => m.id);
|
|
46935
46946
|
var DEFAULT_MODEL_GENERATOR = "tripo-h3.1";
|
|
46936
46947
|
var modelGenerator = (id2) => MODEL_GENERATORS.find((m) => m.id === id2) ?? MODEL_GENERATORS[0];
|
|
46948
|
+
var isSplatGenerator = (id2) => modelGenerator(id2).output === "spz";
|
|
46937
46949
|
var MAX_MODEL_CLIPS = 10;
|
|
46938
46950
|
var MAX_RIG_FACES = 3e5;
|
|
46939
46951
|
var MAX_MESHY_ACTION = 696;
|
|
@@ -46966,7 +46978,7 @@ var MODEL_ANIMATIONS = [
|
|
|
46966
46978
|
var MODEL_ANIMATION_IDS = MODEL_ANIMATIONS.map((a) => a.id);
|
|
46967
46979
|
var DEFAULT_MODEL_ANIMATIONS = ["idle", "walk"];
|
|
46968
46980
|
var animationAction = (clip) => typeof clip === "number" ? clip : MODEL_ANIMATIONS.find((a) => a.id === clip).action;
|
|
46969
|
-
var generationCostUsd = (p) => p.model === "hunyuan-3d-3.1-pro" ? 0.375 + (p.texture && p.pbr ? 0.15 : 0) + (p.faces !== void 0 ? 0.15 : 0) : p.texture ? 0.3 : 0.2;
|
|
46981
|
+
var generationCostUsd = (p) => p.model === "idol" ? 0.1 : p.model === "hunyuan-3d-3.1-pro" ? 0.375 + (p.texture && p.pbr ? 0.15 : 0) + (p.faces !== void 0 ? 0.15 : 0) : p.texture ? 0.3 : 0.2;
|
|
46970
46982
|
var rigCostUsd = (clips2) => 0.2 + 0.12 * clips2;
|
|
46971
46983
|
var modelCostUsd = (p) => generationCostUsd(p) + (p.rig ? rigCostUsd(p.animations.length) : 0);
|
|
46972
46984
|
|
|
@@ -47298,11 +47310,27 @@ var modelAsset = external_exports.object({
|
|
|
47298
47310
|
/** Clips the rig gets (idle and walk when none are named). */
|
|
47299
47311
|
animations: external_exports.array(animationClip).max(MAX_MODEL_CLIPS).default([]),
|
|
47300
47312
|
/** The rigged character's height in metres (default 1.7). */
|
|
47301
|
-
height: external_exports.number().min(0.3).max(5).optional()
|
|
47313
|
+
height: external_exports.number().min(0.3).max(5).optional(),
|
|
47314
|
+
/** Idol: how many splats to make (262,144 unless set). */
|
|
47315
|
+
splats: external_exports.number().int().optional(),
|
|
47316
|
+
/** Idol: the reference is a photograph, which it crops more loosely than a render or a generated image. */
|
|
47317
|
+
photo: external_exports.boolean().optional(),
|
|
47318
|
+
/** Idol: repeats a generation's randomness. */
|
|
47319
|
+
seed: external_exports.number().int().min(0).max(2 ** 32 - 1).optional()
|
|
47302
47320
|
});
|
|
47303
47321
|
function modelChecks(p, ctx) {
|
|
47304
47322
|
const issue2 = (path, message) => ctx.addIssue({ code: "custom", message, path: [path] });
|
|
47305
47323
|
const generator = modelGenerator(p.model);
|
|
47324
|
+
if (generator.output === "spz") {
|
|
47325
|
+
const range = generator.splats;
|
|
47326
|
+
if (!p.reference) issue2("reference", `${generator.label} builds splats from a saved image; choose one as the reference.`);
|
|
47327
|
+
if (p.splats !== void 0 && (p.splats < range.min || p.splats > range.max)) issue2("splats", `${generator.label} makes ${range.min.toLocaleString("en-US")}\u2013${range.max.toLocaleString("en-US")} splats.`);
|
|
47328
|
+
if (p.faces !== void 0) issue2("faces", "Splats have no faces; set splats instead.");
|
|
47329
|
+
if (!p.texture) issue2("texture", "Splats always keep the image's colours.");
|
|
47330
|
+
if (p.rig || p.animations.length || p.height !== void 0) issue2("rig", "Splats can't be rigged. Build a rigged character with Tripo H3.1 or Hunyuan3D 3.1 Pro.");
|
|
47331
|
+
return;
|
|
47332
|
+
}
|
|
47333
|
+
for (const field of ["splats", "photo", "seed"]) if (p[field] !== void 0) issue2(field, `${field} is an option of Idol's splats.`);
|
|
47306
47334
|
if (!p.reference && p.prompt.length < 3) issue2("prompt", "Describe the model in at least 3 characters, or choose a saved image to build it from.");
|
|
47307
47335
|
if (!p.reference && p.prompt.length > MAX_MODEL_PROMPT) issue2("prompt", `Describe the model in at most ${MAX_MODEL_PROMPT.toLocaleString("en-US")} characters.`);
|
|
47308
47336
|
if (p.faces !== void 0 && (p.faces < generator.faces.min || p.faces > generator.faces.max)) issue2("faces", `${generator.label} makes ${generator.faces.min.toLocaleString("en-US")}\u2013${generator.faces.max.toLocaleString("en-US")} faces.`);
|
|
@@ -47421,6 +47449,7 @@ var videoSubmission = videoDetails.and(external_exports.object({
|
|
|
47421
47449
|
showcase: showcaseEntry.optional()
|
|
47422
47450
|
})).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
47451
|
var videoMove = external_exports.object({ workspace: videoWorkspace }).strict();
|
|
47452
|
+
var VIDEO_POSTER_BYTES = 2 * 1024 * 1024;
|
|
47424
47453
|
function videoPosterType(bytes) {
|
|
47425
47454
|
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
47455
|
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return "image/jpeg";
|
|
@@ -47496,7 +47525,7 @@ async function readSubtitleFile(file2, maxBytes) {
|
|
|
47496
47525
|
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
47526
|
return { content, sha256: createHash2("sha256").update(content).digest("hex"), language: /^\uFEFF?WEBVTT[^\n]*\n(?:[^\n]+\n)*?Language:[ \t]*([^\s]+)/.exec(text2)?.[1] };
|
|
47498
47527
|
}
|
|
47499
|
-
async function
|
|
47528
|
+
async function companionFile(root, mp4, choice, extensions) {
|
|
47500
47529
|
if (choice === "none") return null;
|
|
47501
47530
|
if (choice !== "auto") return { file: resolve7(root, choice), explicit: true };
|
|
47502
47531
|
for (const extension of extensions) {
|
|
@@ -48470,7 +48499,7 @@ async function submitVideo(client, options) {
|
|
|
48470
48499
|
let subtitles = null;
|
|
48471
48500
|
const choice = options.subtitles ?? "auto";
|
|
48472
48501
|
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
|
|
48502
|
+
const found = format === "mp4" ? await companionFile(root, file2, choice, [".vtt", ".srt"]) : null;
|
|
48474
48503
|
if (found) {
|
|
48475
48504
|
const { content, language: header } = await readSubtitleFile(found.file, SUBTITLE_MAX_BYTES);
|
|
48476
48505
|
const language = subtitleLanguage.safeParse(options["subtitle-language"] ?? header ?? receipt.language ?? metadata.language);
|
|
@@ -48478,15 +48507,10 @@ async function submitVideo(client, options) {
|
|
|
48478
48507
|
parseSubtitles(content.toString("utf8"), body.duration);
|
|
48479
48508
|
subtitles = { file: found.file, language: language.data, content };
|
|
48480
48509
|
}
|
|
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 } };
|
|
48510
|
+
const posterChoice = options.poster ?? "auto";
|
|
48511
|
+
const posterFile = format === "html5" && posterChoice === "auto" ? null : await companionFile(root, file2, posterChoice, [".jpg", ".jpeg", ".png", ".webp"]);
|
|
48512
|
+
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;
|
|
48513
|
+
if (options["dry-run"]) return { dryRun: true, file: file2, submission: body, poster: posterFile?.file ?? null, subtitles: subtitles && { file: subtitles.file, language: subtitles.language } };
|
|
48490
48514
|
const draft = await client.request("/api/v1/videos", { method: "POST", body });
|
|
48491
48515
|
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
48516
|
const handle = await open4(file2, "r");
|
|
@@ -48509,7 +48533,10 @@ async function submitVideo(client, options) {
|
|
|
48509
48533
|
}
|
|
48510
48534
|
}
|
|
48511
48535
|
}
|
|
48512
|
-
if (poster)
|
|
48536
|
+
if (poster) {
|
|
48537
|
+
if (!posterFile.explicit) options.log?.(`Adding the cover from ${posterFile.file}\u2026`);
|
|
48538
|
+
await client.request(`/api/v1/videos/${draft.id}/poster`, { method: "PUT", body: new Blob([new Uint8Array(poster)]) });
|
|
48539
|
+
}
|
|
48513
48540
|
if (subtitles) {
|
|
48514
48541
|
options.log?.(`Adding ${subtitles.language} subtitles from ${subtitles.file}\u2026`);
|
|
48515
48542
|
await client.request(`/api/v1/videos/${draft.id}/subtitles/${subtitles.language}`, { method: "PUT", body: subtitlesBody(subtitles.content) });
|
|
@@ -48540,6 +48567,28 @@ async function changeVideoSubtitles(client, id2, options) {
|
|
|
48540
48567
|
parseSubtitles(content.toString("utf8"));
|
|
48541
48568
|
return client.request(path + language.data, { method: "PUT", body: subtitlesBody(content) });
|
|
48542
48569
|
}
|
|
48570
|
+
async function readPoster(file2, hint = "") {
|
|
48571
|
+
const info = await stat4(file2).catch(() => {
|
|
48572
|
+
throw new Error(`Cannot read the poster ${file2}.`);
|
|
48573
|
+
});
|
|
48574
|
+
const unusable = new Error(`Poster must be a PNG, JPEG or WebP up to 2 MiB${hint}.`);
|
|
48575
|
+
if (!info.isFile() || info.size > VIDEO_POSTER_BYTES) throw unusable;
|
|
48576
|
+
const poster = await readFile9(file2);
|
|
48577
|
+
try {
|
|
48578
|
+
videoPosterType(poster);
|
|
48579
|
+
} catch {
|
|
48580
|
+
throw unusable;
|
|
48581
|
+
}
|
|
48582
|
+
return poster;
|
|
48583
|
+
}
|
|
48584
|
+
async function changeVideoPoster(client, id2, options) {
|
|
48585
|
+
if (!options.yes) throw new Error("Pass --yes to replace this video's poster.");
|
|
48586
|
+
if (!options.file) throw new Error("Pass --file with a PNG, JPEG or WebP image up to 2 MiB.");
|
|
48587
|
+
const file2 = resolve10(options.file);
|
|
48588
|
+
const poster = await readPoster(file2, ", such as the .jpg npm run export:mp4 saves beside the MP4");
|
|
48589
|
+
const { video = null } = await client.request(`/api/v1/videos/${encodeURIComponent(id2)}/poster`, { method: "PUT", body: new Blob([new Uint8Array(poster)]) });
|
|
48590
|
+
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." } : {} };
|
|
48591
|
+
}
|
|
48543
48592
|
async function listVideos(client, options) {
|
|
48544
48593
|
const scope = options.scope ?? "mine";
|
|
48545
48594
|
const page = options.page ?? 1;
|
|
@@ -48606,7 +48655,7 @@ var extras = {
|
|
|
48606
48655
|
music: [],
|
|
48607
48656
|
speech: ["voice", "delivery"],
|
|
48608
48657
|
sfx: ["seconds", "loop", "prompt-influence"],
|
|
48609
|
-
model: ["reference", "texture", "pbr", "faces", "rig", "animations", "height"],
|
|
48658
|
+
model: ["reference", "texture", "pbr", "faces", "rig", "animations", "height", "splats", "photo", "seed"],
|
|
48610
48659
|
vector: ["workflow", "references", "source", "aspect-ratio", "quality", "shapes", "seed"]
|
|
48611
48660
|
};
|
|
48612
48661
|
var list2 = (items) => items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
|
|
@@ -48646,7 +48695,7 @@ function assetFromFlags(flags) {
|
|
|
48646
48695
|
const given2 = value !== void 0 && (value !== false || (name2 === "texture" || name2 === "pbr") && flags.type !== "model");
|
|
48647
48696
|
if (given2 && !extras[flags.type]?.includes(name2)) throw new Error(`--${name2} is not an option for ${flags.type} assets. ${accepted(flags.type)}`.trim());
|
|
48648
48697
|
}
|
|
48649
|
-
if (flags.type !== "video" && flags.type !== "vector" && !(flags.type === "model" && flags.reference) && flags.prompt === void 0) throw new Error(`--prompt is required for ${flags.type} assets${flags.type === "model" ? " unless --reference names an image to build from" : ""}.`);
|
|
48698
|
+
if (flags.type !== "video" && flags.type !== "vector" && !(flags.type === "model" && (flags.reference || isSplatGenerator(flags.model ?? ""))) && flags.prompt === void 0) throw new Error(`--prompt is required for ${flags.type} assets${flags.type === "model" ? " unless --reference names an image to build from" : ""}.`);
|
|
48650
48699
|
const base2 = { type: flags.type, name: flags.name, prompt: flags.prompt, model: flags.model };
|
|
48651
48700
|
if (flags.type === "image") return parseAsset({ ...base2, aspectRatio: flags["aspect-ratio"], transparent: flags.transparent, references: shas(flags.references), mask: regions(flags.mask) });
|
|
48652
48701
|
if (flags.type === "video") {
|
|
@@ -48684,7 +48733,7 @@ function assetFromFlags(flags) {
|
|
|
48684
48733
|
});
|
|
48685
48734
|
}
|
|
48686
48735
|
if (flags.type === "sfx") return parseAsset({ ...base2, seconds: flags.seconds, loop: flags.loop, promptInfluence: flags["prompt-influence"] });
|
|
48687
|
-
if (flags.type === "model") return parseAsset({ ...base2, reference: flags.reference, texture: flags.texture, pbr: flags.pbr, faces: flags.faces, rig: flags.rig, animations: clips(flags.animations), height: flags.height });
|
|
48736
|
+
if (flags.type === "model") return parseAsset({ ...base2, reference: flags.reference, texture: flags.texture, pbr: flags.pbr, faces: flags.faces, rig: flags.rig, animations: clips(flags.animations), height: flags.height, splats: flags.splats, photo: flags.photo || void 0, seed: flags.seed });
|
|
48688
48737
|
if (flags.type === "vector") return parseAsset({ ...base2, workflow: flags.workflow, quality: flags.quality, aspectRatio: flags["aspect-ratio"], references: shas(flags.references), source: flags.source, shapes: flags.shapes, seed: flags.seed });
|
|
48689
48738
|
return parseAsset({ ...base2, ...flags.type === "speech" ? { voice: flags.voice, delivery: flags.delivery } : {} });
|
|
48690
48739
|
}
|
|
@@ -48784,7 +48833,8 @@ var MIME_EXTENSIONS = {
|
|
|
48784
48833
|
"image/svg+xml": "svg",
|
|
48785
48834
|
"video/mp4": "mp4",
|
|
48786
48835
|
"video/webm": "webm",
|
|
48787
|
-
"model/gltf-binary": "glb"
|
|
48836
|
+
"model/gltf-binary": "glb",
|
|
48837
|
+
"model/x-spz": "spz"
|
|
48788
48838
|
};
|
|
48789
48839
|
var extensionFor = (asset) => MIME_EXTENSIONS[asset.mimeType?.split(";")[0].trim().toLowerCase()] ?? /\.([a-z0-9]{1,5})$/i.exec(asset.file ?? "")?.[1]?.toLowerCase() ?? "bin";
|
|
48790
48840
|
async function downloadTarget(output2, asset, folder = false) {
|
|
@@ -49523,7 +49573,7 @@ async function inspectMp4(file2, signal) {
|
|
|
49523
49573
|
var readCaptionFile = (file2) => readSubtitleFile(file2, 5 * 1024 * 1024);
|
|
49524
49574
|
async function planCaptions(root, mp4, options, fallbackLanguage) {
|
|
49525
49575
|
const choice = options.captions ?? "auto";
|
|
49526
|
-
const found = await
|
|
49576
|
+
const found = await companionFile(root, mp4, choice, [".srt", ".vtt"]);
|
|
49527
49577
|
if (!found) return null;
|
|
49528
49578
|
const { file: file2 } = found;
|
|
49529
49579
|
const { content, sha256, language: header } = await readCaptionFile(file2);
|
|
@@ -49973,6 +50023,7 @@ async function installFilmTemplate(client, id2, options) {
|
|
|
49973
50023
|
await mkdir12(join15(staging, "public/assets"), { recursive: true });
|
|
49974
50024
|
await mkdir12(join15(staging, "production"), { recursive: true });
|
|
49975
50025
|
if (await exists3(directory)) throw new Error(`Animation already exists: ${name2}. Existing files were not changed.`);
|
|
50026
|
+
await mkdir12(join15(root, "animations"), { recursive: true });
|
|
49976
50027
|
await rename6(staging, directory);
|
|
49977
50028
|
} finally {
|
|
49978
50029
|
await rm3(staging, { recursive: true, force: true });
|
|
@@ -50494,7 +50545,7 @@ async function reviewCheck(client, options) {
|
|
|
50494
50545
|
const context = await localProject(options.directory, options.animation);
|
|
50495
50546
|
const reportFile = join19(context.projectRoot, "production/review/report.json");
|
|
50496
50547
|
const report2 = JSON.parse(await readFile19(reportFile, "utf8").catch(() => {
|
|
50497
|
-
throw new Error("
|
|
50548
|
+
throw new Error("production/review/report.json is missing: run npm run review and let it finish. A review that fails, such as one whose player did not load, leaves no frames to check.");
|
|
50498
50549
|
}));
|
|
50499
50550
|
const frames = await framesToCheck(context.projectRoot, options.source ?? "review", report2);
|
|
50500
50551
|
if (frames.length > FRAME_CHECK_MAX_FRAMES) throw new Error(`Check at most ${FRAME_CHECK_MAX_FRAMES} frames at once; run npm run frames -- --full --at with fewer times.`);
|
|
@@ -50798,7 +50849,7 @@ var catalogSections = {
|
|
|
50798
50849
|
model: ["assetTypes", "modelGenerators", "modelAnimations"],
|
|
50799
50850
|
avatars: ["avatarTypes", "avatarLookTypes"]
|
|
50800
50851
|
};
|
|
50801
|
-
var catalog = () => ({ styles: SPRITE_STYLES, kinds: SPRITE_KINDS, actions: ALL_SPRITE_ACTIONS, videoModels: SPRITE_VIDEO_MODEL_IDS, assetTypes: ASSET_TYPES, musicModels: MUSIC_MODELS.map((m) => m.id), speechModels: SPEECH_MODELS.map((m) => m.id), sfxModels: SFX_MODELS.map((m) => m.id), modelGenerators: MODEL_GENERATORS.map(({ id:
|
|
50852
|
+
var catalog = () => ({ styles: SPRITE_STYLES, kinds: SPRITE_KINDS, actions: ALL_SPRITE_ACTIONS, videoModels: SPRITE_VIDEO_MODEL_IDS, assetTypes: ASSET_TYPES, musicModels: MUSIC_MODELS.map((m) => m.id), speechModels: SPEECH_MODELS.map((m) => m.id), sfxModels: SFX_MODELS.map((m) => m.id), modelGenerators: MODEL_GENERATORS.map((g) => ({ id: g.id, label: g.label, output: g.output, ...g.output === "spz" ? { splats: g.splats } : { faces: g.faces } })), modelAnimations: MODEL_ANIMATIONS, vectorModels: VECTOR_MODELS.map((m) => m.id), vectorOptions: { workflows: VECTOR_WORKFLOWS, qualities: VECTOR_QUALITIES, aspectRatios: VECTOR_ASPECT_RATIOS, shapes: VECTOR_SHAPES, maxReferences: MAX_VECTOR_REFERENCES }, elevenLabsVoices: ELEVENLABS_VOICES, voices: SPEECH_VOICES, videoProfiles: SPRITE_VIDEO_MODELS, avatarTypes: AVATAR_CREATE_TYPES, avatarLookTypes: AVATAR_LOOK_TYPES });
|
|
50802
50853
|
var modelFlags = [
|
|
50803
50854
|
{ name: "video-model", type: "string", choices: SPRITE_VIDEO_MODEL_IDS, description: "Video model override for this operation" }
|
|
50804
50855
|
];
|
|
@@ -50913,7 +50964,7 @@ function createCli() {
|
|
|
50913
50964
|
{ name: "description", type: "string" },
|
|
50914
50965
|
{ name: "visibility", type: "string", choices: ["private", "public"], default: "private" },
|
|
50915
50966
|
{ 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" },
|
|
50967
|
+
{ 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
50968
|
{ 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
50969
|
{ 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
50970
|
{ 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 +50991,11 @@ function createCli() {
|
|
|
50940
50991
|
const result = await moveVideos(new Client(flags.url, signal), args.ids ?? [], flags);
|
|
50941
50992
|
out(stdout, result);
|
|
50942
50993
|
if (result.error) throw new Error(result.error);
|
|
50943
|
-
}))).add(defineCommand("
|
|
50994
|
+
}))).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(
|
|
50995
|
+
{ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } },
|
|
50996
|
+
{ 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" },
|
|
50997
|
+
{ name: "yes", type: "boolean", required: true }
|
|
50998
|
+
).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
50999
|
{ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } },
|
|
50945
51000
|
{ name: "file", type: "string", description: ".srt or .vtt subtitle file" },
|
|
50946
51001
|
{ name: "language", type: "string", description: "Language code such as en or tr; defaults to a WebVTT Language header or the export receipt" },
|
|
@@ -51146,7 +51201,7 @@ Confirm code: ${device.user_code}`);
|
|
|
51146
51201
|
{ name: "resolution", type: "string", choices: VIDEO_RESOLUTIONS, description: "Video resolution supported by the selected model and workflow (default its first)" },
|
|
51147
51202
|
{ name: "mode", type: "string", choices: ["cost", "speed", "quality"], description: "Video generation mode supported by the selected model (default its first)" },
|
|
51148
51203
|
{ name: "prompt-expansion", type: "string", choices: PROMPT_EXPANSION_MODES, description: "Video: how much the model may rewrite the prompt (default disabled); extensions treat any other value as on. FLUX 3 Max always reasons about the prompt: quality thoroughly, any other value lightly. HeyGen Video 1 needs balanced or quality for clips over 15 s" },
|
|
51149
|
-
{ name: "seed", type: "number", description: "Video
|
|
51204
|
+
{ name: "seed", type: "number", description: "Video, vector from text, or model with --model idol: seed for repeatable results, where the model takes one" },
|
|
51150
51205
|
{ name: "extend-output", type: "string", choices: EXTEND_OUTPUTS, description: "Extension: extended (default, source plus new footage) or continuation (a 1.625 s overlap, then the new footage)" },
|
|
51151
51206
|
{ name: "voice", type: "string", description: "Gemini voice name or ElevenLabs voice ID (defaults: Kore / George)" },
|
|
51152
51207
|
{ name: "delivery", type: "string", description: "Gemini speech delivery instructions; ElevenLabs uses audio tags in the transcript" },
|
|
@@ -51157,6 +51212,8 @@ Confirm code: ${device.user_code}`);
|
|
|
51157
51212
|
{ name: "rig", type: "boolean", description: "Model: auto-rig a humanoid character standing in a T- or A-pose, with animation clips" },
|
|
51158
51213
|
{ name: "animations", type: "string", description: "Model with --rig: up to 10 comma-separated clips, by name (catalog --type model) or Meshy library number 0\u2013696; default idle,walk" },
|
|
51159
51214
|
{ name: "height", type: "number", description: "Model with --rig: the character's height in metres (default 1.7)" },
|
|
51215
|
+
{ name: "splats", type: "number", description: "Model with --model idol: how many Gaussian splats, 32,768\u20131,048,576 (default 262,144)" },
|
|
51216
|
+
{ name: "photo", type: "boolean", description: "Model with --model idol: --reference is a camera photo, cropped more loosely than a render or generated image" },
|
|
51160
51217
|
{ name: "prompt-influence", type: "number", description: "SFX: prompt adherence, 0\u20131 (default 0.3)" },
|
|
51161
51218
|
{ name: "quality", type: "string", choices: VECTOR_QUALITIES, description: "Vector from text: low (fastest, 13 credits), medium (default, 16) or high (20, or 32 with --references)" },
|
|
51162
51219
|
{ name: "shapes", type: "string", choices: VECTOR_SHAPES, description: "Vector image-to-vector (10 credits): detailed (default) keeps fine detail, simple uses fewer, simpler shapes" },
|
package/package.json
CHANGED
|
@@ -75,7 +75,7 @@ animaguru assets generate PROJECT_ID --type vector --name "Logo" \
|
|
|
75
75
|
--workflow image-to-vector --source IMAGE_SHA --shapes simple --dry-run
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
CLI 0.28.0 adds 3D models: `--type model` builds a GLB from a saved image (`--reference SHA`, best) or from `--prompt` alone, with `--model tripo-h3.1` (default) or `hunyuan-3d-3.1-pro`, `--no-texture`, `--no-pbr` and `--faces`. `--rig` auto-rigs a humanoid character standing in a T- or A-pose, with `--animations idle,walk,wave` (named clips from `catalog --type model`, or Meshy library numbers; default idle,walk) and `--height` in metres. Downloads are `.glb`. Read `animaguru-3d-models` before generating models or building 3D shots: it covers costs, rig limits (no fingers or facial blend shapes), optimisation and the kit's three.js stage.
|
|
78
|
+
CLI 0.28.0 adds 3D models: `--type model` builds a GLB from a saved image (`--reference SHA`, best) or from `--prompt` alone, with `--model tripo-h3.1` (default) or `hunyuan-3d-3.1-pro`, `--no-texture`, `--no-pbr` and `--faces`. `--rig` auto-rigs a humanoid character standing in a T- or A-pose, with `--animations idle,walk,wave` (named clips from `catalog --type model`, or Meshy library numbers; default idle,walk) and `--height` in metres. Downloads are `.glb`. CLI 0.35.0 adds `--model idol`: photoreal Gaussian splats of the object in a saved image (`--reference SHA`, required; `--photo` for a camera photo, `--splats`, `--seed`), downloaded as `.spz` and staged with the kit's `stage.splat()`. Read `animaguru-3d-models` before generating models or building 3D shots: it covers costs, rig limits (no fingers or facial blend shapes), when splats or meshes fit, optimisation and the kit's three.js stage.
|
|
79
79
|
|
|
80
80
|
Use `--dry-run` for local request validation and estimated credits without sign-in or paid work. The estimate does not prove the server has the provider, the credits, or the reference image. Use `--yes` only within the user's existing spending authorization. For file-based requests, write the API asset request as JSON and use `assets submit PROJECT_ID --file asset.json --yes`; `--model` overrides the file's selection for non-image assets. Image model fields in old files are ignored.
|
|
81
81
|
|
|
@@ -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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: animaguru-3d-models
|
|
3
|
-
description: Generate 3D model assets (GLB
|
|
3
|
+
description: Generate 3D model assets with AnimaGuru (GLB meshes, optionally auto-rigged with animation clips, or photoreal Gaussian splats built from one image) and put them into an AnimaGuru film as real three.js 3D shots composited with the PixiJS 2D layers. Use when a film needs a product, prop, set or character in actual 3D (turntables, orbits, real lighting and shadows, a character walking through a scene, a product photo that turns into 3D), or when the user asks for 3D models, GLB files, Gaussian splats, rigging or 3D animation.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# AnimaGuru 3D models
|
|
@@ -31,6 +31,20 @@ animaguru assets generate PROJECT_ID --type model --name "Lantern" --prompt "An
|
|
|
31
31
|
- **What you get.** Every saved model faces +Z (glTF's front) and stands upright. Its asset record lists triangles, file size, height, joints and clips with their lengths.
|
|
32
32
|
- **Licensing.** Generated models are the account's assets like generated images. Record each in `production/` with its prompt, reference and job like other generated media.
|
|
33
33
|
|
|
34
|
+
## Photoreal splats from one image
|
|
35
|
+
|
|
36
|
+
`--model idol` (Sperid Labs' Idol) turns one saved image of an object into Gaussian splats: millions of soft coloured points that keep the image's own colours, materials and lighting. For a real product this is the most faithful option: glass, liquids, brushed metal, fabric and printed labels look like the photo, because they are the photo. It costs about 10 credits, takes about 20 seconds, and needs a saved image (`--reference SHA`); it makes no model from text.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
# A clean product photo or render: the whole object in frame, sharp, ideally on a plain background (Idol cuts it out).
|
|
40
|
+
animaguru assets generate PROJECT_ID --type model --model idol --name "Perfume" --reference SHA --yes --download public/assets/models/
|
|
41
|
+
# --photo when the reference is a camera photo (a looser crop); --splats 524288 for finer detail (32,768–1,048,576, default 262,144); --seed N to repeat one.
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **What you get.** An `.spz` file (about 3 MB at the default 262,144 splats, about 13 MB at the maximum), upright (+Y) with the side the image showed facing +Z, inside a cube of side 1. The asset record's `result.model` lists `splats`, `bytes`, `height` and `view`: where the reference image was taken from, in the splat's own coordinates. Its preview is the cut-out image Idol built from.
|
|
45
|
+
- **Strengths and limits.** The side the image shows is exact; the sides it hid are invented and softer, so plan the camera: a half turn to three-quarter turn reads best, and a full turn shows the back for only a moment. Splats are not lit by scene lights or environments and cast no shadows (the image's light is baked in), so match the set's light direction to the photo's. They can't be rigged, deformed by bones or re-textured, and fine thin parts (straps, cables) may be fuzzy. Several objects in one image become one splat; generate each product from its own image.
|
|
46
|
+
- **When to use which.** Splats for real products and props the camera admires (turntables, hero reveals, photo-to-3D openings). Tripo or Hunyuan3D meshes for anything lit by the scene, cast shadows, exploded into parts, rigged or animated.
|
|
47
|
+
|
|
34
48
|
## Optimise and place the files
|
|
35
49
|
|
|
36
50
|
Download into `public/assets/models/` and load them with `publicAsset`-relative paths. Shrink large files before building the film; this needs no install:
|
|
@@ -41,6 +55,31 @@ npx --yes @gltf-transform/cli@4.5.1 optimize public/assets/models/speaker.glb pu
|
|
|
41
55
|
|
|
42
56
|
Drop `--simplify-ratio` for rigged characters and for Tripo models that are already small; simplifying a skinned mesh can tear it. A 62 MB Hunyuan3D speaker became 1.5 MB this way. `ThreeStage.load()` decodes meshopt and WebP itself. Keep HDR environments in `public/assets/` too: a 1K studio `.hdr` (for example from Poly Haven, CC0) lights products well.
|
|
43
57
|
|
|
58
|
+
## Stage splats
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { ThreeStage, THREE, normalize, photoView, softShadow } from "./kit";
|
|
62
|
+
|
|
63
|
+
const stage = new ThreeStage({ width: 1920, height: 1080, supersample: 1.5 });
|
|
64
|
+
const scene = new THREE.Scene();
|
|
65
|
+
const perfume = normalize(await stage.splat("assets/models/perfume.spz"), 0.3); // metres, on y = 0, centred
|
|
66
|
+
const holder = new THREE.Group(); holder.add(perfume, softShadow(0.2)); scene.add(holder);
|
|
67
|
+
const camera = new THREE.PerspectiveCamera(30, 1920 / 1080, 0.02, 60);
|
|
68
|
+
const view = { position: [0, -0.438, 12.049] as [number, number, number], fov: 8.07 }; // the asset's result.model.view
|
|
69
|
+
this.on((t) => {
|
|
70
|
+
holder.rotation.y = 0.35 * t;
|
|
71
|
+
if (t < 1) photoView(perfume, view, camera); // opens on exactly the product photo…
|
|
72
|
+
else { /* …then ease into an orbit, as for any model */ }
|
|
73
|
+
return stage.draw(scene, camera); // return it: splats are sorted for each frame's camera
|
|
74
|
+
});
|
|
75
|
+
await stage.warm(scene, camera);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- **Return `stage.draw()`.** Spark sorts splats for the camera in a worker, so with splats in the scene `draw()` returns a promise, and the film waits for it before the frame. An update that drops it renders splats in the order of an earlier camera. Every frame is still a pure function of `t`: cold seeks and sequential playback give identical frames.
|
|
79
|
+
- **Colour.** The first splat switches the stage from AgX to Neutral tone mapping, which keeps the photo's colours through `stage.post()`; meshes in the scene look a little crisper than with AgX. Set `stage.renderer.toneMapping` after loading to choose otherwise.
|
|
80
|
+
- **Placing.** `normalize()`, `bounds()`, `project()` and holders work as for meshes. Use `softShadow(width, depth, opacity)` under each splat, on a plinth or floor. Splats mix with meshes in one scene and respect their depth. `photoView()` needs the splat placed first.
|
|
81
|
+
- **Size.** Each splat file loads whole: keep a film to a few splats at the default count (a 1080p frame with two takes about 50 ms to render on a Mac), and count their megabytes in HTML5 exports.
|
|
82
|
+
|
|
44
83
|
## Build the shot
|
|
45
84
|
|
|
46
85
|
```ts
|
|
@@ -19,9 +19,9 @@ Follow sibling `animaguru-video` for purpose → style → compact interview bef
|
|
|
19
19
|
|
|
20
20
|
Read sibling `animaguru-3d-models` before generating anything: it covers the `model` asset type, costs, optimisation and the kit's `ThreeStage`. Read `animaguru` for credits and jobs, and `pixijs` for the Text and Graphics layers.
|
|
21
21
|
|
|
22
|
-
- Use the user's own CAD or GLB when there is one. Otherwise build from their best product photo, or from a clean reference image you draw first (plain white background, the whole product in frame, three-quarter view).
|
|
22
|
+
- Use the user's own CAD or GLB when there is one. Otherwise build from their best product photo, or from a clean reference image you draw first (plain white background, the whole product in frame, three-quarter view). For the photo's exact look (glass, liquids, labels, fabric), make photoreal Gaussian splats with `assets generate --type model --model idol --reference SHA`: they keep the photo's light, so the set's light must agree with it, and the side the photo hid is softer, so keep the turn to about three-quarters and open on the photo's own view with `photoView()`. For a mesh the scene lights, use `assets generate --type model --reference SHA` (Tripo for clean shells and 2K PBR textures, Hunyuan3D 3.1 Pro with `--faces` for fine surface detail) and optimise to about 100–200k triangles with 2K WebP textures before staging.
|
|
23
23
|
- Look at a turnaround (front, side, back) before staging. Generated texture atlases sometimes give painted surfaces half metalness, which reads as chrome: correct a material in the shader (for example a `metalnessFactor` band in `onBeforeCompile`) rather than misdescribe it in a callout.
|
|
24
|
-
- Light with an HDR studio environment (`stage.environment(...)`, a CC0 studio from Poly Haven, or `stage.room()`) plus one shadow-casting key spot and a warm rim. Rotate `scene.environmentRotation` with time so reflections slide across metal, and choose its offset so polished parts don't mirror a softbox flat white in the macro shot. A thin `RectAreaLight` crossing in front adds a moving strip highlight.
|
|
24
|
+
- Light meshes with an HDR studio environment (`stage.environment(...)`, a CC0 studio from Poly Haven, or `stage.room()`) plus one shadow-casting key spot and a warm rim. Rotate `scene.environmentRotation` with time so reflections slide across metal, and choose its offset so polished parts don't mirror a softbox flat white in the macro shot. A thin `RectAreaLight` crossing in front adds a moving strip highlight.
|
|
25
25
|
- Build a seamless set: an inside-out sphere with a vertical gradient that ignores fog, a floor that fades into it through matching fog, and a plinth (dark stone or matte resin) with a soft contact shadow under the product. Keep the background opaque when using bloom.
|
|
26
26
|
|
|
27
27
|
## Build the movement
|
|
@@ -6,7 +6,7 @@ In an untouched starter, run `npm run example -- product-turntable-3d`, then `np
|
|
|
6
6
|
|
|
7
7
|
## Sources
|
|
8
8
|
|
|
9
|
-
- **The product.** The user's CAD, GLB or scan comes first. Otherwise generate a `model` asset from their product photo, or from a clean reference image drawn for the purpose (see `animaguru-3d-models`). A generated model of a real product is an illustration of it: never invent specifications, materials, certifications or claims for a real product. Callouts name only what the user approved or what is plainly visible. For an invented product, say so on screen (an eyebrow such as "Invented product · generated with AnimaGuru") and invent a brand that is clearly fictional.
|
|
9
|
+
- **The product.** The user's CAD, GLB or scan comes first. Otherwise generate a `model` asset from their product photo, or from a clean reference image drawn for the purpose (see `animaguru-3d-models`): Idol's Gaussian splats (`--model idol`) keep the photo's exact look, a Tripo or Hunyuan3D mesh takes the scene's light. Splats are drawn with `stage.splat()` and `return stage.draw(…)`, need a `softShadow()` instead of a cast shadow, and their focus and callouts work like a mesh's. A generated model of a real product is an illustration of it: never invent specifications, materials, certifications or claims for a real product. Callouts name only what the user approved or what is plainly visible. For an invented product, say so on screen (an eyebrow such as "Invented product · generated with AnimaGuru") and invent a brand that is clearly fictional.
|
|
10
10
|
- **Lighting.** Studio HDRs from [Poly Haven](https://polyhaven.com/hdris/studio) are CC0 (`studio_small_09` lit the gallery preview). `ThreeStage.room()` gives a neutral studio with no file at all.
|
|
11
11
|
- **Records.** Keep each generation's request, job and cost in `production/`, and every asset's source and licence in `production/CREDITS.md`.
|
|
12
12
|
|
|
@@ -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
|
|
|
@@ -90,7 +90,7 @@ Call frame rendering sequentially and await it before reading pixels. When addin
|
|
|
90
90
|
|
|
91
91
|
## 3D shots
|
|
92
92
|
|
|
93
|
-
The kit's `ThreeStage` renders a three.js scene into a layer of the Pixi film: 2D art behind it, titles and captions on top, every pose and camera move a function of film time, so review, HTML5 export and MP4 export work unchanged. Generate GLB models (optionally rigged with animation clips) with `animaguru assets generate PROJECT_ID --type model`, download them into `public/assets/models/` and load them with `stage.load("assets/models/NAME.glb")`. The `animaguru-3d-models` skill has the workflow.
|
|
93
|
+
The kit's `ThreeStage` renders a three.js scene into a layer of the Pixi film: 2D art behind it, titles and captions on top, every pose and camera move a function of film time, so review, HTML5 export and MP4 export work unchanged. Generate GLB models (optionally rigged with animation clips) with `animaguru assets generate PROJECT_ID --type model`, download them into `public/assets/models/` and load them with `stage.load("assets/models/NAME.glb")`. Photoreal Gaussian splats (`--model idol`, built from one saved image) download as `.spz` and load with `stage.splat("assets/models/NAME.spz")`; Spark draws them, and an update that draws splats returns `stage.draw(…)` so each frame waits for its own sort. The `animaguru-3d-models` skill has the workflow.
|
|
94
94
|
|
|
95
95
|
## Branching stories
|
|
96
96
|
|
|
@@ -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;
|
|
@@ -57,10 +57,17 @@ export async function withPage(url, use, { viewport = { width: 1280, height: 900
|
|
|
57
57
|
} finally { await browser.close(); }
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** A film module or the JSON it imports that did not load: the player cannot start, so waiting longer cannot help. */
|
|
61
|
+
const MODULE_FAILURE = /^HTTP [45]\d\d \S+(?:\.(?:[cm]?[jt]sx?|json)|[?&]import\b)/;
|
|
62
|
+
|
|
60
63
|
/** Waits for the player's window.animaguru API (after fonts, textures and the first render), or fails with the page's errors. */
|
|
61
64
|
export async function waitForPlayer(page, problems = [], timeout = 120_000) {
|
|
62
|
-
|
|
63
|
-
const
|
|
65
|
+
let poll;
|
|
66
|
+
const broken = new Promise((resolve) => { poll = setInterval(() => { if (problems.some((problem) => MODULE_FAILURE.test(problem))) resolve("broken"); }, 250); });
|
|
67
|
+
const started = page.waitForFunction(() => window.animaguru?.ready ? "ready" : document.getElementById("status")?.textContent || null, undefined, { timeout, polling: 250 })
|
|
68
|
+
.then((handle) => handle.jsonValue()).catch(() => null);
|
|
69
|
+
const state = await Promise.race([started, broken]).finally(() => clearInterval(poll));
|
|
70
|
+
if (state === "broken") throw new Error(`The player did not start: a film module did not load.${report(problems)}`);
|
|
64
71
|
if (state !== "ready") throw new Error(`The player did not start${state ? `: ${state}` : ` within ${timeout / 1000} s`}.${report(problems)}`);
|
|
65
72
|
}
|
|
66
73
|
|
|
@@ -71,6 +78,9 @@ export async function captureHeadless(url, { endpoint, timeout = 30 * 60 * 1000
|
|
|
71
78
|
const page = await browser.newPage({ deviceScaleFactor: 1 });
|
|
72
79
|
const problems = watch(page);
|
|
73
80
|
const finished = page.waitForResponse((response) => new URL(response.url()).pathname === endpoint, { timeout });
|
|
81
|
+
// Closing the browser rejects this wait; unhandled, that ended the process before the real failure, such as a
|
|
82
|
+
// module that answered 404, could be reported.
|
|
83
|
+
finished.catch(() => undefined);
|
|
74
84
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
|
75
85
|
await waitForPlayer(page, problems);
|
|
76
86
|
let stop;
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { createServer } from "vite";
|
|
@@ -18,6 +18,9 @@ if(selection.rest.length)throw new Error("Usage: npm run review -- [--animation
|
|
|
18
18
|
const context=await projectContext(root,selection.animation),config=context.config;
|
|
19
19
|
const token=randomUUID(),port=Number(process.env.ANIMAGURU_REVIEW_PORT??5197);
|
|
20
20
|
const output=path.join(context.projectRoot,"production/review");
|
|
21
|
+
// The last review's frames, report and frame check go first: a review that fails leaves none, so animaguru review check
|
|
22
|
+
// cannot score frames of an earlier version of the film. One failed review's check scored the frames before it.
|
|
23
|
+
for(const file of await readdir(output).catch(()=>[]))if(/^(?:frame-\d{2}\.jpg|report\.json|check\.json)$/.test(file))await rm(path.join(output,file));
|
|
21
24
|
const server=await createServer({configFile:false,root,base:"./",publicDir:context.workspace?false:undefined,clearScreen:false,server:{host:"127.0.0.1",port,strictPort:true},plugins:[...(context.workspace?[workspacePlugin(root,{animation:context.name})]:[]),{name:"animaguru-review",configureServer(vite){
|
|
22
25
|
vite.middlewares.use((request,response,next)=>{
|
|
23
26
|
if(request.url!=="/__review/report")return next();
|
|
@@ -45,8 +45,11 @@ void mountPlayer({config,Film,chapters,${context.story ? "story," : ""}${context
|
|
|
45
45
|
})().catch((error) => response.writeHead(500).end(error.message)); return;
|
|
46
46
|
}
|
|
47
47
|
if (!match) return next();
|
|
48
|
-
// Film modules and
|
|
49
|
-
|
|
48
|
+
// Film modules and everything they import go to Vite; everything else is public media. Vite marks an import of a
|
|
49
|
+
// file that is not a script or stylesheet with ?import, so a film can import any JSON it writes, such as the
|
|
50
|
+
// voiceover cue map, an SFX cue list or a media manifest under production/. A film that imported
|
|
51
|
+
// production/media-manifest.json and production/audio/sfx-cues.json got 404s while only two files there were listed.
|
|
52
|
+
if (url.searchParams.has("import") || /^(?:src|shared)\//.test(match[2]) || /^production\/.+\.json$/.test(match[2]) || ["film.config.json", "animaguru.project.json", STORY_FILE, QUIZ_FILE].includes(match[2])) return next();
|
|
50
53
|
void (async () => {
|
|
51
54
|
const context = await projectContext(root, match[1]);
|
|
52
55
|
if (!match[2] || match[2] === "index.html") {
|
|
@@ -5,7 +5,8 @@ import { CaptionBar, type CaptionStyle } from "./components";
|
|
|
5
5
|
import { safeArea } from "./layout";
|
|
6
6
|
import type { Caption } from "./timing";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/** A per-frame update. One that returns a promise (a 3D draw with Gaussian splats) is awaited before the next update and the frame. */
|
|
9
|
+
export type Update = (t: number) => void | Promise<void>;
|
|
9
10
|
export type Cue = { id: string; time: number; label: string };
|
|
10
11
|
|
|
11
12
|
/** Visible, opaque text with its bounds in film pixels, rendered font size and role; npm run review and npm run frames lint these. */
|
|
@@ -101,7 +102,10 @@ export abstract class FilmBase {
|
|
|
101
102
|
async render(seconds: number) {
|
|
102
103
|
await this.ready;
|
|
103
104
|
const t = Math.min(Math.max(seconds, 0), this.options.config.duration - 0.00001);
|
|
104
|
-
for (const update of this.updates)
|
|
105
|
+
for (const update of this.updates) {
|
|
106
|
+
const pending = update(t);
|
|
107
|
+
if (pending) await pending;
|
|
108
|
+
}
|
|
105
109
|
this.captionBar?.update(t, this.captions);
|
|
106
110
|
this.app.render();
|
|
107
111
|
}
|
|
@@ -10,6 +10,7 @@ import { BokehPass } from "three/addons/postprocessing/BokehPass.js";
|
|
|
10
10
|
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
|
|
11
11
|
import type { Pass } from "three/addons/postprocessing/Pass.js";
|
|
12
12
|
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
|
|
13
|
+
import type { SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
|
|
13
14
|
import { publicAsset } from "../assets";
|
|
14
15
|
import { easeInOut } from "./motion";
|
|
15
16
|
|
|
@@ -19,9 +20,21 @@ import { easeInOut } from "./motion";
|
|
|
19
20
|
and leader lines on top. Everything stays a function of film time: pose rigs with Rig.pose(), move
|
|
20
21
|
cameras with vecAt(), never use THREE.Clock, mixer.update(delta) or Math.random() in a frame, and draw
|
|
21
22
|
last, from the film.on() update registered after every 3D update. See the animaguru-3d-models skill.
|
|
23
|
+
|
|
24
|
+
Gaussian splats (stage.splat()) render through Spark, loaded only by films that use them. Spark sorts
|
|
25
|
+
splats by distance from the camera in a worker, so a frame with splats waits for its own sort: draw()
|
|
26
|
+
returns a promise then, and the update that calls it must return it.
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
29
|
export { THREE };
|
|
30
|
+
export type { SplatMesh };
|
|
31
|
+
|
|
32
|
+
type Spark = typeof import("@sparkjsdev/spark");
|
|
33
|
+
let spark: Promise<Spark> | undefined;
|
|
34
|
+
const loadSpark = () => (spark ??= import("@sparkjsdev/spark"));
|
|
35
|
+
|
|
36
|
+
/** Where an AnimaGuru splat's reference image was taken from (the asset's result.model.view): a camera at `position` looking at the splat's origin. */
|
|
37
|
+
export type SplatView = { position: [number, number, number]; fov: number };
|
|
25
38
|
|
|
26
39
|
export type StageOptions = {
|
|
27
40
|
/** The design size the sprite covers, in film coordinates. */
|
|
@@ -42,6 +55,7 @@ export class ThreeStage {
|
|
|
42
55
|
private readonly scale: number;
|
|
43
56
|
private readonly composers = new Map<THREE.Scene, EffectComposer>();
|
|
44
57
|
private readonly gltf = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
|
|
58
|
+
private spark?: { lib: Spark; renderer: SparkRenderer };
|
|
45
59
|
|
|
46
60
|
constructor({ width, height, supersample = 1.5 }: StageOptions) {
|
|
47
61
|
this.width = width;
|
|
@@ -68,6 +82,27 @@ export class ThreeStage {
|
|
|
68
82
|
return gltf;
|
|
69
83
|
}
|
|
70
84
|
|
|
85
|
+
/**
|
|
86
|
+
Loads a Gaussian splat from public/: an AnimaGuru splat (.spz), or any .ply, .splat, .ksplat or .spz. AnimaGuru
|
|
87
|
+
splats stand upright and face +Z like saved models, inside a cube of side 1; place them with normalize().
|
|
88
|
+
Splats carry the light of the image they were built from: scene lights, shadows and environments don't
|
|
89
|
+
touch them, and they cast no shadows (use softShadow()). The first splat switches the stage from AgX to
|
|
90
|
+
Neutral tone mapping, which keeps the image's colours through post(), unless the film chose another.
|
|
91
|
+
Splats from other tools may need `splat.quaternion.set(1, 0, 0, 0)` to stand up.
|
|
92
|
+
*/
|
|
93
|
+
async splat(path: string): Promise<SplatMesh> {
|
|
94
|
+
const lib = await loadSpark();
|
|
95
|
+
// Sorting runs only when draw() asks for it, so a frame never shows the order of an earlier camera.
|
|
96
|
+
if (!this.spark) {
|
|
97
|
+
this.spark = { lib, renderer: new lib.SparkRenderer({ renderer: this.renderer, autoUpdate: false }) };
|
|
98
|
+
// post()'s tone mapping reaches splats too, and AgX greys a photo's colours; Khronos PBR Neutral keeps them.
|
|
99
|
+
if (this.renderer.toneMapping === THREE.AgXToneMapping) this.renderer.toneMapping = THREE.NeutralToneMapping;
|
|
100
|
+
}
|
|
101
|
+
const mesh = new lib.SplatMesh({ url: publicAsset(path) });
|
|
102
|
+
await mesh.initialized;
|
|
103
|
+
return mesh;
|
|
104
|
+
}
|
|
105
|
+
|
|
71
106
|
/** An equirectangular .hdr from public/ as image-based lighting; `hdr` can also be a scene's background. */
|
|
72
107
|
async environment(path: string) {
|
|
73
108
|
const hdr = await new HDRLoader().loadAsync(publicAsset(path));
|
|
@@ -105,11 +140,28 @@ export class ThreeStage {
|
|
|
105
140
|
/** Compiles the scene's materials and draws it once, so the first exported frame never waits on shaders. Await it in build(). */
|
|
106
141
|
async warm(scene: THREE.Scene, camera: THREE.Camera) {
|
|
107
142
|
await this.renderer.compileAsync(scene, camera);
|
|
108
|
-
this.draw(scene, camera);
|
|
143
|
+
await this.draw(scene, camera);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
Draws the scene and hands the canvas to Pixi for this frame. With splats in the scene it returns a promise
|
|
148
|
+
that settles once they are sorted for this camera and drawn: return it from the update (`return stage.draw(…)`).
|
|
149
|
+
*/
|
|
150
|
+
draw(scene: THREE.Scene, camera: THREE.Camera): void | Promise<void> {
|
|
151
|
+
const spark = this.spark;
|
|
152
|
+
if (spark && hasSplats(spark.lib, scene)) return this.drawSplats(spark.renderer, scene, camera);
|
|
153
|
+
this.present(scene, camera);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async drawSplats(renderer: SparkRenderer, scene: THREE.Scene, camera: THREE.Camera) {
|
|
157
|
+
if (renderer.parent !== scene) scene.add(renderer);
|
|
158
|
+
scene.updateMatrixWorld();
|
|
159
|
+
camera.updateMatrixWorld();
|
|
160
|
+
await renderer.update({ scene, camera });
|
|
161
|
+
this.present(scene, camera);
|
|
109
162
|
}
|
|
110
163
|
|
|
111
|
-
|
|
112
|
-
draw(scene: THREE.Scene, camera: THREE.Camera) {
|
|
164
|
+
private present(scene: THREE.Scene, camera: THREE.Camera) {
|
|
113
165
|
const composer = this.composers.get(scene);
|
|
114
166
|
if (composer) composer.render();
|
|
115
167
|
else this.renderer.render(scene, camera);
|
|
@@ -126,18 +178,35 @@ export class ThreeStage {
|
|
|
126
178
|
|
|
127
179
|
dispose() {
|
|
128
180
|
for (const composer of this.composers.values()) composer.dispose();
|
|
181
|
+
this.spark?.renderer.dispose();
|
|
129
182
|
this.renderer.dispose();
|
|
130
183
|
this.sprite.destroy({ texture: true, textureSource: true });
|
|
131
184
|
}
|
|
132
185
|
}
|
|
133
186
|
|
|
134
|
-
|
|
135
|
-
|
|
187
|
+
const hasSplats = (lib: Spark, root: THREE.Object3D) => {
|
|
188
|
+
let found = false;
|
|
189
|
+
root.traverseVisible((node) => { if (node instanceof lib.SplatMesh) found = true; });
|
|
190
|
+
return found;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** World bounds of meshes and splats (by their centres). */
|
|
194
|
+
export function bounds(object: THREE.Object3D) {
|
|
136
195
|
object.updateMatrixWorld(true);
|
|
137
196
|
const box = new THREE.Box3().setFromObject(object);
|
|
197
|
+
const part = new THREE.Box3();
|
|
198
|
+
object.traverse((node) => {
|
|
199
|
+
const splat = node as Partial<SplatMesh>;
|
|
200
|
+
if (typeof splat.getBoundingBox === "function" && splat.isInitialized) box.union(part.copy(splat.getBoundingBox()).applyMatrix4(node.matrixWorld));
|
|
201
|
+
});
|
|
202
|
+
return box;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Scales a model or splat to `height` scene units (metres), stands it on y = 0 and centres it on the origin. Saved AnimaGuru models and splats face +Z. */
|
|
206
|
+
export function normalize<T extends THREE.Object3D>(object: T, height: number): T {
|
|
207
|
+
let box = bounds(object);
|
|
138
208
|
object.scale.multiplyScalar(height / box.getSize(new THREE.Vector3()).y);
|
|
139
|
-
object
|
|
140
|
-
box.setFromObject(object);
|
|
209
|
+
box = bounds(object);
|
|
141
210
|
const center = box.getCenter(new THREE.Vector3());
|
|
142
211
|
object.position.x -= center.x;
|
|
143
212
|
object.position.z -= center.z;
|
|
@@ -145,6 +214,42 @@ export function normalize<T extends THREE.Object3D>(object: T, height: number):
|
|
|
145
214
|
return object;
|
|
146
215
|
}
|
|
147
216
|
|
|
217
|
+
/**
|
|
218
|
+
Puts the camera where an AnimaGuru splat's reference image was taken from (the asset's result.model.view), after
|
|
219
|
+
the splat is placed: a shot that opens on exactly the product photo, then moves into 3D. Returns the point it looks at.
|
|
220
|
+
*/
|
|
221
|
+
export function photoView(splat: THREE.Object3D, view: SplatView, camera: THREE.PerspectiveCamera) {
|
|
222
|
+
splat.updateMatrixWorld(true);
|
|
223
|
+
camera.position.copy(splat.localToWorld(new THREE.Vector3(...view.position)));
|
|
224
|
+
const target = splat.localToWorld(new THREE.Vector3());
|
|
225
|
+
camera.fov = view.fov;
|
|
226
|
+
camera.updateProjectionMatrix();
|
|
227
|
+
camera.lookAt(target);
|
|
228
|
+
return target;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
A soft contact shadow on y = 0 under a splat (which casts none) or a model: a radial fade, `width` by `depth`
|
|
233
|
+
scene units. Add it to the same holder as the subject.
|
|
234
|
+
*/
|
|
235
|
+
export function softShadow(width: number, depth = width, opacity = 0.45) {
|
|
236
|
+
const canvas = document.createElement("canvas");
|
|
237
|
+
canvas.width = canvas.height = 128;
|
|
238
|
+
const context = canvas.getContext("2d")!;
|
|
239
|
+
const fade = context.createRadialGradient(64, 64, 0, 64, 64, 64);
|
|
240
|
+
fade.addColorStop(0, "rgba(0,0,0,1)");
|
|
241
|
+
fade.addColorStop(0.45, "rgba(0,0,0,0.55)");
|
|
242
|
+
fade.addColorStop(1, "rgba(0,0,0,0)");
|
|
243
|
+
context.fillStyle = fade;
|
|
244
|
+
context.fillRect(0, 0, 128, 128);
|
|
245
|
+
const material = new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(canvas), transparent: true, opacity, depthWrite: false, toneMapped: false });
|
|
246
|
+
const shadow = new THREE.Mesh(new THREE.PlaneGeometry(width, depth), material);
|
|
247
|
+
shadow.rotation.x = -Math.PI / 2;
|
|
248
|
+
shadow.position.y = 0.001;
|
|
249
|
+
shadow.renderOrder = -1;
|
|
250
|
+
return shadow;
|
|
251
|
+
}
|
|
252
|
+
|
|
148
253
|
export type ClipLayer = { clip: string; time: number; weight?: number; loop?: boolean };
|
|
149
254
|
|
|
150
255
|
/**
|
|
@@ -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). */
|
|
@@ -2,9 +2,9 @@ import type { Container } from "pixi.js";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* A 3D study builds its stage and Pixi layers into `root` (1920×1080 design space) and returns the frame update,
|
|
5
|
-
* a pure function of seconds that ends with stage.draw(). `lines` are its localized on-screen texts.
|
|
5
|
+
* a pure function of seconds that ends with stage.draw() (returning it when the scene has splats). `lines` are its localized on-screen texts.
|
|
6
6
|
*/
|
|
7
|
-
export type ThreeStudy = (root: Container, lines: readonly string[]) => Promise<(seconds: number) => void
|
|
7
|
+
export type ThreeStudy = (root: Container, lines: readonly string[]) => Promise<(seconds: number) => void | Promise<void>>;
|
|
8
8
|
|
|
9
9
|
/** Loaded on demand, so a film only bundles the study it installs. */
|
|
10
10
|
export const THREE_STUDIES = {
|
|
@@ -18,7 +18,7 @@ export class ThreeStudyFilm {
|
|
|
18
18
|
captions = true;
|
|
19
19
|
readonly resources: ResourceTrack[];
|
|
20
20
|
private readonly root = new Container();
|
|
21
|
-
private readonly ready: Promise<(seconds: number) => void
|
|
21
|
+
private readonly ready: Promise<(seconds: number) => void | Promise<void>>;
|
|
22
22
|
|
|
23
23
|
constructor(private readonly app: Application, id: ThreeStudyId) {
|
|
24
24
|
app.stage.addChild(this.root);
|
|
@@ -36,7 +36,7 @@ export class ThreeStudyFilm {
|
|
|
36
36
|
|
|
37
37
|
async render(seconds: number) {
|
|
38
38
|
const update = await this.ready;
|
|
39
|
-
update(Math.min(Math.max(seconds, 0), STUDY_DURATION - 1e-5));
|
|
39
|
+
await update(Math.min(Math.max(seconds, 0), STUDY_DURATION - 1e-5));
|
|
40
40
|
this.app.render();
|
|
41
41
|
}
|
|
42
42
|
}
|