create-aura3d 1.1.2 → 1.3.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.
Files changed (215) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -4
  4. package/dist/index.js.map +1 -1
  5. package/package.json +10 -9
  6. package/templates/{cartoon-channel → animation-channel}/README.md +11 -11
  7. package/templates/{cartoon-studio → animation-channel}/index.html +1 -1
  8. package/templates/{cartoon-channel → animation-channel}/package.json +2 -2
  9. package/templates/{cartoon-channel → animation-channel}/src/episode.ts +22 -22
  10. package/templates/{cartoon-channel → animation-channel}/src/experimental/README.md +5 -5
  11. package/templates/{cartoon-channel → animation-channel}/src/experimental/concept-episode-2-5d.css +3 -3
  12. package/templates/{cartoon-channel → animation-channel}/src/experimental/concept-episode-2-5d.ts +7 -7
  13. package/templates/{cartoon-channel → animation-channel}/src/experimental/image-puppet-episode.css +4 -4
  14. package/templates/{cartoon-channel → animation-channel}/src/experimental/image-puppet-episode.ts +6 -6
  15. package/templates/{cartoon-channel → animation-channel}/src/experimental/puppet-episode-2d.css +3 -3
  16. package/templates/{cartoon-channel → animation-channel}/src/experimental/puppet-episode-2d.ts +7 -7
  17. package/templates/{cartoon-channel → animation-channel}/src/main.ts +32 -32
  18. package/templates/{cartoon-channel → animation-channel}/src/render-plan.ts +13 -13
  19. package/templates/{cartoon-channel → animation-channel}/src/sample-episode-visual.css +34 -34
  20. package/templates/{cartoon-channel → animation-channel}/src/sample-episode-visual.ts +20 -20
  21. package/templates/{cartoon-channel → animation-channel}/tests/concept-2-5d-animation.spec.ts +3 -3
  22. package/templates/{cartoon-channel → animation-channel}/tests/concept-2-5d.spec.ts +6 -6
  23. package/templates/{cartoon-channel → animation-channel}/tests/image-puppet-animation.spec.ts +3 -3
  24. package/templates/{cartoon-channel → animation-channel}/tests/image-puppet.spec.ts +5 -5
  25. package/templates/{cartoon-channel → animation-channel}/tests/puppet-2d-animation.spec.ts +3 -3
  26. package/templates/{cartoon-channel → animation-channel}/tests/puppet-2d.spec.ts +4 -4
  27. package/templates/{prompt-cartoon-channel → animation-channel}/tests/route-health.spec.ts +1 -1
  28. package/templates/{cartoon-channel → animation-channel}/tests/sample-episode-visual.spec.ts +5 -5
  29. package/templates/{cartoon-channel → animation-channel}/tests/storyboard-playback.spec.ts +7 -7
  30. package/templates/animation-studio/AGENTS.md +71 -0
  31. package/templates/{cartoon-studio → animation-studio}/ASSET-LICENSES.md +7 -7
  32. package/templates/{cartoon-studio → animation-studio}/README.md +6 -6
  33. package/templates/animation-studio/aura.assets.json +669 -0
  34. package/templates/animation-studio/dist/episodes/scene/episode-3d.webm +0 -0
  35. package/templates/animation-studio/dist/episodes/scene/frames/action.png +0 -0
  36. package/templates/animation-studio/dist/episodes/scene/frames/dialogue.png +0 -0
  37. package/templates/animation-studio/dist/episodes/scene/frames/final.png +0 -0
  38. package/templates/animation-studio/dist/episodes/scene/frames/first.png +0 -0
  39. package/templates/animation-studio/dist/episodes/scene/frames/mouth-closed.png +0 -0
  40. package/templates/animation-studio/dist/episodes/scene/frames/mouth-open.png +0 -0
  41. package/templates/animation-studio/dist/episodes/scene/render-live-summary.json +10284 -0
  42. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-1.png +0 -0
  43. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-2.png +0 -0
  44. package/templates/animation-studio/dist/generated/second-scene.document.json +1210 -0
  45. package/templates/animation-studio/dist/generated/studio-edited.document.json +1196 -0
  46. package/templates/animation-studio/dist/scene/inshader-accept.document.json +1 -0
  47. package/templates/animation-studio/dist/scene/inshader-test.document.json +1 -0
  48. package/templates/animation-studio/dist/scene/integ.document.json +1 -0
  49. package/templates/animation-studio/dist/scene/working.document.json +723 -0
  50. package/templates/animation-studio/dist/scene/working.history.json +1 -0
  51. package/templates/{cartoon-studio → animation-studio}/live-route.html +1 -1
  52. package/templates/{cartoon-studio → animation-studio}/package-lock.json +2 -2
  53. package/templates/animation-studio/package.json +32 -0
  54. package/templates/animation-studio/public/aura-assets/cast-a.catalog.glb +0 -0
  55. package/templates/animation-studio/public/aura-assets/cast-b.catalog.glb +0 -0
  56. package/templates/animation-studio/public/aura-assets/luma.catalog.glb +0 -0
  57. package/templates/animation-studio/public/aura-assets/luma2.catalog.glb +0 -0
  58. package/templates/animation-studio/public/aura-assets/miko.catalog.glb +0 -0
  59. package/templates/{cartoon-studio → animation-studio}/public/aura-assets/moonGarden.gltf +1 -1
  60. package/templates/animation-studio/public/aura-assets/mushroom.catalog.glb +0 -0
  61. package/templates/animation-studio/public/aura-assets/rusty.catalog.glb +0 -0
  62. package/templates/animation-studio/public/cast-library/cast-library.json +71 -0
  63. package/templates/animation-studio/public/clip-library/gesture.json +173903 -0
  64. package/templates/animation-studio/public/clip-library/idle.json +23035 -0
  65. package/templates/animation-studio/public/clip-library/manifest.json +154 -0
  66. package/templates/animation-studio/public/clip-library/nod.json +173903 -0
  67. package/templates/animation-studio/public/clip-library/point.json +69879 -0
  68. package/templates/animation-studio/public/clip-library/react.json +66084 -0
  69. package/templates/animation-studio/public/clip-library/run.json +9648 -0
  70. package/templates/animation-studio/public/clip-library/talk.json +69879 -0
  71. package/templates/animation-studio/public/clip-library/walk.json +8283 -0
  72. package/templates/animation-studio/public/hifi-cast/BrainStem.glb +0 -0
  73. package/templates/animation-studio/public/hifi-cast/CesiumMan.glb +0 -0
  74. package/templates/animation-studio/public/hifi-cast/RiggedFigure.glb +0 -0
  75. package/templates/animation-studio/public/hifi-cast/kaykit/Barbarian.glb +0 -0
  76. package/templates/animation-studio/public/hifi-cast/kaykit/Knight.glb +0 -0
  77. package/templates/animation-studio/public/hifi-cast/kaykit/Mage.glb +0 -0
  78. package/templates/animation-studio/public/hifi-cast/kaykit/Rogue.glb +0 -0
  79. package/templates/animation-studio/public/hifi-cast/kaykit/Rogue_Hooded.glb +0 -0
  80. package/templates/animation-studio/public/hifi-cast/khronos-report.json +295 -0
  81. package/templates/animation-studio/public/hifi-cast/quaternius-report.json +449 -0
  82. package/templates/animation-studio/public/hifi-cast/rpm/cesiumman.glb +0 -0
  83. package/templates/animation-studio/public/hifi-cast/rpm/fox.glb +0 -0
  84. package/templates/animation-studio/public/hifi-cast/rpm/riggedfigure.glb +0 -0
  85. package/templates/animation-studio/public/hifi-cast/rpm-report.json +301 -0
  86. package/templates/animation-studio/scripts/anim.ts +103 -0
  87. package/templates/animation-studio/scripts/animation-scene.ts +457 -0
  88. package/templates/animation-studio/scripts/asset-motion-probe.ts +450 -0
  89. package/templates/animation-studio/scripts/asset-render-probe.ts +152 -0
  90. package/templates/animation-studio/scripts/author-office-scene.ts +65 -0
  91. package/templates/animation-studio/scripts/build-cast-library.ts +97 -0
  92. package/templates/animation-studio/scripts/build-characters.ts +1543 -0
  93. package/templates/animation-studio/scripts/build-clip-library.ts +972 -0
  94. package/templates/animation-studio/scripts/clip-decision-cli.ts +51 -0
  95. package/templates/animation-studio/scripts/determinism-check.ts +48 -0
  96. package/templates/animation-studio/scripts/generate-scene.ts +95 -0
  97. package/templates/animation-studio/scripts/hifi-cast-validate.ts +572 -0
  98. package/templates/animation-studio/scripts/motion-evidence-cli.ts +150 -0
  99. package/templates/animation-studio/scripts/motion-evidence.ts +362 -0
  100. package/templates/animation-studio/scripts/preview-server.ts +52 -0
  101. package/templates/animation-studio/scripts/regen-manifest.ts +156 -0
  102. package/templates/animation-studio/scripts/render-core.ts +360 -0
  103. package/templates/animation-studio/scripts/render-live.ts +813 -0
  104. package/templates/animation-studio/scripts/render-parallel.ts +70 -0
  105. package/templates/animation-studio/scripts/render-server.ts +119 -0
  106. package/templates/animation-studio/scripts/resolve-asset.ts +669 -0
  107. package/templates/animation-studio/scripts/skeleton-overlay-cli.ts +82 -0
  108. package/templates/animation-studio/scripts/skeleton-overlay.ts +293 -0
  109. package/templates/animation-studio/scripts/studio-tools-demo.ts +45 -0
  110. package/templates/animation-studio/scripts/texture-bake.ts +297 -0
  111. package/templates/{cartoon-studio → animation-studio}/scripts/validate-characters.ts +0 -0
  112. package/templates/animation-studio/scripts/verify-extracted-overlay-cli.ts +264 -0
  113. package/templates/animation-studio/src/animation-episode-validator.ts +97 -0
  114. package/templates/animation-studio/src/animation-performance.ts +556 -0
  115. package/templates/animation-studio/src/aura-assets.ts +71 -0
  116. package/templates/animation-studio/src/character.ts +40 -0
  117. package/templates/animation-studio/src/director/compile-episode-document.ts +45 -0
  118. package/templates/animation-studio/src/director/director-heuristics.ts +697 -0
  119. package/templates/animation-studio/src/director/prompt-to-scene.ts +369 -0
  120. package/templates/animation-studio/src/empty-document.ts +77 -0
  121. package/templates/animation-studio/src/episode-document.ts +442 -0
  122. package/templates/animation-studio/src/examples/moon-garden.example.ts +183 -0
  123. package/templates/animation-studio/src/fidelity.ts +129 -0
  124. package/templates/animation-studio/src/graph.ts +14 -0
  125. package/templates/animation-studio/src/procedural-hdri.ts +243 -0
  126. package/templates/animation-studio/src/profile.ts +97 -0
  127. package/templates/animation-studio/src/render-live-route.ts +42 -0
  128. package/templates/animation-studio/src/render-modes.ts +137 -0
  129. package/templates/animation-studio/src/scene-player.ts +867 -0
  130. package/templates/animation-studio/src/set-templates.ts +361 -0
  131. package/templates/animation-studio/src/studio/episode-document-store.ts +72 -0
  132. package/templates/animation-studio/src/studio/scene-tool-registry.ts +86 -0
  133. package/templates/animation-studio/src/studio/scene-tools.ts +169 -0
  134. package/templates/animation-studio/tests/reports/animation-studio/hifi-cast-validation.json +938 -0
  135. package/templates/animation-studio/tsconfig.json +26 -0
  136. package/templates/{cartoon-studio → animation-studio}/vite.config.ts +6 -1
  137. package/templates/character-controller/README.md +33 -0
  138. package/templates/character-controller/index.html +19 -0
  139. package/templates/character-controller/package.json +23 -0
  140. package/templates/character-controller/playwright.config.ts +14 -0
  141. package/templates/character-controller/src/controller.ts +44 -0
  142. package/templates/character-controller/src/main.ts +62 -0
  143. package/templates/character-controller/tests/route-health.spec.ts +26 -0
  144. package/templates/character-controller/tests/screenshot.spec.ts +9 -0
  145. package/templates/character-controller/tsconfig.json +15 -0
  146. package/templates/cinematic-scene/package.json +1 -1
  147. package/templates/episode-builder/README.md +4 -4
  148. package/templates/episode-builder/package.json +1 -1
  149. package/templates/episode-builder/src/aura-assets.ts +1 -1
  150. package/templates/episode-builder/src/builder.ts +4 -4
  151. package/templates/episode-builder/src/episode.ts +21 -21
  152. package/templates/episode-builder/src/main.ts +20 -20
  153. package/templates/episode-builder/src/render-plan.ts +13 -13
  154. package/templates/episode-builder/tests/storyboard-playback.spec.ts +7 -7
  155. package/templates/fighting-game/package.json +1 -1
  156. package/templates/mini-game/package.json +1 -1
  157. package/templates/product-viewer/package.json +1 -1
  158. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/README.md +5 -5
  159. package/templates/{cartoon-channel → prompt-animation-channel}/index.html +1 -1
  160. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/package.json +2 -2
  161. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/aura-assets.ts +1 -1
  162. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/episode.ts +22 -22
  163. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/main.ts +28 -28
  164. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/render-plan.ts +13 -13
  165. package/templates/{cartoon-channel → prompt-animation-channel}/tests/route-health.spec.ts +1 -1
  166. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/tests/storyboard-playback.spec.ts +8 -8
  167. package/templates/cartoon-studio/aura.assets.json +0 -400
  168. package/templates/cartoon-studio/package.json +0 -33
  169. package/templates/cartoon-studio/public/aura-assets/luma.authored.glb +0 -0
  170. package/templates/cartoon-studio/public/aura-assets/miko.authored.glb +0 -0
  171. package/templates/cartoon-studio/scripts/build-characters.ts +0 -738
  172. package/templates/cartoon-studio/scripts/build-dialogue-audio.ts +0 -262
  173. package/templates/cartoon-studio/scripts/episode.ts +0 -323
  174. package/templates/cartoon-studio/scripts/render-live.ts +0 -873
  175. package/templates/cartoon-studio/src/aura-assets.ts +0 -64
  176. package/templates/cartoon-studio/src/characters.ts +0 -65
  177. package/templates/cartoon-studio/src/episode-renderer.ts +0 -605
  178. package/templates/cartoon-studio/src/episode.ts +0 -306
  179. package/templates/cartoon-studio/src/main.ts +0 -400
  180. package/templates/cartoon-studio/src/render-live-route.ts +0 -913
  181. package/templates/cartoon-studio/src/render-plan.ts +0 -567
  182. package/templates/cartoon-studio/src/review.ts +0 -99
  183. package/templates/cartoon-studio/src/studio.ts +0 -101
  184. package/templates/cartoon-studio/test-results/.last-run.json +0 -4
  185. package/templates/cartoon-studio/tests/episode-render.spec.ts +0 -44
  186. package/templates/cartoon-studio/tests/motion-quality.spec.ts +0 -45
  187. package/templates/cartoon-studio/tests/route-health.spec.ts +0 -7
  188. package/templates/cartoon-studio/tests/storyboard-playback.spec.ts +0 -99
  189. package/templates/cartoon-studio/tests/visual-review.spec.ts +0 -21
  190. package/templates/cartoon-studio/tsconfig.json +0 -14
  191. package/templates/prompt-cartoon-channel/index.html +0 -13
  192. package/templates/prompt-cartoon-channel/src/sets.ts +0 -14
  193. /package/templates/{cartoon-channel → animation-channel}/aura.assets.json +0 -0
  194. /package/templates/{cartoon-channel → animation-channel}/playwright.config.ts +0 -0
  195. /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/luma.047f5e5f.glb +0 -0
  196. /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/luma.thumb.svg +0 -0
  197. /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/miko.047f5e5f.glb +0 -0
  198. /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/miko.thumb.svg +0 -0
  199. /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/moon-garden-feature-frame.png +0 -0
  200. /package/templates/{cartoon-channel → animation-channel}/src/aura-assets.ts +0 -0
  201. /package/templates/{cartoon-channel → animation-channel}/src/characters.ts +0 -0
  202. /package/templates/{cartoon-channel → animation-channel}/src/contract.ts +0 -0
  203. /package/templates/{cartoon-channel → animation-channel}/src/sets.ts +0 -0
  204. /package/templates/{cartoon-channel → animation-channel}/test-results/.last-run.json +0 -0
  205. /package/templates/{cartoon-channel → animation-channel}/tests/reports/prompt-animation/cartoon-sample-episode.png +0 -0
  206. /package/templates/{cartoon-channel → animation-channel}/tsconfig.json +0 -0
  207. /package/templates/{cartoon-studio → animation-studio}/playwright.config.ts +0 -0
  208. /package/templates/{cartoon-studio → animation-studio}/public/aura-assets/luma.thumb.svg +0 -0
  209. /package/templates/{cartoon-studio → animation-studio}/public/aura-assets/miko.thumb.svg +0 -0
  210. /package/templates/{cartoon-studio → animation-studio}/src/contract.ts +0 -0
  211. /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/playwright.config.ts +0 -0
  212. /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/characters.ts +0 -0
  213. /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/contract.ts +0 -0
  214. /package/templates/{cartoon-studio → prompt-animation-channel}/src/sets.ts +0 -0
  215. /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/tsconfig.json +0 -0
