@docyrus/animaguru 0.34.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 +2 -0
- package/dist/animaguru.js +44 -12
- package/package.json +1 -1
- package/skills/animaguru/SKILL.md +1 -1
- 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/templates/video/README.md +1 -1
- package/templates/video/package.json +1 -0
- package/templates/video/scripts/headless-browser.mjs +12 -2
- 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/studies/three/index.ts +2 -2
- package/templates/video/src/studies/three-film.ts +2 -2
package/README.md
CHANGED
|
@@ -61,6 +61,8 @@ CLI 0.33.0 adds **Cartoon Host Explainer** (`cartoon-host-explainer`): the talki
|
|
|
61
61
|
|
|
62
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
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
|
+
|
|
64
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.
|
|
65
67
|
|
|
66
68
|
CLI 0.5.2 includes a complete local PixiJS video workspace, now with 60 visual styles:
|
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.`);
|
|
@@ -48627,7 +48655,7 @@ var extras = {
|
|
|
48627
48655
|
music: [],
|
|
48628
48656
|
speech: ["voice", "delivery"],
|
|
48629
48657
|
sfx: ["seconds", "loop", "prompt-influence"],
|
|
48630
|
-
model: ["reference", "texture", "pbr", "faces", "rig", "animations", "height"],
|
|
48658
|
+
model: ["reference", "texture", "pbr", "faces", "rig", "animations", "height", "splats", "photo", "seed"],
|
|
48631
48659
|
vector: ["workflow", "references", "source", "aspect-ratio", "quality", "shapes", "seed"]
|
|
48632
48660
|
};
|
|
48633
48661
|
var list2 = (items) => items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
|
|
@@ -48667,7 +48695,7 @@ function assetFromFlags(flags) {
|
|
|
48667
48695
|
const given2 = value !== void 0 && (value !== false || (name2 === "texture" || name2 === "pbr") && flags.type !== "model");
|
|
48668
48696
|
if (given2 && !extras[flags.type]?.includes(name2)) throw new Error(`--${name2} is not an option for ${flags.type} assets. ${accepted(flags.type)}`.trim());
|
|
48669
48697
|
}
|
|
48670
|
-
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" : ""}.`);
|
|
48671
48699
|
const base2 = { type: flags.type, name: flags.name, prompt: flags.prompt, model: flags.model };
|
|
48672
48700
|
if (flags.type === "image") return parseAsset({ ...base2, aspectRatio: flags["aspect-ratio"], transparent: flags.transparent, references: shas(flags.references), mask: regions(flags.mask) });
|
|
48673
48701
|
if (flags.type === "video") {
|
|
@@ -48705,7 +48733,7 @@ function assetFromFlags(flags) {
|
|
|
48705
48733
|
});
|
|
48706
48734
|
}
|
|
48707
48735
|
if (flags.type === "sfx") return parseAsset({ ...base2, seconds: flags.seconds, loop: flags.loop, promptInfluence: flags["prompt-influence"] });
|
|
48708
|
-
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 });
|
|
48709
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 });
|
|
48710
48738
|
return parseAsset({ ...base2, ...flags.type === "speech" ? { voice: flags.voice, delivery: flags.delivery } : {} });
|
|
48711
48739
|
}
|
|
@@ -48805,7 +48833,8 @@ var MIME_EXTENSIONS = {
|
|
|
48805
48833
|
"image/svg+xml": "svg",
|
|
48806
48834
|
"video/mp4": "mp4",
|
|
48807
48835
|
"video/webm": "webm",
|
|
48808
|
-
"model/gltf-binary": "glb"
|
|
48836
|
+
"model/gltf-binary": "glb",
|
|
48837
|
+
"model/x-spz": "spz"
|
|
48809
48838
|
};
|
|
48810
48839
|
var extensionFor = (asset) => MIME_EXTENSIONS[asset.mimeType?.split(";")[0].trim().toLowerCase()] ?? /\.([a-z0-9]{1,5})$/i.exec(asset.file ?? "")?.[1]?.toLowerCase() ?? "bin";
|
|
48811
48840
|
async function downloadTarget(output2, asset, folder = false) {
|
|
@@ -49994,6 +50023,7 @@ async function installFilmTemplate(client, id2, options) {
|
|
|
49994
50023
|
await mkdir12(join15(staging, "public/assets"), { recursive: true });
|
|
49995
50024
|
await mkdir12(join15(staging, "production"), { recursive: true });
|
|
49996
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 });
|
|
49997
50027
|
await rename6(staging, directory);
|
|
49998
50028
|
} finally {
|
|
49999
50029
|
await rm3(staging, { recursive: true, force: true });
|
|
@@ -50515,7 +50545,7 @@ async function reviewCheck(client, options) {
|
|
|
50515
50545
|
const context = await localProject(options.directory, options.animation);
|
|
50516
50546
|
const reportFile = join19(context.projectRoot, "production/review/report.json");
|
|
50517
50547
|
const report2 = JSON.parse(await readFile19(reportFile, "utf8").catch(() => {
|
|
50518
|
-
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.");
|
|
50519
50549
|
}));
|
|
50520
50550
|
const frames = await framesToCheck(context.projectRoot, options.source ?? "review", report2);
|
|
50521
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.`);
|
|
@@ -50819,7 +50849,7 @@ var catalogSections = {
|
|
|
50819
50849
|
model: ["assetTypes", "modelGenerators", "modelAnimations"],
|
|
50820
50850
|
avatars: ["avatarTypes", "avatarLookTypes"]
|
|
50821
50851
|
};
|
|
50822
|
-
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 });
|
|
50823
50853
|
var modelFlags = [
|
|
50824
50854
|
{ name: "video-model", type: "string", choices: SPRITE_VIDEO_MODEL_IDS, description: "Video model override for this operation" }
|
|
50825
50855
|
];
|
|
@@ -51171,7 +51201,7 @@ Confirm code: ${device.user_code}`);
|
|
|
51171
51201
|
{ name: "resolution", type: "string", choices: VIDEO_RESOLUTIONS, description: "Video resolution supported by the selected model and workflow (default its first)" },
|
|
51172
51202
|
{ name: "mode", type: "string", choices: ["cost", "speed", "quality"], description: "Video generation mode supported by the selected model (default its first)" },
|
|
51173
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" },
|
|
51174
|
-
{ 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" },
|
|
51175
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)" },
|
|
51176
51206
|
{ name: "voice", type: "string", description: "Gemini voice name or ElevenLabs voice ID (defaults: Kore / George)" },
|
|
51177
51207
|
{ name: "delivery", type: "string", description: "Gemini speech delivery instructions; ElevenLabs uses audio tags in the transcript" },
|
|
@@ -51182,6 +51212,8 @@ Confirm code: ${device.user_code}`);
|
|
|
51182
51212
|
{ name: "rig", type: "boolean", description: "Model: auto-rig a humanoid character standing in a T- or A-pose, with animation clips" },
|
|
51183
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" },
|
|
51184
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" },
|
|
51185
51217
|
{ name: "prompt-influence", type: "number", description: "SFX: prompt adherence, 0\u20131 (default 0.3)" },
|
|
51186
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)" },
|
|
51187
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
|
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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,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
|
/**
|
|
@@ -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
|
}
|