@docyrus/animaguru 0.34.0 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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. CLI 0.35.1 fixes `photoView()` for product images Idol reads as near-orthographic (a field of view under 0.1°): it widens the view to 6° and moves the camera in, so the shot still opens on the product.
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.34.0",
19764
+ version: "0.35.1",
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("Run npm run review first: production/review/report.json is missing.");
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: id2, label: label3, faces }) => ({ id: id2, label: label3, 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 });
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 or vector from text: seed for repeatable results, where the model takes one" },
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docyrus/animaguru",
3
- "version": "0.34.0",
3
+ "version": "0.35.1",
4
4
  "description": "Agent-friendly AnimaGuru CLI built with CrustJS",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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) with AnimaGuru, optionally auto-rigged with animation clips, 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), or when the user asks for 3D models, GLB files, rigging or 3D animation.
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). Then `assets generate --type model --reference SHA` (Tripo for clean shells and 2K PBR textures, Hunyuan3D 3.1 Pro with `--faces` for fine surface detail). Optimise to about 100–200k triangles with 2K WebP textures before staging.
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
 
@@ -21,6 +21,7 @@
21
21
  "inspect:site": "node scripts/site-inspect.mjs"
22
22
  },
23
23
  "dependencies": {
24
+ "@sparkjsdev/spark": "2.4.0",
24
25
  "pixi.js": "8.22.0",
25
26
  "three": "0.186.1"
26
27
  },
@@ -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
- const handle = await page.waitForFunction(() => window.animaguru?.ready ? "ready" : document.getElementById("status")?.textContent || null, undefined, { timeout, polling: 250 }).catch(() => null);
63
- const state = await handle?.jsonValue();
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 the JSON they import (including the voiceover cue map) go to Vite; everything else is public media.
49
- if (/^(?:src|shared)\//.test(match[2]) || ["film.config.json", "animaguru.project.json", "production/audio/timing.json", STORY_FILE, QUIZ_FILE].includes(match[2])) return next();
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
- export type Update = (t: number) => void;
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) update(t);
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
- /** Draws the scene and hands the canvas to Pixi for this frame. */
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
- /** Scales a model to `height` scene units (metres), stands it on y = 0 and centres it on the origin. Saved AnimaGuru models face +Z. */
135
- export function normalize<T extends THREE.Object3D>(object: T, height: number): T {
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.updateMatrixWorld(true);
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,50 @@ export function normalize<T extends THREE.Object3D>(object: T, height: number):
145
214
  return object;
146
215
  }
147
216
 
217
+ /** The narrowest field of view photoView() uses; Idol reports near-orthographic cameras (under 0.1°, ~1,000 units away) for some product shots. */
218
+ const MIN_PHOTO_FOV = 6;
219
+
220
+ /**
221
+ Puts the camera where an AnimaGuru splat's reference image was taken from (the asset's result.model.view), after
222
+ the splat is placed: a shot that opens on exactly the product photo, then moves into 3D. A field of view under 6°
223
+ is widened to 6°, with the camera moved in along the same line so the product keeps its size in frame. Returns the
224
+ point it looks at.
225
+ */
226
+ export function photoView(splat: THREE.Object3D, view: SplatView, camera: THREE.PerspectiveCamera) {
227
+ splat.updateMatrixWorld(true);
228
+ const fov = Math.max(view.fov, MIN_PHOTO_FOV);
229
+ const toward = new THREE.Vector3(...view.position);
230
+ toward.multiplyScalar(Math.tan((view.fov * Math.PI) / 360) / Math.tan((fov * Math.PI) / 360));
231
+ camera.position.copy(splat.localToWorld(toward));
232
+ const target = splat.localToWorld(new THREE.Vector3());
233
+ camera.fov = fov;
234
+ camera.updateProjectionMatrix();
235
+ camera.lookAt(target);
236
+ return target;
237
+ }
238
+
239
+ /**
240
+ A soft contact shadow on y = 0 under a splat (which casts none) or a model: a radial fade, `width` by `depth`
241
+ scene units. Add it to the same holder as the subject.
242
+ */
243
+ export function softShadow(width: number, depth = width, opacity = 0.45) {
244
+ const canvas = document.createElement("canvas");
245
+ canvas.width = canvas.height = 128;
246
+ const context = canvas.getContext("2d")!;
247
+ const fade = context.createRadialGradient(64, 64, 0, 64, 64, 64);
248
+ fade.addColorStop(0, "rgba(0,0,0,1)");
249
+ fade.addColorStop(0.45, "rgba(0,0,0,0.55)");
250
+ fade.addColorStop(1, "rgba(0,0,0,0)");
251
+ context.fillStyle = fade;
252
+ context.fillRect(0, 0, 128, 128);
253
+ const material = new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(canvas), transparent: true, opacity, depthWrite: false, toneMapped: false });
254
+ const shadow = new THREE.Mesh(new THREE.PlaneGeometry(width, depth), material);
255
+ shadow.rotation.x = -Math.PI / 2;
256
+ shadow.position.y = 0.001;
257
+ shadow.renderOrder = -1;
258
+ return shadow;
259
+ }
260
+
148
261
  export type ClipLayer = { clip: string; time: number; weight?: number; loop?: boolean };
149
262
 
150
263
  /**
@@ -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
  }