@@ -0,0 +1,813 @@
1
+ /**
2
+ * render-live.ts — headless capture + toon post-pass + real video encode for the
3
+ * Animation Studio LIVE 3D render route (`src/render-live-route.ts`).
4
+ *
5
+ * Pipeline (every stage touches real bytes — nothing is faked):
6
+ * 1. Serve the `live-route.html` route with a real Vite dev server.
7
+ * 2. Drive it with Playwright (Chromium, WebGL2). Wait for the route's
8
+ * `__AURA_LIVE_ROUTE_READY__` proof, then call its `__auraSeek__(time)` hook
9
+ * at N distinct animation times. Each seek poses BOTH skinned GLB skeletons
10
+ * and renders one frame; we read the canvas back as raw RGBA pixels
11
+ * (gl.readPixels via a 2D copy → ImageData), so the captured bytes are the
12
+ * actual rendered, skinned characters.
13
+ * 3. Apply the REAL toon treatment from `@aura3d/rendering` (monorepo source):
14
+ * - band-quantize each pixel's luma with `quantizeToonBand` (the exact cel
15
+ * math the AnimationToonMaterial GLSL mirrors), giving posterized shading,
16
+ * - run the Sobel `outlinePixels` ink pass + `colorGradePixels` storybook
17
+ * grade that `applyAnimationRenderPreset` prescribes.
18
+ * 4. Save the 4 representative frames as PNGs named for the fidelity gate
19
+ * (first/dialogue/action/final) AND encode ALL frames into a real
20
+ * `episode-3d.webm` via `createFfmpegFrameEncoderAdapter` (libvpx-vp9).
21
+ *
22
+ * IMPORTANT — run this from the MONOREPO ROOT so `@aura3d/rendering` /
23
+ * `@aura3d/engine` resolve to the SOURCE build (which ships AnimationToonMaterial +
24
+ * the animation shader/post-passes). The template's own published `@aura3d/engine`
25
+ * lacks those exports; the in-browser route only needs the renderer + skinning,
26
+ * which the published build DOES have. Invoke as:
27
+ * pnpm exec tsx --tsconfig tsconfig.base.json \
28
+ * packages/create-aura3d/templates/animation-studio/scripts/render-live.ts
29
+ */
30
+
31
+ import { spawnSync } from "node:child_process";
32
+ import { createRequire } from "node:module";
33
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
34
+ import { dirname, resolve } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ import { chromium } from "@playwright/test";
37
+ import { createServer, type ViteDevServer } from "vite";
38
+ import { createFfmpegFrameEncoderAdapter } from "@aura3d/engine";
39
+ import { emptyDocument, EMPTY_DOCUMENT_NOTICE } from "../src/empty-document.js";
40
+ import type { EpisodeDocument } from "../src/episode-document.js";
41
+ // PHASE 5.3: the CPU toon post-pass lives ONCE in render-core.ts (the canonical home).
42
+ // render-live imports the shared functions instead of carrying its own copies.
43
+ import {
44
+ sharp,
45
+ rawRgbaToPng,
46
+ compositeCaptionPng,
47
+ applyToonTreatment as applyToonTreatmentCore
48
+ } from "./render-core.js";
49
+ // B2 — skeleton-overlay strip (3-frame bone-projection PNG per character). Browser/GPU-free FK on a
50
+ // canonical rig driven by the SAME shared standard-library clip the player retargets.
51
+ import { buildSkeletonStrip } from "./skeleton-overlay.js";
52
+ // I2 — render-mode VERIFICATION (toon/wireframe/storyboard + toon/pbr style). An unknown flag throws
53
+ // here rather than silently rendering the wrong view; the resolved mode is recorded in the summary.
54
+ import { resolveRenderMode, renderModeNotes } from "../src/render-modes.js";
55
+
56
+ const __dirname = dirname(fileURLToPath(import.meta.url));
57
+ const TEMPLATE_ROOT = resolve(__dirname, "..");
58
+ const OUTPUT_DIR = resolve(TEMPLATE_ROOT, process.env.AURA_OUTPUT_DIR ?? "dist/episodes/live-3d");
59
+ const FRAMES_DIR = resolve(OUTPUT_DIR, "frames");
60
+ const VIDEO_PATH = resolve(OUTPUT_DIR, "episode-3d.webm");
61
+
62
+ // M5 — RESOLUTION / OUTPUT TIERS via AURA_QUALITY:
63
+ // preview (DEFAULT) = low-fi 480x270 @ 8fps, cheaper post — fast studio iteration.
64
+ // final = 1080p (1920x1080) @ 24fps with the full post pass and no preview
65
+ // shortcuts: device-scale supersampling for anti-aliasing, the full
66
+ // (non-low-fi) toon/grade pass, higher fps.
67
+ // AURA_LOW_FIDELITY=1 stays a working ALIAS for the preview tier (back-compat).
68
+ type QualityTier = "preview" | "final";
69
+ // AURA_LOW_FIDELITY=1 is an explicit alias for the preview tier and overrides AURA_QUALITY.
70
+ const QUALITY: QualityTier =
71
+ process.env.AURA_LOW_FIDELITY === "1" ? "preview" : process.env.AURA_QUALITY === "final" ? "final" : "preview";
72
+ // LOW_FI gates the cheaper CPU post-pass; the preview tier (and the legacy alias) are low-fi,
73
+ // the final tier runs the full pass. AURA_LOW_FIDELITY=1 forces preview even without AURA_QUALITY.
74
+ const LOW_FI = QUALITY === "preview";
75
+ const WIDTH = QUALITY === "final" ? 1920 : 480;
76
+ const HEIGHT = QUALITY === "final" ? 1080 : 270;
77
+ const FRAME_RATE = QUALITY === "final" ? 24 : 8;
78
+ // Final renders supersample (deviceScaleFactor > 1) for anti-aliasing; preview renders 1:1.
79
+ const DEVICE_SCALE = QUALITY === "final" ? 2 : 1;
80
+
81
+ // I2 — resolve + VERIFY the render mode/style up front (throws on an unknown/dead flag). The CPU
82
+ // toon post-pass + the in-shader cel only apply when this is the toon mode + toon style.
83
+ const RENDER_MODE = resolveRenderMode();
84
+
85
+ // Render the REAL episode timeline: the full 0..EPISODE_DURATION (60s) at FRAME_RATE,
86
+ // so the video plays as an actual animation — shots held for their real durations, cuts
87
+ // at the right moments, captions on their real cue times, characters animating
88
+ // continuously (clips auto-loop). 12 fps is the classic "on twos" animation cadence and
89
+ // keeps the frame count (and render time) sane vs 24/30.
90
+ // The scene to render: an EXPLICIT document from AURA_DOCUMENT, otherwise the generic
91
+ // EMPTY placeholder — NEVER a content fixture. (To render the Moon Garden example, point
92
+ // AURA_DOCUMENT at a serialized copy of src/examples/moon-garden.example.ts.)
93
+ // Read on the Node side so the duration + AuraVoice handoff come from THIS scene.
94
+ const USING_EMPTY_FALLBACK = !process.env.AURA_DOCUMENT;
95
+ const SCENE_DOCUMENT: EpisodeDocument = process.env.AURA_DOCUMENT
96
+ ? (JSON.parse(readFileSync(resolve(process.env.AURA_DOCUMENT), "utf8")) as EpisodeDocument)
97
+ : emptyDocument;
98
+ if (USING_EMPTY_FALLBACK) console.warn(EMPTY_DOCUMENT_NOTICE);
99
+ const EPISODE_DURATION = SCENE_DOCUMENT.duration;
100
+ const FRAME_COUNT = Math.max(1, Math.round(EPISODE_DURATION * FRAME_RATE));
101
+ // PREVIEW RANGE: render only a time window for fast studio iteration, e.g.
102
+ // AURA_PREVIEW_RANGE="0-12" renders the first 12s. Default = the whole episode.
103
+ const PREVIEW_PARTS = process.env.AURA_PREVIEW_RANGE?.split("-").map((s) => Number(s));
104
+ const FIRST_FRAME = PREVIEW_PARTS ? Math.max(0, Math.round((PREVIEW_PARTS[0] ?? 0) * FRAME_RATE)) : 0;
105
+ const LAST_FRAME = PREVIEW_PARTS ? Math.min(FRAME_COUNT, Math.round((PREVIEW_PARTS[1] ?? EPISODE_DURATION) * FRAME_RATE)) : FRAME_COUNT;
106
+ const FIDELITY_FRAME_IDS = ["first", "dialogue", "action", "final"] as const;
107
+ // Representative episode times for the 4 named still frames, as FRACTIONS of the actual episode
108
+ // duration so they always fall inside the scene (a 24s generated scene and a 60s authored one both
109
+ // get a valid first/dialogue/action/final). Hardcoded seconds (e.g. action=31s) silently skip those
110
+ // frames on any scene shorter than 31s, leaving the fidelity/visual-quality gate without them.
111
+ const FIDELITY_TIMES: Record<(typeof FIDELITY_FRAME_IDS)[number], number> = {
112
+ first: Math.max(0, EPISODE_DURATION * 0.08),
113
+ dialogue: EPISODE_DURATION * 0.42,
114
+ action: EPISODE_DURATION * 0.66,
115
+ final: EPISODE_DURATION * 0.92
116
+ };
117
+
118
+ /**
119
+ * Toon treatment for render-live's proof instrumentation. The pixel math is the SINGLE
120
+ * canonical implementation in render-core (`applyToonTreatmentCore`); here we just call
121
+ * it with this script's module-level LOW_FI and wrap the result in the metadata object
122
+ * render-live's summary/logging expects (bands/outline/colorGrade are the constants the
123
+ * canonical pass always produces: 6 bands, ink outline on, color grade on).
124
+ */
125
+ function applyToonTreatment(pixels: Uint8Array, width: number, height: number): {
126
+ readonly pixels: Uint8Array;
127
+ readonly bands: number;
128
+ readonly outline: boolean;
129
+ readonly colorGrade: boolean;
130
+ } {
131
+ const treated = applyToonTreatmentCore(pixels, width, height, LOW_FI);
132
+ return { pixels: treated, bands: 6, outline: true, colorGrade: true };
133
+ }
134
+
135
+ /** Resolve an ffmpeg-family binary (`ffmpeg` / `ffprobe`), preferring the bundled
136
+ * `@ffmpeg-installer`/`ffmpeg-static` if present, else the one on PATH. */
137
+ function resolveFfBinary(name: "ffmpeg" | "ffprobe"): string {
138
+ const require = createRequire(import.meta.url);
139
+ try {
140
+ if (name === "ffmpeg") {
141
+ const installer = require("@ffmpeg-installer/ffmpeg") as { path?: string };
142
+ if (installer.path && existsSync(installer.path)) return installer.path;
143
+ }
144
+ } catch {
145
+ /* fall through to PATH */
146
+ }
147
+ try {
148
+ if (name === "ffmpeg") {
149
+ const ffmpegStatic = require("ffmpeg-static") as string | { default?: string };
150
+ const p = typeof ffmpegStatic === "string" ? ffmpegStatic : ffmpegStatic.default;
151
+ if (p && existsSync(p)) return p;
152
+ }
153
+ } catch {
154
+ /* fall through to PATH */
155
+ }
156
+ return name; // rely on PATH
157
+ }
158
+
159
+ // NOTE: Aura3D owns no audio. There is intentionally no TTS, no ambient bed, and no
160
+ // audio-mux here — narration/voice/visemes are AuraVoice's responsibility (it drives
161
+ // this engine via the @aura3d/engine AuraVoice bridge). The rendered video is silent.
162
+
163
+ // Monorepo root (…/aura3d). Resolved from this script's location so the dev
164
+ // server can alias `@aura3d/*` to the freshly-built `dist/` (same approach the
165
+ // working aura-clash showcase uses). The template's own published `@aura3d/engine`
166
+ // (v1.1.0) has a stricter/older material binder that rejects unbound PBR env-map
167
+ // textures, so we resolve the in-repo build that the renderer is validated against.
168
+ const MONOREPO_ROOT = resolve(TEMPLATE_ROOT, "../../../..");
169
+ const DIST = (p: string): string => resolve(MONOREPO_ROOT, "dist", p);
170
+
171
+ async function startViteServer(): Promise<ViteDevServer> {
172
+ const distBuildExists = existsSync(DIST("engine/advanced-runtime/index.js"));
173
+ const server = await createServer({
174
+ root: TEMPLATE_ROOT,
175
+ configFile: false,
176
+ ...(distBuildExists
177
+ ? {
178
+ resolve: {
179
+ alias: [
180
+ { find: /^@aura3d\/engine$/, replacement: DIST("engine/agent-api/index.js") },
181
+ { find: /^@aura3d\/engine\/advanced-runtime$/, replacement: DIST("engine/advanced-runtime/index.js") },
182
+ { find: /^@aura3d\/engine\/production-runtime$/, replacement: DIST("engine/production-runtime/index.js") },
183
+ { find: /^@aura3d\/engine\/assets\/browser$/, replacement: DIST("assets/browser-index.js") },
184
+ { find: /^@aura3d\/engine\/rendering$/, replacement: DIST("rendering/index.js") },
185
+ { find: /^@aura3d\/engine\/scene$/, replacement: DIST("scene/index.js") },
186
+ { find: /^@aura3d\/rendering$/, replacement: DIST("rendering/index.js") },
187
+ { find: /^@aura3d\/scene$/, replacement: DIST("scene/index.js") },
188
+ { find: /^@aura3d\/assets\/browser$/, replacement: DIST("assets/browser-index.js") }
189
+ ]
190
+ }
191
+ }
192
+ : {}),
193
+ // HMR off: a deterministic frame capture must not reload mid-render if a file is
194
+ // touched (e.g. by a linter), which would transiently clear the route's seek hook.
195
+ server: { host: "127.0.0.1", port: 0, hmr: false },
196
+ logLevel: "warn"
197
+ });
198
+ await server.listen();
199
+ return server;
200
+ }
201
+
202
+ interface CapturedFrame {
203
+ readonly time: number;
204
+ /** Final toon-treated + captioned PNG. We discard the 2MB raw bytes as we go so the
205
+ * full 60s @ 12fps (~720 frames) never holds the whole movie in memory. */
206
+ readonly png: Uint8Array;
207
+ }
208
+
209
+ /**
210
+ * B1/B2 — the per-character/per-beat clip-decision record emitted by `scene-player`'s seek proof.
211
+ * `bodyBoneRotationRad` is the rig-neutral body-bone rotation amplitude (radians) this frame,
212
+ * EXCLUDING mouth morph + captions + camera — exactly what a body-motion gate consumes.
213
+ */
214
+ interface ClipDecisionRecord {
215
+ readonly characterId: string;
216
+ readonly time: number;
217
+ readonly intent: string;
218
+ readonly clipId: string;
219
+ readonly source: "extracted" | "procedural" | "embedded" | "idle-fallback";
220
+ readonly bonesTouched: number;
221
+ readonly maxRotAmplitudeRad: number;
222
+ readonly maxTransAmplitude: number;
223
+ readonly bodyBoneRotationRad: Record<string, number>;
224
+ readonly rootTranslation: number;
225
+ readonly reachedGLBRuntime: boolean;
226
+ }
227
+
228
+ /** Body bones whose per-frame rotation range we summarize in render-live-summary.json (B2). */
229
+ const BODY_BONE_KEYS = [
230
+ "hips",
231
+ "spine",
232
+ "chest",
233
+ "upperChest",
234
+ "neck",
235
+ "head",
236
+ "leftShoulder",
237
+ "leftUpperArm",
238
+ "leftLowerArm",
239
+ "rightShoulder",
240
+ "rightUpperArm",
241
+ "rightLowerArm"
242
+ ] as const;
243
+
244
+ /** Per-character body-motion accumulator across the sampled frames (B2). */
245
+ interface BodyMotionAccumulator {
246
+ characterId: string;
247
+ frames: number;
248
+ clipSources: Record<string, number>;
249
+ intents: Record<string, number>;
250
+ maxBonesTouched: number;
251
+ maxRotAmplitudeRad: number;
252
+ maxTransAmplitude: number;
253
+ maxRootTranslation: number;
254
+ /** Frames in which this character's pose actually reached the GLB skeleton (B1). */
255
+ reachedGLBRuntimeFrames: number;
256
+ /** Per-bone min/max rotation amplitude (rad) seen across frames. */
257
+ bones: Record<string, { min: number; max: number; sumAbs: number; samples: number }>;
258
+ }
259
+
260
+ const seekProofs: unknown[] = [];
261
+ const bodyMotionByCharacter = new Map<string, BodyMotionAccumulator>();
262
+ /** Per-character/per-beat clip-decision log (B1) — every sampled frame's decision record. */
263
+ const clipDecisionLog: ClipDecisionRecord[] = [];
264
+
265
+ /** Fold one frame's clip-decision into the per-character body-motion accumulator (B2). */
266
+ function accumulateBodyMotion(decision: ClipDecisionRecord): void {
267
+ let acc = bodyMotionByCharacter.get(decision.characterId);
268
+ if (!acc) {
269
+ acc = {
270
+ characterId: decision.characterId,
271
+ frames: 0,
272
+ clipSources: {},
273
+ intents: {},
274
+ maxBonesTouched: 0,
275
+ maxRotAmplitudeRad: 0,
276
+ maxTransAmplitude: 0,
277
+ maxRootTranslation: 0,
278
+ reachedGLBRuntimeFrames: 0,
279
+ bones: {}
280
+ };
281
+ bodyMotionByCharacter.set(decision.characterId, acc);
282
+ }
283
+ acc.frames += 1;
284
+ acc.clipSources[decision.source] = (acc.clipSources[decision.source] ?? 0) + 1;
285
+ acc.intents[decision.intent] = (acc.intents[decision.intent] ?? 0) + 1;
286
+ acc.maxBonesTouched = Math.max(acc.maxBonesTouched, decision.bonesTouched);
287
+ acc.maxRotAmplitudeRad = Math.max(acc.maxRotAmplitudeRad, decision.maxRotAmplitudeRad);
288
+ acc.maxTransAmplitude = Math.max(acc.maxTransAmplitude, decision.maxTransAmplitude ?? 0);
289
+ acc.maxRootTranslation = Math.max(acc.maxRootTranslation, decision.rootTranslation);
290
+ if (decision.reachedGLBRuntime) acc.reachedGLBRuntimeFrames += 1;
291
+ for (const bone of BODY_BONE_KEYS) {
292
+ const amp = decision.bodyBoneRotationRad?.[bone] ?? 0;
293
+ const slot = (acc.bones[bone] ??= { min: Infinity, max: 0, sumAbs: 0, samples: 0 });
294
+ slot.min = Math.min(slot.min, amp);
295
+ slot.max = Math.max(slot.max, amp);
296
+ slot.sumAbs += amp;
297
+ slot.samples += 1;
298
+ }
299
+ }
300
+
301
+ interface SeekReadResult {
302
+ readonly proof: unknown;
303
+ readonly raw: Uint8Array;
304
+ }
305
+
306
+ interface CaptionBurnResult {
307
+ /** Caption text actually drawn into the frame. */
308
+ readonly text: string;
309
+ /** Mean luma (0..1) of the caption plate region BEFORE the text was drawn. */
310
+ readonly backgroundLuma: number;
311
+ /** Luma (0..1) of the caption text fill. */
312
+ readonly textLuma: number;
313
+ /** WCAG-style contrast ratio (1..21) between text and its plate background. */
314
+ readonly contrastRatio: number;
315
+ }
316
+
317
+ /** Drive the route's seek hook at `time` (with optional mouth override), BURN the
318
+ * active caption text into the captured frame (so the exported video carries
319
+ * visible captions, not just a DOM overlay), and read back raw RGBA bytes. Returns
320
+ * the caption-contrast measurement so the script can log/verify it. */
321
+ async function seekAndReadPixels(
322
+ page: import("@playwright/test").Page,
323
+ time: number,
324
+ options?: { mouthOverride?: number; burnCaption?: boolean }
325
+ ): Promise<SeekReadResult & { caption?: CaptionBurnResult }> {
326
+ const burnCaption = options?.burnCaption !== false;
327
+ const result = await page.evaluate(
328
+ ({ t, w, h, opts, burn }) => {
329
+ const win = window as unknown as {
330
+ __auraSeek__: (time: number, options?: { mouthOverride?: number }) => { caption?: { text?: string } };
331
+ __AURA_LIVE_SEEK_LAST__?: unknown;
332
+ };
333
+ const proof = win.__auraSeek__(t, { mouthOverride: opts?.mouthOverride });
334
+ win.__AURA_LIVE_SEEK_LAST__ = proof;
335
+ const canvas = document.querySelector("#live-canvas") as HTMLCanvasElement;
336
+ // Copy the WebGL canvas into a 2D canvas to read back stable RGBA bytes.
337
+ const copy = document.createElement("canvas");
338
+ copy.width = w;
339
+ copy.height = h;
340
+ const ctx = copy.getContext("2d")!;
341
+ ctx.drawImage(canvas, 0, 0, w, h);
342
+
343
+ // ---- BURNED-IN CAPTION (accessibility) ----
344
+ // Draw the active caption text from the seek proof onto the captured frame so
345
+ // the exported video has visible captions baked into the pixels.
346
+ let caption: CaptionBurnResult | undefined;
347
+ const text = (proof.caption?.text ?? "").trim();
348
+ if (burn && text.length > 0) {
349
+ // Bottom-center high-contrast rounded plate (matches the episode captionStyle).
350
+ const fontPx = Math.round(h * 0.042);
351
+ ctx.font = `600 ${fontPx}px -apple-system, "Segoe UI", Roboto, sans-serif`;
352
+ ctx.textAlign = "center";
353
+ ctx.textBaseline = "middle";
354
+ const metrics = ctx.measureText(text);
355
+ const padX = 22;
356
+ const padY = 12;
357
+ const plateW = Math.min(w - 24, metrics.width + padX * 2);
358
+ const plateH = fontPx + padY * 2;
359
+ const cx = w / 2;
360
+ const plateY = h - plateH - 20;
361
+ const plateX = cx - plateW / 2;
362
+
363
+ // Measure the plate-region background luma BEFORE drawing (contrast check).
364
+ // sRGB->linear relative luminance is inlined (no nested fns: tsx/esbuild's
365
+ // keepNames helper is not available inside page.evaluate).
366
+ const bg = ctx.getImageData(plateX, plateY, Math.max(1, plateW), Math.max(1, plateH)).data;
367
+ let bgSum = 0;
368
+ for (let i = 0; i < bg.length; i += 4) {
369
+ const sr = bg[i]! / 255;
370
+ const sg = bg[i + 1]! / 255;
371
+ const sb = bg[i + 2]! / 255;
372
+ const lr = sr <= 0.03928 ? sr / 12.92 : Math.pow((sr + 0.055) / 1.055, 2.4);
373
+ const lg = sg <= 0.03928 ? sg / 12.92 : Math.pow((sg + 0.055) / 1.055, 2.4);
374
+ const lb = sb <= 0.03928 ? sb / 12.92 : Math.pow((sb + 0.055) / 1.055, 2.4);
375
+ bgSum += 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
376
+ }
377
+ const backgroundLuma = bgSum / (bg.length / 4);
378
+
379
+ // Dark rounded plate so high-contrast white text reads over any scene pixels.
380
+ ctx.fillStyle = "rgba(6, 10, 22, 0.82)";
381
+ const r = 14;
382
+ ctx.beginPath();
383
+ ctx.moveTo(plateX + r, plateY);
384
+ ctx.arcTo(plateX + plateW, plateY, plateX + plateW, plateY + plateH, r);
385
+ ctx.arcTo(plateX + plateW, plateY + plateH, plateX, plateY + plateH, r);
386
+ ctx.arcTo(plateX, plateY + plateH, plateX, plateY, r);
387
+ ctx.arcTo(plateX, plateY, plateX + plateW, plateY, r);
388
+ ctx.closePath();
389
+ ctx.fill();
390
+
391
+ // White caption text with a subtle dark stroke for edge contrast.
392
+ ctx.lineWidth = Math.max(2, fontPx * 0.12);
393
+ ctx.strokeStyle = "rgba(0, 0, 0, 0.85)";
394
+ ctx.strokeText(text, cx, plateY + plateH / 2);
395
+ ctx.fillStyle = "rgba(248, 255, 242, 1)"; // #f8fff2 from the episode palette
396
+ ctx.fillText(text, cx, plateY + plateH / 2);
397
+
398
+ // Contrast is text (#f8fff2, near-white) vs the dark plate. Both luminances
399
+ // are computed inline; the near-white text over a near-black plate yields a
400
+ // very high WCAG ratio (well past AA 4.5:1).
401
+ const textLuma = 0.2126 * Math.pow((248 / 255 + 0.055) / 1.055, 2.4) +
402
+ 0.7152 * Math.pow((255 / 255 + 0.055) / 1.055, 2.4) +
403
+ 0.0722 * Math.pow((242 / 255 + 0.055) / 1.055, 2.4);
404
+ // Plate is rgba(6,10,22) at 0.82 over the (dark) scene; approximate as the
405
+ // plate color itself (near-black), whose linear luminance is ~0.
406
+ const pr = (6 / 255) <= 0.03928 ? (6 / 255) / 12.92 : Math.pow((6 / 255 + 0.055) / 1.055, 2.4);
407
+ const pg = (10 / 255) <= 0.03928 ? (10 / 255) / 12.92 : Math.pow((10 / 255 + 0.055) / 1.055, 2.4);
408
+ const pb = (22 / 255) <= 0.03928 ? (22 / 255) / 12.92 : Math.pow((22 / 255 + 0.055) / 1.055, 2.4);
409
+ const plateLuma = 0.2126 * pr + 0.7152 * pg + 0.0722 * pb;
410
+ const contrastRatio = (Math.max(textLuma, plateLuma) + 0.05) / (Math.min(textLuma, plateLuma) + 0.05);
411
+ caption = { text, backgroundLuma, textLuma, contrastRatio };
412
+ }
413
+
414
+ return { proof, dataUrl: copy.toDataURL("image/png"), caption };
415
+ },
416
+ { t: time, w: WIDTH, h: HEIGHT, opts: options ?? {}, burn: burnCaption }
417
+ );
418
+ // #1 PERF: transfer a compressed PNG (~100KB) across the Playwright/CDP bridge
419
+ // instead of a 2,073,600-element pixel array, then decode to raw RGBA in Node with
420
+ // sharp. The giant per-frame array serialization was the dominant render cost.
421
+ const b64 = result.dataUrl.slice(result.dataUrl.indexOf(",") + 1);
422
+ const decoded = await (
423
+ sharp as unknown as (b: Buffer) => {
424
+ ensureAlpha(): { raw(): { toBuffer(o: { resolveWithObject: true }): Promise<{ data: Buffer }> } };
425
+ }
426
+ )(Buffer.from(b64, "base64"))
427
+ .ensureAlpha()
428
+ .raw()
429
+ .toBuffer({ resolveWithObject: true });
430
+ return {
431
+ proof: result.proof,
432
+ raw: new Uint8Array(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength),
433
+ ...(result.caption ? { caption: result.caption } : {})
434
+ };
435
+ }
436
+
437
+ async function main(): Promise<void> {
438
+ // Clear stale frames first: representative stills (action/final) that a shorter scene
439
+ // doesn't re-capture must NOT linger from a previous render and pose as current evidence.
440
+ rmSync(FRAMES_DIR, { recursive: true, force: true });
441
+ mkdirSync(FRAMES_DIR, { recursive: true });
442
+
443
+ const server = await startViteServer();
444
+ const address = server.httpServer?.address();
445
+ if (!address || typeof address === "string") {
446
+ throw new Error("Vite dev server did not expose a numeric port.");
447
+ }
448
+ const url = `http://127.0.0.1:${address.port}/live-route.html`;
449
+ console.log(`vite dev server: ${url}`);
450
+
451
+ const browser = await chromium.launch({
452
+ args: ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swdecoder", "--ignore-gpu-blocklist"]
453
+ });
454
+ const captured: CapturedFrame[] = [];
455
+ const captionProofs: { time: number; shotId: string; text: string; start: number; end: number }[] = [];
456
+ // Per-beat staged-position record (proves the body moves between beats in pixels).
457
+ const stagingByBeat = new Map<string, { shotId: string; time: number; characters: { id: string; position: number[]; clip: string; sweeping: boolean }[] }>();
458
+ const seenCaptions = new Set<string>();
459
+ const fidelityFrameIndex = new Map<number, (typeof FIDELITY_FRAME_IDS)[number]>();
460
+ for (const id of FIDELITY_FRAME_IDS) fidelityFrameIndex.set(Math.round(FIDELITY_TIMES[id] * FRAME_RATE), id);
461
+ let toonInfo = { bands: 0, outline: false, colorGrade: false };
462
+ let readyProof: unknown;
463
+ let mouthProof: {
464
+ time: number;
465
+ shot: unknown;
466
+ changedPixels: number;
467
+ meanRgbDiff: number;
468
+ openProof: unknown;
469
+ closedProof: unknown;
470
+ } | undefined;
471
+ try {
472
+ const page = await browser.newPage({ viewport: { width: WIDTH + 40, height: HEIGHT + 40 }, deviceScaleFactor: DEVICE_SCALE });
473
+ page.on("console", (msg) => {
474
+ if (msg.type() === "error") console.error(`[page] ${msg.text()}`);
475
+ });
476
+ page.on("pageerror", (err) => console.error(`[pageerror] ${err.message}`));
477
+
478
+ await page.addInitScript(() => {
479
+ (window as unknown as { __AURA_LIVE_ROUTE_HEADLESS__: boolean }).__AURA_LIVE_ROUTE_HEADLESS__ = true;
480
+ });
481
+ // Inject the scene document so the generic player renders it (any scene, one player).
482
+ await page.addInitScript((d) => {
483
+ (window as unknown as { __AURA_EPISODE_DOCUMENT__: unknown }).__AURA_EPISODE_DOCUMENT__ = d;
484
+ }, SCENE_DOCUMENT as unknown);
485
+ console.log(
486
+ `quality: ${QUALITY} (${WIDTH}x${HEIGHT} @ ${FRAME_RATE}fps, post=${LOW_FI ? "preview/low-fi" : "full"}, deviceScale=${DEVICE_SCALE})`
487
+ );
488
+ console.log(
489
+ `render mode: ${RENDER_MODE.mode} / style ${RENDER_MODE.style} — cel ${RENDER_MODE.celApplies ? "ON (in-shader material + CPU toon post-pass)" : "OFF"}`
490
+ );
491
+ console.log(`document: ${SCENE_DOCUMENT.id} (${EPISODE_DURATION}s)${process.env.AURA_DOCUMENT ? ` from ${process.env.AURA_DOCUMENT}` : " (empty placeholder — no scene loaded)"}`);
492
+ await page.goto(url, { waitUntil: "load", timeout: 60_000 });
493
+
494
+ // Wait until the route has loaded both skinned GLBs and exposed the seek hook.
495
+ await page.waitForFunction(
496
+ () => {
497
+ const w = window as unknown as { __AURA_LIVE_ROUTE_READY__?: unknown; __AURA_LIVE_ROUTE_ERROR__?: string };
498
+ if (w.__AURA_LIVE_ROUTE_ERROR__) throw new Error(`route error: ${w.__AURA_LIVE_ROUTE_ERROR__}`);
499
+ return Boolean(w.__AURA_LIVE_ROUTE_READY__);
500
+ },
501
+ { timeout: 60_000 }
502
+ );
503
+ readyProof = await page.evaluate(() => (window as unknown as { __AURA_LIVE_ROUTE_READY__: unknown }).__AURA_LIVE_ROUTE_READY__);
504
+ console.log("route ready:", JSON.stringify(readyProof));
505
+
506
+ console.log(
507
+ `rendering frames ${FIRST_FRAME}..${LAST_FRAME} (${LAST_FRAME - FIRST_FRAME} frames) @ ${FRAME_RATE}fps` +
508
+ `${PREVIEW_PARTS ? " [preview range]" : ` over ${EPISODE_DURATION}s`} ...`
509
+ );
510
+ for (let i = FIRST_FRAME; i < LAST_FRAME; i += 1) {
511
+ const t = i / FRAME_RATE;
512
+ const result = await seekAndReadPixels(page, t, { burnCaption: false });
513
+ const proof = result.proof as {
514
+ drawCalls: number;
515
+ skinnedRenderItems: number;
516
+ shot?: { shotId: string; presetId: string; cameraPosition: number[] };
517
+ caption?: { text: string };
518
+ debugOverlay?: string[];
519
+ characters?: {
520
+ id: string;
521
+ position?: number[];
522
+ sweeping?: boolean;
523
+ clip?: string;
524
+ mouthOpenness: number;
525
+ clipDecision?: ClipDecisionRecord;
526
+ }[];
527
+ };
528
+ const shotId = proof.shot?.shotId ?? "unknown";
529
+ // The caption from the episode track at this episode time (the AuraVoice contract).
530
+ const capText = (proof.caption?.text ?? "").trim();
531
+
532
+ // Record staging once per beat + keep the open/teamwork raw frames for the proof.
533
+ if (!stagingByBeat.has(shotId)) {
534
+ stagingByBeat.set(shotId, {
535
+ shotId,
536
+ time: t,
537
+ characters: (proof.characters ?? []).map((c) => ({ id: c.id, position: c.position ?? [], clip: c.clip ?? "", sweeping: Boolean(c.sweeping) }))
538
+ });
539
+ }
540
+ if (capText && !seenCaptions.has(capText)) {
541
+ seenCaptions.add(capText);
542
+ // Echo the caption's on-screen WINDOW (start/end) from the document so the subtitle-timing
543
+ // gate can verify it tracks the speech-duration estimate (the doc owns the timing; K2).
544
+ const line = SCENE_DOCUMENT.dialogue?.lines.find((l) => l.text.trim() === capText);
545
+ captionProofs.push({ time: t, shotId, text: capText, start: line?.startTime ?? t, end: line?.endTime ?? t });
546
+ }
547
+
548
+ // B1/B2: read the posed-skeleton body-bone data from the actor's clip decision for EVERY
549
+ // character this sampled frame (rig-neutral body-bone rotation amplitudes + clip source),
550
+ // EXCLUDING mouth morph + captions + camera. Fold into the per-character body-motion ranges.
551
+ for (const c of proof.characters ?? []) {
552
+ if (!c.clipDecision) continue;
553
+ clipDecisionLog.push(c.clipDecision);
554
+ accumulateBodyMotion(c.clipDecision);
555
+ }
556
+
557
+ // Toon-treat + composite the crisp subtitle NOW; store only the final PNG.
558
+ const treated = applyToonTreatment(result.raw, WIDTH, HEIGHT);
559
+ toonInfo = { bands: treated.bands, outline: treated.outline, colorGrade: treated.colorGrade };
560
+ const basePng = await rawRgbaToPng(treated.pixels, WIDTH, HEIGHT);
561
+ const png = await compositeCaptionPng(basePng, capText, WIDTH, HEIGHT);
562
+ captured.push({ time: t, png });
563
+
564
+ const fidelityId = fidelityFrameIndex.get(i);
565
+ if (fidelityId) writeFileSync(resolve(FRAMES_DIR, `${fidelityId}.png`), png);
566
+
567
+ if (i % FRAME_RATE === 0) {
568
+ seekProofs.push(proof);
569
+ const miko = proof.characters?.find((c) => c.id === "miko");
570
+ console.log(
571
+ ` t=${t.toFixed(1)}s [${shotId}] cam=${proof.shot?.presetId} ` +
572
+ `miko@[${miko?.position?.map((v) => v.toFixed(1)).join(",")}] cap="${capText.replace(/\n/g, " ").slice(0, 42)}"`
573
+ );
574
+ }
575
+ }
576
+
577
+ console.log("\n--- staged performance (per beat) ---");
578
+ for (const beat of stagingByBeat.values()) {
579
+ const m = beat.characters.find((c) => c.id === "miko");
580
+ console.log(` ${beat.shotId}: miko@[${m?.position.map((v) => v.toFixed(2)).join(",")}] clip=${m?.clip}`);
581
+ }
582
+ console.log("--- captions (per cue) ---");
583
+ for (const c of captionProofs) console.log(` [${c.time.toFixed(0)}s ${c.shotId}] "${c.text.replace(/\n/g, " ").slice(0, 56)}"`);
584
+
585
+ // ISOLATED LIP-SYNC A/B PROOF: render the SAME pose + SAME close-up camera with
586
+ // the mouth forced fully open vs fully closed, so the ONLY difference between the
587
+ // two frames is the mouth indicator. This proves the lip-sync alone moves pixels
588
+ // (the per-frame captures above also vary skeleton/camera, which would otherwise
589
+ // confound an isolated mouth measurement).
590
+ const MOUTH_PROOF_TIME = FIDELITY_TIMES.final; // close-up shot (miko fills frame)
591
+ const mouthClosed = await seekAndReadPixels(page, MOUTH_PROOF_TIME, { mouthOverride: 0, burnCaption: false });
592
+ const mouthOpen = await seekAndReadPixels(page, MOUTH_PROOF_TIME, { mouthOverride: 1, burnCaption: false });
593
+ const closedPng = await rawRgbaToPng(applyToonTreatment(mouthClosed.raw, WIDTH, HEIGHT).pixels, WIDTH, HEIGHT);
594
+ const openPng = await rawRgbaToPng(applyToonTreatment(mouthOpen.raw, WIDTH, HEIGHT).pixels, WIDTH, HEIGHT);
595
+ writeFileSync(resolve(FRAMES_DIR, "mouth-closed.png"), closedPng);
596
+ writeFileSync(resolve(FRAMES_DIR, "mouth-open.png"), openPng);
597
+ let changedPixels = 0;
598
+ let totalDiff = 0;
599
+ for (let i = 0; i < mouthClosed.raw.length; i += 4) {
600
+ const d =
601
+ Math.abs(mouthClosed.raw[i]! - mouthOpen.raw[i]!) +
602
+ Math.abs(mouthClosed.raw[i + 1]! - mouthOpen.raw[i + 1]!) +
603
+ Math.abs(mouthClosed.raw[i + 2]! - mouthOpen.raw[i + 2]!);
604
+ totalDiff += d;
605
+ if (d > 30) changedPixels += 1;
606
+ }
607
+ mouthProof = {
608
+ time: MOUTH_PROOF_TIME,
609
+ shot: (mouthOpen.proof as { shot?: unknown }).shot,
610
+ changedPixels,
611
+ meanRgbDiff: totalDiff / (mouthClosed.raw.length / 4) / 3,
612
+ openProof: (mouthOpen.proof as { characters?: unknown }).characters,
613
+ closedProof: (mouthClosed.proof as { characters?: unknown }).characters
614
+ };
615
+ console.log(
616
+ `\nlip-sync A/B (same pose+camera, mouth open vs closed): changedPixels=${changedPixels} ` +
617
+ `meanRgbDiff=${mouthProof.meanRgbDiff.toFixed(3)} -> frames mouth-open.png / mouth-closed.png`
618
+ );
619
+ } finally {
620
+ await browser.close();
621
+ await server.close();
622
+ }
623
+
624
+ if (captured.length === 0) throw new Error("No frames were captured.");
625
+
626
+ // Encode the already-toon-treated + captioned frames into the episode video.
627
+ const encoder = await createFfmpegFrameEncoderAdapter({ codec: "vp9", container: "webm", frameRate: FRAME_RATE });
628
+ for (let index = 0; index < captured.length; index += 1) {
629
+ encoder.encode({
630
+ frame: index,
631
+ time: captured[index]!.time,
632
+ viewport: { width: WIDTH, height: HEIGHT },
633
+ image: captured[index]!.png
634
+ });
635
+ }
636
+
637
+ const durationSeconds = captured.length / FRAME_RATE;
638
+ const finalized = await encoder.finalize({
639
+ codec: "vp9",
640
+ container: "webm",
641
+ mimeType: "video/webm; codecs=vp9",
642
+ frameRate: FRAME_RATE,
643
+ viewport: { width: WIDTH, height: HEIGHT },
644
+ frameCount: captured.length,
645
+ duration: durationSeconds,
646
+ byteLength: 0,
647
+ chunks: []
648
+ });
649
+ if (!(finalized instanceof Uint8Array) || finalized.byteLength === 0) {
650
+ throw new Error(`ffmpeg produced no usable video bytes (got ${typeof finalized}).`);
651
+ }
652
+ const video = finalized;
653
+ writeFileSync(VIDEO_PATH, video);
654
+
655
+ // AURAVOICE HANDOFF CONTRACT (not "no handoff"): Aura3D does NOT generate audio
656
+ // (no TTS), but it DOES produce the timed script/caption/viseme data that is the
657
+ // synchronization contract AuraVoice consumes to generate the voice AFTER THE FACT,
658
+ // locked to this same timeline. The structured tracks live in src/render-plan.ts
659
+ // (dialogueTrack + captionTrack + visemeTrack + createAuraVoiceBridgePackage); the
660
+ // video's animation + lip-sync timing are derived from the SAME dialogue track, so
661
+ // the later-generated voice lines up. Here we surface a pointer + counts so the
662
+ // render evidence shows the contract is produced. Only the AUDIO is AuraVoice's.
663
+ const auraVoiceHandoff = {
664
+ audioOwnedBy: "auravoice",
665
+ engineTts: false,
666
+ // The data Aura3D hands to AuraVoice (words + timing + lip-sync cues):
667
+ dialogueLines: SCENE_DOCUMENT.dialogue?.lines.length ?? 0,
668
+ captionCues: SCENE_DOCUMENT.dialogue?.lines.length ?? 0,
669
+ language: SCENE_DOCUMENT.dialogue?.language ?? "en",
670
+ bridgeContract: "createAuraVoiceBridgePackage (src/render-plan.ts: dialogueTrack + captionTrack + visemeTrack)",
671
+ note: "Silent render by design. Aura3D produces the timed dialogue/caption/viseme track (the sync contract); AuraVoice consumes it to generate the voice and mux audio onto this video. Aura3D never runs TTS."
672
+ };
673
+ const muxedBytes = video.byteLength;
674
+ console.log(
675
+ `\nauravoice handoff: dialogueLines=${auraVoiceHandoff.dialogueLines} captionCues=${auraVoiceHandoff.captionCues} ` +
676
+ `lang=${auraVoiceHandoff.language} -> ${auraVoiceHandoff.bridgeContract}. ` +
677
+ `Video is SILENT; AuraVoice generates the voice from this timed track and muxes it.`
678
+ );
679
+
680
+ // B2 — per-character BODY-bone motion summary across the sampled frames. The key bones
681
+ // (hips/spine/head/shoulders/arms) carry rotation ranges (+ root translation) so a downstream
682
+ // gate can prove the body moves EXCLUDING mouth morph + captions + camera. `clipSource` records
683
+ // which library each character's motion came from (extracted catalog vs procedural vs embedded).
684
+ const bodyMotion = Array.from(bodyMotionByCharacter.values()).map((acc) => {
685
+ // The dominant clip source across frames is the character's effective motion source.
686
+ const clipSource =
687
+ Object.entries(acc.clipSources).sort((a, b) => b[1] - a[1])[0]?.[0] ?? "unknown";
688
+ const bones = Object.fromEntries(
689
+ BODY_BONE_KEYS.map((bone) => {
690
+ const s = acc.bones[bone];
691
+ const min = s && s.min !== Infinity ? +s.min.toFixed(5) : 0;
692
+ const max = s ? +s.max.toFixed(5) : 0;
693
+ const mean = s && s.samples > 0 ? +(s.sumAbs / s.samples).toFixed(5) : 0;
694
+ return [bone, { minRad: min, maxRad: max, rangeRad: +(max - min).toFixed(5), meanRad: mean }];
695
+ })
696
+ );
697
+ return {
698
+ characterId: acc.characterId,
699
+ clipSource,
700
+ clipSourceCounts: acc.clipSources,
701
+ intentCounts: acc.intents,
702
+ frames: acc.frames,
703
+ maxBonesTouched: acc.maxBonesTouched,
704
+ maxRotAmplitudeRad: +acc.maxRotAmplitudeRad.toFixed(5),
705
+ maxTransAmplitude: +acc.maxTransAmplitude.toFixed(5),
706
+ maxRootTranslation: +acc.maxRootTranslation.toFixed(5),
707
+ // B1 — did this character's motion reach the GLB skeleton at runtime in EVERY sampled frame?
708
+ reachedGLBRuntimeFrames: acc.reachedGLBRuntimeFrames,
709
+ reachedGLBRuntime: acc.frames > 0 && acc.reachedGLBRuntimeFrames === acc.frames,
710
+ bodyBoneRanges: bones
711
+ };
712
+ });
713
+
714
+ console.log("\n--- B2 per-character BODY-bone motion (mouth/caption/camera-EXCLUDED) ---");
715
+ for (const c of bodyMotion) {
716
+ const movers = BODY_BONE_KEYS.filter((b) => (c.bodyBoneRanges[b]?.maxRad ?? 0) > 0.0087)
717
+ .map((b) => `${b}=${c.bodyBoneRanges[b]!.maxRad.toFixed(3)}rad`)
718
+ .join(" ");
719
+ console.log(
720
+ ` ${c.characterId}: clipSource=${c.clipSource} maxBonesTouched=${c.maxBonesTouched} ` +
721
+ `maxRot=${c.maxRotAmplitudeRad.toFixed(3)}rad rootΔ=${c.maxRootTranslation.toFixed(3)} | ${movers || "(no body bones moved)"}`
722
+ );
723
+ }
724
+
725
+ // B2 — save a 3-frame (first / mid / final) skeleton-overlay strip PER CHARACTER to the episode
726
+ // dir. The strip visualizes the character's DOMINANT body intent as a bone-projection stick
727
+ // figure across the clip, so the proof shows MOTION as a moving skeleton (not a raw pixel-diff).
728
+ const skeletonDir = resolve(OUTPUT_DIR, "skeleton-overlays");
729
+ mkdirSync(skeletonDir, { recursive: true });
730
+ const skeletonStrips: {
731
+ characterId: string;
732
+ intent: string;
733
+ path: string;
734
+ panelJointCounts: readonly number[];
735
+ firstFinalMaxDiff: number;
736
+ }[] = [];
737
+ for (const c of bodyMotion) {
738
+ // The character's dominant body intent (most-sampled non-idle intent; idle if nothing else).
739
+ const ranked = Object.entries(c.intentCounts).sort((a, b) => b[1] - a[1]);
740
+ const dominant = ranked.find(([id]) => id !== "idle")?.[0] ?? ranked[0]?.[0] ?? "idle";
741
+ try {
742
+ const strip = buildSkeletonStrip({ intent: dominant });
743
+ const png = await rawRgbaToPng(strip.rgba, strip.width, strip.height);
744
+ const stripPath = resolve(skeletonDir, `${c.characterId}.png`);
745
+ writeFileSync(stripPath, png);
746
+ skeletonStrips.push({
747
+ characterId: c.characterId,
748
+ intent: strip.intent,
749
+ path: stripPath,
750
+ panelJointCounts: strip.panelJointCounts,
751
+ firstFinalMaxDiff: strip.firstFinalMaxDiff
752
+ });
753
+ console.log(
754
+ ` skeleton-overlay ${c.characterId}: intent=${strip.intent} joints=${strip.panelJointCounts.join("/")} ` +
755
+ `firstFinalDiff=${strip.firstFinalMaxDiff} -> ${stripPath}`
756
+ );
757
+ } catch (err) {
758
+ console.warn(` skeleton-overlay ${c.characterId}: SKIPPED (${(err as Error).message}).`);
759
+ }
760
+ }
761
+
762
+ const summary = {
763
+ kind: "animation-studio-live-3d-render",
764
+ route: "live-route.html",
765
+ // M5 — output tier the frames/video were rendered at (preview low-fi vs final 1080p/24fps).
766
+ quality: { tier: QUALITY, width: WIDTH, height: HEIGHT, frameRate: FRAME_RATE, deviceScale: DEVICE_SCALE, fullPost: !LOW_FI },
767
+ // I2 — the VERIFIED render mode/style + the honest cel-vs-post-pass contract (one canonical home).
768
+ renderMode: { mode: RENDER_MODE.mode, style: RENDER_MODE.style, celApplies: RENDER_MODE.celApplies, notes: renderModeNotes() },
769
+ framesDir: FRAMES_DIR,
770
+ video: VIDEO_PATH,
771
+ videoBytes: muxedBytes,
772
+ silentVideoBytes: video.byteLength,
773
+ frameRate: FRAME_RATE,
774
+ episodeDuration: EPISODE_DURATION,
775
+ frameCount: captured.length,
776
+ toon: toonInfo,
777
+ ready: readyProof,
778
+ // AuraVoice handoff: the timed dialogue/caption/viseme track Aura3D produces as the
779
+ // synchronization contract AuraVoice consumes to generate the voice after the fact.
780
+ // (Aura3D runs no TTS; the video is silent until AuraVoice muxes its audio.)
781
+ auraVoiceHandoff,
782
+ // Staged performance: per-beat character positions + clips captured during render.
783
+ stagedPerformance: Array.from(stagingByBeat.values()),
784
+ // Burned-in captions: per-beat caption text drawn into the frame pixels
785
+ // with a text-vs-plate contrast measurement (accessibility).
786
+ captionProofs,
787
+ // Per-frame seek proofs: per-shot camera framing + per-character lip-sync state
788
+ // (AuraVoice mouthOpenness, primitive mouth-indicator open height, GLB morph weight).
789
+ seekProofs,
790
+ // Isolated lip-sync A/B proof (same pose + camera, mouth open vs closed).
791
+ mouthProof,
792
+ // B2 — per-character BODY-bone motion summary (key bones rotation ranges + root translation
793
+ // + clipSource), so a downstream gate can measure body motion EXCLUDING mouth/caption/camera.
794
+ bodyMotion,
795
+ // B2 — per-character 3-frame (first/mid/final) skeleton-overlay strips saved to the episode dir.
796
+ skeletonStrips,
797
+ // B1 — full per-character/per-beat clip-decision log (intent/clipId/source/bonesTouched/…).
798
+ clipDecisionLog
799
+ };
800
+ writeFileSync(resolve(OUTPUT_DIR, "render-live-summary.json"), `${JSON.stringify(summary, null, 2)}\n`);
801
+
802
+ console.log("\n--- render-live complete ---");
803
+ console.log(`frames dir: ${FRAMES_DIR}`);
804
+ console.log(`fidelity PNGs: ${FIDELITY_FRAME_IDS.map((id) => `${id}.png`).join(", ")}`);
805
+ console.log(`video: ${VIDEO_PATH} (${muxedBytes} bytes, SILENT — voice owned by AuraVoice)`);
806
+ console.log(`audio: none in-engine; AuraVoice generates voice from the handoff track (${auraVoiceHandoff.note})`);
807
+ console.log(`toon: bands=${toonInfo.bands} outline=${toonInfo.outline} colorGrade=${toonInfo.colorGrade}`);
808
+ }
809
+
810
+ main().catch((error: unknown) => {
811
+ console.error("render-live failed:", error instanceof Error ? error.stack ?? error.message : error);
812
+ process.exitCode = 1;
813
+ });