create-aura3d 1.1.1 → 1.1.2

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 (32) hide show
  1. package/dist/index.js +1 -1
  2. package/package.json +1 -1
  3. package/templates/cartoon-channel/README.md +11 -1
  4. package/templates/cartoon-channel/src/experimental/README.md +24 -0
  5. package/templates/cartoon-channel/src/{concept-episode-2-5d.ts → experimental/concept-episode-2-5d.ts} +2 -2
  6. package/templates/cartoon-channel/src/{image-puppet-episode.ts → experimental/image-puppet-episode.ts} +2 -2
  7. package/templates/cartoon-channel/src/{puppet-episode-2d.ts → experimental/puppet-episode-2d.ts} +2 -2
  8. package/templates/cartoon-studio/ASSET-LICENSES.md +41 -0
  9. package/templates/cartoon-studio/aura.assets.json +321 -78
  10. package/templates/cartoon-studio/live-route.html +12 -0
  11. package/templates/cartoon-studio/package.json +1 -0
  12. package/templates/cartoon-studio/public/aura-assets/luma.authored.glb +0 -0
  13. package/templates/cartoon-studio/public/aura-assets/miko.authored.glb +0 -0
  14. package/templates/cartoon-studio/scripts/build-characters.ts +738 -0
  15. package/templates/cartoon-studio/scripts/build-dialogue-audio.ts +262 -0
  16. package/templates/cartoon-studio/scripts/episode.ts +48 -1
  17. package/templates/cartoon-studio/scripts/render-live.ts +873 -0
  18. package/templates/cartoon-studio/scripts/validate-characters.ts +0 -0
  19. package/templates/cartoon-studio/src/aura-assets.ts +15 -12
  20. package/templates/cartoon-studio/src/main.ts +84 -17
  21. package/templates/cartoon-studio/src/render-live-route.ts +913 -0
  22. package/templates/cartoon-studio/tsconfig.json +3 -1
  23. package/templates/cartoon-studio/vite.config.ts +13 -0
  24. package/templates/episode-builder/README.md +4 -0
  25. package/templates/fighting-game/README.md +16 -0
  26. package/templates/prompt-cartoon-channel/README.md +4 -0
  27. package/templates/cartoon-studio/public/aura-assets/luma.047f5e5f.glb +0 -0
  28. package/templates/cartoon-studio/public/aura-assets/luma.humanoid-fixture.glb +0 -0
  29. package/templates/cartoon-studio/public/aura-assets/miko.047f5e5f.glb +0 -0
  30. /package/templates/cartoon-channel/src/{concept-episode-2-5d.css → experimental/concept-episode-2-5d.css} +0 -0
  31. /package/templates/cartoon-channel/src/{image-puppet-episode.css → experimental/image-puppet-episode.css} +0 -0
  32. /package/templates/cartoon-channel/src/{puppet-episode-2d.css → experimental/puppet-episode-2d.css} +0 -0
@@ -0,0 +1,873 @@
1
+ /**
2
+ * render-live.ts — headless capture + toon post-pass + real video encode for the
3
+ * Cartoon 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 CartoonToonMaterial GLSL mirrors), giving posterized shading,
16
+ * - run the Sobel `outlinePixels` ink pass + `colorGradePixels` storybook
17
+ * grade that `applyCartoonRenderPreset` 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 CartoonToonMaterial +
24
+ * the cartoon 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/cartoon-studio/scripts/render-live.ts
29
+ */
30
+
31
+ import { spawnSync } from "node:child_process";
32
+ import { createRequire } from "node:module";
33
+ import { existsSync, mkdirSync, 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 {
39
+ applyCartoonRenderPreset,
40
+ createCartoonRenderPreset,
41
+ quantizeToonBand
42
+ } from "@aura3d/rendering";
43
+ import { createFfmpegFrameEncoderAdapter } from "@aura3d/engine";
44
+ import { buildDialogueAudioTrack, type DialogueAudioResult } from "./build-dialogue-audio.js";
45
+
46
+ const __dirname = dirname(fileURLToPath(import.meta.url));
47
+ const TEMPLATE_ROOT = resolve(__dirname, "..");
48
+ const OUTPUT_DIR = resolve(TEMPLATE_ROOT, "dist/episodes/live-3d");
49
+ const FRAMES_DIR = resolve(OUTPUT_DIR, "frames");
50
+ const VIDEO_PATH = resolve(OUTPUT_DIR, "episode-3d.webm");
51
+
52
+ const WIDTH = 960;
53
+ const HEIGHT = 540;
54
+ const FRAME_RATE = 12;
55
+
56
+ // 12 captured frames spanning >1s of each clip so the skeletons clearly move.
57
+ // The 4 fidelity-gate frame IDs map onto a spread of these capture times.
58
+ const CAPTURE_TIMES = [0, 0.12, 0.25, 0.4, 0.55, 0.7, 0.85, 1.0, 1.2, 1.45, 1.7, 2.0];
59
+ const FIDELITY_FRAME_IDS = ["first", "dialogue", "action", "final"] as const;
60
+ // Pick spread-out capture indices for the gate's 4 named frames.
61
+ const FIDELITY_CAPTURE_INDEX: Record<(typeof FIDELITY_FRAME_IDS)[number], number> = {
62
+ first: 0,
63
+ dialogue: 3,
64
+ action: 7,
65
+ final: 11
66
+ };
67
+
68
+ interface SharpModule {
69
+ (input: Buffer | Uint8Array, options?: { raw: { width: number; height: number; channels: number } }): {
70
+ png(): { toBuffer(): Promise<Buffer> };
71
+ };
72
+ }
73
+
74
+ function loadSharp(): SharpModule {
75
+ try {
76
+ const require = createRequire(import.meta.url);
77
+ return require("sharp") as SharpModule;
78
+ } catch {
79
+ const storePkg = resolve(process.cwd(), "node_modules/.pnpm/sharp@0.33.5/node_modules/sharp/package.json");
80
+ if (existsSync(storePkg)) {
81
+ const require = createRequire(storePkg);
82
+ return require("sharp") as SharpModule;
83
+ }
84
+ throw new Error("Could not load `sharp` to encode PNGs. Install it: pnpm add -D sharp");
85
+ }
86
+ }
87
+
88
+ const sharp = loadSharp();
89
+
90
+ async function rawRgbaToPng(pixels: Uint8Array, width: number, height: number): Promise<Uint8Array> {
91
+ const buffer = await sharp(Buffer.from(pixels.buffer, pixels.byteOffset, pixels.byteLength), {
92
+ raw: { width, height, channels: 4 }
93
+ })
94
+ .png()
95
+ .toBuffer();
96
+ return new Uint8Array(buffer);
97
+ }
98
+
99
+ /**
100
+ * Band-quantize each pixel toward the cel ramp using the engine's exact
101
+ * `quantizeToonBand` math, then let `applyCartoonRenderPreset` ink the edges and
102
+ * grade the result. Returns the final toon-treated RGBA buffer.
103
+ */
104
+ function applyToonTreatment(pixels: Uint8Array, width: number, height: number): {
105
+ readonly pixels: Uint8Array;
106
+ readonly bands: number;
107
+ readonly outline: boolean;
108
+ readonly colorGrade: boolean;
109
+ } {
110
+ const preset = createCartoonRenderPreset({
111
+ name: "live-3d-toon",
112
+ resolution: { width, height },
113
+ materialStyle: { rampSteps: 4, outline: true, treatment: "cel", saturationBoost: 0.18 }
114
+ });
115
+ const bands = Math.max(2, Math.min(16, Math.round(preset.materialStyle.rampSteps)));
116
+
117
+ // 1. Cel band-quantization on luma (the CartoonToonMaterial ramp), preserving hue.
118
+ const banded = new Uint8Array(pixels.length);
119
+ for (let i = 0; i < pixels.length; i += 4) {
120
+ const r = pixels[i]! / 255;
121
+ const g = pixels[i + 1]! / 255;
122
+ const b = pixels[i + 2]! / 255;
123
+ const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
124
+ const quantized = quantizeToonBand(luma, bands);
125
+ const scale = luma > 1e-4 ? quantized / luma : quantized;
126
+ banded[i] = clampByte(r * scale * 255);
127
+ banded[i + 1] = clampByte(g * scale * 255);
128
+ banded[i + 2] = clampByte(b * scale * 255);
129
+ banded[i + 3] = pixels[i + 3]!;
130
+ }
131
+
132
+ // 2. Outline + storybook color grade via the real preset post-passes.
133
+ const applied = applyCartoonRenderPreset(preset, {
134
+ frame: { pixels: banded, width, height }
135
+ });
136
+ const out = applied.colorGrade?.pixels ?? applied.outline?.pixels ?? banded;
137
+ return {
138
+ pixels: out instanceof Uint8Array ? out : new Uint8Array(out),
139
+ bands,
140
+ outline: applied.appliedToPixels.outline,
141
+ colorGrade: applied.appliedToPixels.colorGrade
142
+ };
143
+ }
144
+
145
+ function clampByte(value: number): number {
146
+ return value < 0 ? 0 : value > 255 ? 255 : Math.round(value);
147
+ }
148
+
149
+ /** Resolve an ffmpeg-family binary (`ffmpeg` / `ffprobe`), preferring the bundled
150
+ * `@ffmpeg-installer`/`ffmpeg-static` if present, else the one on PATH. */
151
+ function resolveFfBinary(name: "ffmpeg" | "ffprobe"): string {
152
+ const require = createRequire(import.meta.url);
153
+ try {
154
+ if (name === "ffmpeg") {
155
+ const installer = require("@ffmpeg-installer/ffmpeg") as { path?: string };
156
+ if (installer.path && existsSync(installer.path)) return installer.path;
157
+ }
158
+ } catch {
159
+ /* fall through to PATH */
160
+ }
161
+ try {
162
+ if (name === "ffmpeg") {
163
+ const ffmpegStatic = require("ffmpeg-static") as string | { default?: string };
164
+ const p = typeof ffmpegStatic === "string" ? ffmpegStatic : ffmpegStatic.default;
165
+ if (p && existsSync(p)) return p;
166
+ }
167
+ } catch {
168
+ /* fall through to PATH */
169
+ }
170
+ return name; // rely on PATH
171
+ }
172
+
173
+ interface AudioMuxResult {
174
+ readonly muxed: boolean;
175
+ readonly audioKind: "placeholder-ambient" | "macos-say-dialogue";
176
+ readonly dialogueAudio: boolean;
177
+ readonly voiceSource?: "macos-say-tts";
178
+ /** Per-line voice + timing table (only for real dialogue). */
179
+ readonly dialogueLines?: {
180
+ readonly lineId: string;
181
+ readonly speakerId: string;
182
+ readonly voice: string;
183
+ readonly text: string;
184
+ readonly startTime: number;
185
+ readonly endTime: number;
186
+ readonly spokenDuration: number;
187
+ }[];
188
+ readonly voices?: Record<string, string>;
189
+ /** Total muxed audio/episode length in seconds (dialogue extends the video). */
190
+ readonly muxedDurationSeconds?: number;
191
+ readonly codec?: string;
192
+ readonly channels?: number;
193
+ readonly sampleRate?: number;
194
+ readonly note: string;
195
+ }
196
+
197
+ /**
198
+ * Mux a short, soft, NON-flashing PLACEHOLDER AMBIENT bed into the silent webm.
199
+ *
200
+ * HONESTY: this is NOT dialogue. There is no TTS in this template, so we synthesize
201
+ * a gentle ambient pad (two low sines + faint filtered noise at low volume) purely
202
+ * to prove the audio-mux path works end-to-end. Real voice requires a TTS step.
203
+ * The bed is steady (no tremolo/strobe) to stay reduced-flash / sensory-safe.
204
+ */
205
+ function muxPlaceholderAmbientAudio(videoPath: string, durationSeconds: number): AudioMuxResult {
206
+ const ffmpeg = resolveFfBinary("ffmpeg");
207
+ const tmpOut = `${videoPath}.muxed.webm`;
208
+ // Soft ambient bed: 174Hz + 220Hz sines + low anoisesrc, mixed and gained down.
209
+ const filter =
210
+ "sine=frequency=174:sample_rate=48000[a];" +
211
+ "sine=frequency=220:sample_rate=48000[b];" +
212
+ "anoisesrc=color=pink:sample_rate=48000:amplitude=0.04[c];" +
213
+ "[a][b]amix=inputs=2:weights=0.6 0.4[tones];" +
214
+ "[tones][c]amix=inputs=2:weights=0.85 0.15,volume=0.08,aformat=sample_fmts=fltp:channel_layouts=stereo[aout]";
215
+ const args = [
216
+ "-y",
217
+ "-i", videoPath,
218
+ "-filter_complex", filter,
219
+ "-map", "0:v:0",
220
+ "-map", "[aout]",
221
+ "-c:v", "copy",
222
+ "-c:a", "libopus",
223
+ "-b:a", "96k",
224
+ "-t", durationSeconds.toFixed(3),
225
+ "-shortest",
226
+ tmpOut
227
+ ];
228
+ const run = spawnSync(ffmpeg, args, { encoding: "utf8" });
229
+ if (run.status !== 0 || !existsSync(tmpOut)) {
230
+ return {
231
+ muxed: false,
232
+ audioKind: "placeholder-ambient",
233
+ dialogueAudio: false,
234
+ note: `ffmpeg audio mux failed (status=${run.status}). stderr: ${(run.stderr ?? "").slice(-400)}`
235
+ };
236
+ }
237
+ rmSync(videoPath, { force: true });
238
+ renameSync(tmpOut, videoPath);
239
+ return {
240
+ muxed: true,
241
+ audioKind: "placeholder-ambient",
242
+ dialogueAudio: false,
243
+ muxedDurationSeconds: durationSeconds,
244
+ note: "Soft synthesized ambient bed muxed via ffmpeg to prove the audio-mux path. NOT dialogue; real voice needs a TTS step."
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Mux the REAL macOS-`say` synthesized dialogue track (episode-length, each line at
250
+ * its dialogue startTime over a faint ambient bed) into the silent webm.
251
+ *
252
+ * The captured video is only a short proof clip, but the dialogue spans the full
253
+ * episode. To keep EVERY spoken line audible at its true timecode, we extend the
254
+ * video to the dialogue/episode length by holding the last frame (`tpad`), then mux
255
+ * the dialogue track as libopus. The result is an episode-length webm whose audio
256
+ * stream carries the genuinely synthesized voices.
257
+ *
258
+ * HONESTY: `say` is real on-device TTS, but a robotic system voice — placeholder-grade
259
+ * VO, not studio voice acting. Labeled `voiceSource: "macos-say-tts"`.
260
+ */
261
+ function muxDialogueAudio(videoPath: string, dialogue: DialogueAudioResult): AudioMuxResult {
262
+ if (!dialogue.available || !dialogue.trackPath || !existsSync(dialogue.trackPath)) {
263
+ return {
264
+ muxed: false,
265
+ audioKind: "macos-say-dialogue",
266
+ dialogueAudio: false,
267
+ note: dialogue.note
268
+ };
269
+ }
270
+ const ffmpeg = resolveFfBinary("ffmpeg");
271
+ const tmpOut = `${videoPath}.dialogue.webm`;
272
+ const target = dialogue.durationSeconds;
273
+ const args = [
274
+ "-y",
275
+ "-i", videoPath,
276
+ "-i", dialogue.trackPath,
277
+ // Hold the final captured frame out to the full episode length so the dialogue
278
+ // track (which spans the whole episode) is not clipped by the short proof clip.
279
+ "-filter_complex", `[0:v]tpad=stop_mode=clone:stop_duration=${target.toFixed(3)}[v]`,
280
+ "-map", "[v]",
281
+ "-map", "1:a:0",
282
+ "-c:v", "libvpx-vp9",
283
+ "-b:v", "0",
284
+ "-crf", "34",
285
+ "-c:a", "libopus",
286
+ "-b:a", "96k",
287
+ "-t", target.toFixed(3),
288
+ tmpOut
289
+ ];
290
+ const run = spawnSync(ffmpeg, args, { encoding: "utf8" });
291
+ if (run.status !== 0 || !existsSync(tmpOut)) {
292
+ return {
293
+ muxed: false,
294
+ audioKind: "macos-say-dialogue",
295
+ dialogueAudio: false,
296
+ voiceSource: dialogue.voiceSource,
297
+ voices: dialogue.voices,
298
+ dialogueLines: dialogue.lines,
299
+ note: `ffmpeg dialogue mux failed (status=${run.status}). stderr: ${(run.stderr ?? "").slice(-500)}`
300
+ };
301
+ }
302
+ rmSync(videoPath, { force: true });
303
+ renameSync(tmpOut, videoPath);
304
+ // The standalone dialogue WAV is kept alongside the webm as an audio stem.
305
+ return {
306
+ muxed: true,
307
+ audioKind: "macos-say-dialogue",
308
+ dialogueAudio: true,
309
+ voiceSource: dialogue.voiceSource,
310
+ voices: dialogue.voices,
311
+ dialogueLines: dialogue.lines,
312
+ muxedDurationSeconds: target,
313
+ note: dialogue.note
314
+ };
315
+ }
316
+
317
+ /** ffprobe the muxed file and return the first audio stream's codec/channels/rate. */
318
+ function probeAudioStream(videoPath: string): { codec?: string; channels?: number; sampleRate?: number; raw: string } {
319
+ const ffprobe = resolveFfBinary("ffprobe");
320
+ const run = spawnSync(
321
+ ffprobe,
322
+ ["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name,channels,sample_rate", "-of", "default=noprint_wrappers=1", videoPath],
323
+ { encoding: "utf8" }
324
+ );
325
+ const out = run.stdout ?? "";
326
+ const codec = /codec_name=(\S+)/.exec(out)?.[1];
327
+ const channels = Number(/channels=(\d+)/.exec(out)?.[1]);
328
+ const sampleRate = Number(/sample_rate=(\d+)/.exec(out)?.[1]);
329
+ return {
330
+ ...(codec ? { codec } : {}),
331
+ ...(Number.isFinite(channels) ? { channels } : {}),
332
+ ...(Number.isFinite(sampleRate) ? { sampleRate } : {}),
333
+ raw: out.trim()
334
+ };
335
+ }
336
+
337
+ // Monorepo root (…/aura3d). Resolved from this script's location so the dev
338
+ // server can alias `@aura3d/*` to the freshly-built `dist/` (same approach the
339
+ // working aura-clash showcase uses). The template's own published `@aura3d/engine`
340
+ // (v1.1.0) has a stricter/older material binder that rejects unbound PBR env-map
341
+ // textures, so we resolve the in-repo build that the renderer is validated against.
342
+ const MONOREPO_ROOT = resolve(TEMPLATE_ROOT, "../../../..");
343
+ const DIST = (p: string): string => resolve(MONOREPO_ROOT, "dist", p);
344
+
345
+ async function startViteServer(): Promise<ViteDevServer> {
346
+ const distBuildExists = existsSync(DIST("engine/advanced-runtime/index.js"));
347
+ const server = await createServer({
348
+ root: TEMPLATE_ROOT,
349
+ configFile: false,
350
+ ...(distBuildExists
351
+ ? {
352
+ resolve: {
353
+ alias: [
354
+ { find: /^@aura3d\/engine$/, replacement: DIST("engine/agent-api/index.js") },
355
+ { find: /^@aura3d\/engine\/advanced-runtime$/, replacement: DIST("engine/advanced-runtime/index.js") },
356
+ { find: /^@aura3d\/engine\/production-runtime$/, replacement: DIST("engine/production-runtime/index.js") },
357
+ { find: /^@aura3d\/engine\/assets\/browser$/, replacement: DIST("assets/browser-index.js") },
358
+ { find: /^@aura3d\/engine\/rendering$/, replacement: DIST("rendering/index.js") },
359
+ { find: /^@aura3d\/engine\/scene$/, replacement: DIST("scene/index.js") },
360
+ { find: /^@aura3d\/rendering$/, replacement: DIST("rendering/index.js") },
361
+ { find: /^@aura3d\/scene$/, replacement: DIST("scene/index.js") },
362
+ { find: /^@aura3d\/assets\/browser$/, replacement: DIST("assets/browser-index.js") }
363
+ ]
364
+ }
365
+ }
366
+ : {}),
367
+ server: { host: "127.0.0.1", port: 0 },
368
+ logLevel: "warn"
369
+ });
370
+ await server.listen();
371
+ return server;
372
+ }
373
+
374
+ interface CapturedFrame {
375
+ readonly time: number;
376
+ readonly raw: Uint8Array;
377
+ }
378
+
379
+ const seekProofs: unknown[] = [];
380
+
381
+ interface SeekReadResult {
382
+ readonly proof: unknown;
383
+ readonly raw: Uint8Array;
384
+ }
385
+
386
+ interface CaptionBurnResult {
387
+ /** Caption text actually drawn into the frame. */
388
+ readonly text: string;
389
+ /** Mean luma (0..1) of the caption plate region BEFORE the text was drawn. */
390
+ readonly backgroundLuma: number;
391
+ /** Luma (0..1) of the caption text fill. */
392
+ readonly textLuma: number;
393
+ /** WCAG-style contrast ratio (1..21) between text and its plate background. */
394
+ readonly contrastRatio: number;
395
+ }
396
+
397
+ /** Drive the route's seek hook at `time` (with optional mouth override), BURN the
398
+ * active caption text into the captured frame (so the exported video carries
399
+ * visible captions, not just a DOM overlay), and read back raw RGBA bytes. Returns
400
+ * the caption-contrast measurement so the script can log/verify it. */
401
+ async function seekAndReadPixels(
402
+ page: import("@playwright/test").Page,
403
+ time: number,
404
+ options?: { mouthOverride?: number; burnCaption?: boolean }
405
+ ): Promise<SeekReadResult & { caption?: CaptionBurnResult }> {
406
+ const burnCaption = options?.burnCaption !== false;
407
+ const result = await page.evaluate(
408
+ ({ t, w, h, opts, burn }) => {
409
+ const win = window as unknown as {
410
+ __auraSeek__: (time: number, options?: { mouthOverride?: number }) => { caption?: { text?: string } };
411
+ __AURA_LIVE_SEEK_LAST__?: unknown;
412
+ };
413
+ const proof = win.__auraSeek__(t, { mouthOverride: opts?.mouthOverride });
414
+ win.__AURA_LIVE_SEEK_LAST__ = proof;
415
+ const canvas = document.querySelector("#live-canvas") as HTMLCanvasElement;
416
+ // Copy the WebGL canvas into a 2D canvas to read back stable RGBA bytes.
417
+ const copy = document.createElement("canvas");
418
+ copy.width = w;
419
+ copy.height = h;
420
+ const ctx = copy.getContext("2d")!;
421
+ ctx.drawImage(canvas, 0, 0, w, h);
422
+
423
+ // ---- BURNED-IN CAPTION (accessibility) ----
424
+ // Draw the active caption text from the seek proof onto the captured frame so
425
+ // the exported video has visible captions baked into the pixels.
426
+ let caption: CaptionBurnResult | undefined;
427
+ const text = (proof.caption?.text ?? "").trim();
428
+ if (burn && text.length > 0) {
429
+ // Bottom-center high-contrast rounded plate (matches the episode captionStyle).
430
+ const fontPx = Math.round(h * 0.042);
431
+ ctx.font = `600 ${fontPx}px -apple-system, "Segoe UI", Roboto, sans-serif`;
432
+ ctx.textAlign = "center";
433
+ ctx.textBaseline = "middle";
434
+ const metrics = ctx.measureText(text);
435
+ const padX = 22;
436
+ const padY = 12;
437
+ const plateW = Math.min(w - 24, metrics.width + padX * 2);
438
+ const plateH = fontPx + padY * 2;
439
+ const cx = w / 2;
440
+ const plateY = h - plateH - 20;
441
+ const plateX = cx - plateW / 2;
442
+
443
+ // Measure the plate-region background luma BEFORE drawing (contrast check).
444
+ // sRGB->linear relative luminance is inlined (no nested fns: tsx/esbuild's
445
+ // keepNames helper is not available inside page.evaluate).
446
+ const bg = ctx.getImageData(plateX, plateY, Math.max(1, plateW), Math.max(1, plateH)).data;
447
+ let bgSum = 0;
448
+ for (let i = 0; i < bg.length; i += 4) {
449
+ const sr = bg[i]! / 255;
450
+ const sg = bg[i + 1]! / 255;
451
+ const sb = bg[i + 2]! / 255;
452
+ const lr = sr <= 0.03928 ? sr / 12.92 : Math.pow((sr + 0.055) / 1.055, 2.4);
453
+ const lg = sg <= 0.03928 ? sg / 12.92 : Math.pow((sg + 0.055) / 1.055, 2.4);
454
+ const lb = sb <= 0.03928 ? sb / 12.92 : Math.pow((sb + 0.055) / 1.055, 2.4);
455
+ bgSum += 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
456
+ }
457
+ const backgroundLuma = bgSum / (bg.length / 4);
458
+
459
+ // Dark rounded plate so high-contrast white text reads over any scene pixels.
460
+ ctx.fillStyle = "rgba(6, 10, 22, 0.82)";
461
+ const r = 14;
462
+ ctx.beginPath();
463
+ ctx.moveTo(plateX + r, plateY);
464
+ ctx.arcTo(plateX + plateW, plateY, plateX + plateW, plateY + plateH, r);
465
+ ctx.arcTo(plateX + plateW, plateY + plateH, plateX, plateY + plateH, r);
466
+ ctx.arcTo(plateX, plateY + plateH, plateX, plateY, r);
467
+ ctx.arcTo(plateX, plateY, plateX + plateW, plateY, r);
468
+ ctx.closePath();
469
+ ctx.fill();
470
+
471
+ // White caption text with a subtle dark stroke for edge contrast.
472
+ ctx.lineWidth = Math.max(2, fontPx * 0.12);
473
+ ctx.strokeStyle = "rgba(0, 0, 0, 0.85)";
474
+ ctx.strokeText(text, cx, plateY + plateH / 2);
475
+ ctx.fillStyle = "rgba(248, 255, 242, 1)"; // #f8fff2 from the episode palette
476
+ ctx.fillText(text, cx, plateY + plateH / 2);
477
+
478
+ // Contrast is text (#f8fff2, near-white) vs the dark plate. Both luminances
479
+ // are computed inline; the near-white text over a near-black plate yields a
480
+ // very high WCAG ratio (well past AA 4.5:1).
481
+ const textLuma = 0.2126 * Math.pow((248 / 255 + 0.055) / 1.055, 2.4) +
482
+ 0.7152 * Math.pow((255 / 255 + 0.055) / 1.055, 2.4) +
483
+ 0.0722 * Math.pow((242 / 255 + 0.055) / 1.055, 2.4);
484
+ // Plate is rgba(6,10,22) at 0.82 over the (dark) scene; approximate as the
485
+ // plate color itself (near-black), whose linear luminance is ~0.
486
+ const pr = (6 / 255) <= 0.03928 ? (6 / 255) / 12.92 : Math.pow((6 / 255 + 0.055) / 1.055, 2.4);
487
+ const pg = (10 / 255) <= 0.03928 ? (10 / 255) / 12.92 : Math.pow((10 / 255 + 0.055) / 1.055, 2.4);
488
+ const pb = (22 / 255) <= 0.03928 ? (22 / 255) / 12.92 : Math.pow((22 / 255 + 0.055) / 1.055, 2.4);
489
+ const plateLuma = 0.2126 * pr + 0.7152 * pg + 0.0722 * pb;
490
+ const contrastRatio = (Math.max(textLuma, plateLuma) + 0.05) / (Math.min(textLuma, plateLuma) + 0.05);
491
+ caption = { text, backgroundLuma, textLuma, contrastRatio };
492
+ }
493
+
494
+ const data = ctx.getImageData(0, 0, w, h).data;
495
+ return { proof, pixels: Array.from(data), caption };
496
+ },
497
+ { t: time, w: WIDTH, h: HEIGHT, opts: options ?? {}, burn: burnCaption }
498
+ );
499
+ return {
500
+ proof: result.proof,
501
+ raw: Uint8Array.from(result.pixels as number[]),
502
+ ...(result.caption ? { caption: result.caption } : {})
503
+ };
504
+ }
505
+
506
+ async function main(): Promise<void> {
507
+ mkdirSync(FRAMES_DIR, { recursive: true });
508
+
509
+ const server = await startViteServer();
510
+ const address = server.httpServer?.address();
511
+ if (!address || typeof address === "string") {
512
+ throw new Error("Vite dev server did not expose a numeric port.");
513
+ }
514
+ const url = `http://127.0.0.1:${address.port}/live-route.html`;
515
+ console.log(`vite dev server: ${url}`);
516
+
517
+ const browser = await chromium.launch({
518
+ args: ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swdecoder", "--ignore-gpu-blocklist"]
519
+ });
520
+ const captured: CapturedFrame[] = [];
521
+ // Per-beat caption-contrast measurements (text vs plate background) for the summary.
522
+ const captionProofs: {
523
+ time: number;
524
+ shotId: string;
525
+ text: string;
526
+ backgroundLuma: number;
527
+ textLuma: number;
528
+ contrastRatio: number;
529
+ burnedIntoFrame: boolean;
530
+ }[] = [];
531
+ // Per-beat staged-position record (proves the body moves between beats in pixels).
532
+ const stagingByBeat = new Map<string, { shotId: string; time: number; characters: { id: string; position: number[]; clip: string; sweeping: boolean }[] }>();
533
+ let readyProof: unknown;
534
+ let mouthProof: {
535
+ time: number;
536
+ shot: unknown;
537
+ changedPixels: number;
538
+ meanRgbDiff: number;
539
+ openProof: unknown;
540
+ closedProof: unknown;
541
+ } | undefined;
542
+ try {
543
+ const page = await browser.newPage({ viewport: { width: WIDTH + 40, height: HEIGHT + 40 }, deviceScaleFactor: 1 });
544
+ page.on("console", (msg) => {
545
+ if (msg.type() === "error") console.error(`[page] ${msg.text()}`);
546
+ });
547
+ page.on("pageerror", (err) => console.error(`[pageerror] ${err.message}`));
548
+
549
+ await page.addInitScript(() => {
550
+ (window as unknown as { __AURA_LIVE_ROUTE_HEADLESS__: boolean }).__AURA_LIVE_ROUTE_HEADLESS__ = true;
551
+ });
552
+ await page.goto(url, { waitUntil: "load", timeout: 60_000 });
553
+
554
+ // Wait until the route has loaded both skinned GLBs and exposed the seek hook.
555
+ await page.waitForFunction(
556
+ () => {
557
+ const w = window as unknown as { __AURA_LIVE_ROUTE_READY__?: unknown; __AURA_LIVE_ROUTE_ERROR__?: string };
558
+ if (w.__AURA_LIVE_ROUTE_ERROR__) throw new Error(`route error: ${w.__AURA_LIVE_ROUTE_ERROR__}`);
559
+ return Boolean(w.__AURA_LIVE_ROUTE_READY__);
560
+ },
561
+ { timeout: 60_000 }
562
+ );
563
+ readyProof = await page.evaluate(() => (window as unknown as { __AURA_LIVE_ROUTE_READY__: unknown }).__AURA_LIVE_ROUTE_READY__);
564
+ console.log("route ready:", JSON.stringify(readyProof));
565
+
566
+ for (const time of CAPTURE_TIMES) {
567
+ const result = await seekAndReadPixels(page, time);
568
+ const raw = result.raw;
569
+ captured.push({ time, raw });
570
+ seekProofs.push(result.proof);
571
+ const proof = result.proof as {
572
+ drawCalls: number;
573
+ skinnedRenderItems: number;
574
+ shot?: { shotId: string; presetId: string; cameraPosition: number[] };
575
+ caption?: { text: string };
576
+ characters?: {
577
+ id: string;
578
+ position?: number[];
579
+ sweeping?: boolean;
580
+ clip?: string;
581
+ mouthOpenness: number;
582
+ primitiveMouthOpen: number;
583
+ mouthMorphWeight: number;
584
+ }[];
585
+ };
586
+ const miko = proof.characters?.find((c) => c.id === "miko");
587
+ // Record staging once per beat (first capture that lands in each shot).
588
+ const shotId = proof.shot?.shotId ?? "unknown";
589
+ if (!stagingByBeat.has(shotId)) {
590
+ stagingByBeat.set(shotId, {
591
+ shotId,
592
+ time,
593
+ characters: (proof.characters ?? []).map((c) => ({
594
+ id: c.id,
595
+ position: c.position ?? [],
596
+ clip: c.clip ?? "",
597
+ sweeping: Boolean(c.sweeping)
598
+ }))
599
+ });
600
+ }
601
+ // Record caption-contrast (one per distinct caption text).
602
+ if (result.caption && !captionProofs.some((c) => c.text === result.caption!.text)) {
603
+ captionProofs.push({
604
+ time,
605
+ shotId,
606
+ text: result.caption.text,
607
+ backgroundLuma: result.caption.backgroundLuma,
608
+ textLuma: result.caption.textLuma,
609
+ contrastRatio: result.caption.contrastRatio,
610
+ burnedIntoFrame: true
611
+ });
612
+ }
613
+ console.log(
614
+ `captured t=${time.toFixed(2)}s draw=${proof.drawCalls} skinned=${proof.skinnedRenderItems} ` +
615
+ `cam=${proof.shot?.presetId}@[${proof.shot?.cameraPosition.map((v) => v.toFixed(1)).join(",")}] ` +
616
+ `miko@[${miko?.position?.map((v) => v.toFixed(2)).join(",")}] clip=${miko?.clip} sweep=${miko?.sweeping} ` +
617
+ `mouthOpen=${miko?.mouthOpenness.toFixed(2)} primitiveOpen=${miko?.primitiveMouthOpen.toFixed(3)} ` +
618
+ `caption="${(result.caption?.text ?? proof.caption?.text ?? "").slice(0, 36)}" ` +
619
+ `${result.caption ? `contrast=${result.caption.contrastRatio.toFixed(1)}:1` : ""}`
620
+ );
621
+ }
622
+
623
+ // CAPTION + STAGING CONSOLE SUMMARY.
624
+ console.log("\n--- staged performance (per beat) ---");
625
+ for (const beat of stagingByBeat.values()) {
626
+ const m = beat.characters.find((c) => c.id === "miko");
627
+ console.log(` ${beat.shotId}: miko@[${m?.position.map((v) => v.toFixed(2)).join(",")}] clip=${m?.clip} sweeping=${m?.sweeping}`);
628
+ }
629
+ console.log("--- burned-in caption contrast (text vs plate) ---");
630
+ for (const c of captionProofs) {
631
+ console.log(` ${c.shotId}: contrast=${c.contrastRatio.toFixed(1)}:1 (WCAG AA>=4.5) "${c.text.slice(0, 48)}"`);
632
+ }
633
+
634
+ // ISOLATED LIP-SYNC A/B PROOF: render the SAME pose + SAME close-up camera with
635
+ // the mouth forced fully open vs fully closed, so the ONLY difference between the
636
+ // two frames is the mouth indicator. This proves the lip-sync alone moves pixels
637
+ // (the per-frame captures above also vary skeleton/camera, which would otherwise
638
+ // confound an isolated mouth measurement).
639
+ const MOUTH_PROOF_TIME = 1.7; // close-up shot (miko fills frame)
640
+ const mouthClosed = await seekAndReadPixels(page, MOUTH_PROOF_TIME, { mouthOverride: 0, burnCaption: false });
641
+ const mouthOpen = await seekAndReadPixels(page, MOUTH_PROOF_TIME, { mouthOverride: 1, burnCaption: false });
642
+ const closedPng = await rawRgbaToPng(applyToonTreatment(mouthClosed.raw, WIDTH, HEIGHT).pixels, WIDTH, HEIGHT);
643
+ const openPng = await rawRgbaToPng(applyToonTreatment(mouthOpen.raw, WIDTH, HEIGHT).pixels, WIDTH, HEIGHT);
644
+ writeFileSync(resolve(FRAMES_DIR, "mouth-closed.png"), closedPng);
645
+ writeFileSync(resolve(FRAMES_DIR, "mouth-open.png"), openPng);
646
+ let changedPixels = 0;
647
+ let totalDiff = 0;
648
+ for (let i = 0; i < mouthClosed.raw.length; i += 4) {
649
+ const d =
650
+ Math.abs(mouthClosed.raw[i]! - mouthOpen.raw[i]!) +
651
+ Math.abs(mouthClosed.raw[i + 1]! - mouthOpen.raw[i + 1]!) +
652
+ Math.abs(mouthClosed.raw[i + 2]! - mouthOpen.raw[i + 2]!);
653
+ totalDiff += d;
654
+ if (d > 30) changedPixels += 1;
655
+ }
656
+ mouthProof = {
657
+ time: MOUTH_PROOF_TIME,
658
+ shot: (mouthOpen.proof as { shot?: unknown }).shot,
659
+ changedPixels,
660
+ meanRgbDiff: totalDiff / (mouthClosed.raw.length / 4) / 3,
661
+ openProof: (mouthOpen.proof as { characters?: unknown }).characters,
662
+ closedProof: (mouthClosed.proof as { characters?: unknown }).characters
663
+ };
664
+ console.log(
665
+ `\nlip-sync A/B (same pose+camera, mouth open vs closed): changedPixels=${changedPixels} ` +
666
+ `meanRgbDiff=${mouthProof.meanRgbDiff.toFixed(3)} -> frames mouth-open.png / mouth-closed.png`
667
+ );
668
+ } finally {
669
+ await browser.close();
670
+ await server.close();
671
+ }
672
+
673
+ if (captured.length === 0) throw new Error("No frames were captured.");
674
+
675
+ // STAGED-POSITION PIXEL PROOF: diff the captured raw frame at the OPENING beat vs
676
+ // the TEAMWORK (sweep) beat. Because miko crosses to the broom and plays the sweep
677
+ // stand-in clip, these frames must differ substantially in pixels (not just world
678
+ // coordinates). We measure over the full frame AND over a vertical band where
679
+ // miko's body sits so the staged move is provable from the bytes alone.
680
+ const findCapture = (shotId: string): CapturedFrame | undefined => {
681
+ const beat = stagingByBeat.get(shotId);
682
+ return beat ? captured.find((f) => f.time === beat.time) : undefined;
683
+ };
684
+ const openFrame = findCapture("shot-moon-garden-open");
685
+ const teamworkFrame = findCapture("shot-glow-stone-teamwork");
686
+ let stagedPositionProof:
687
+ | {
688
+ openBeat: string;
689
+ teamworkBeat: string;
690
+ openTime: number;
691
+ teamworkTime: number;
692
+ changedPixelRatio: number;
693
+ meanRgbDiff: number;
694
+ mikoOpenPosition: number[];
695
+ mikoTeamworkPosition: number[];
696
+ positionDelta: number;
697
+ positionDiffersAcrossBeats: boolean;
698
+ }
699
+ | undefined;
700
+ if (openFrame && teamworkFrame) {
701
+ let changed = 0;
702
+ let total = 0;
703
+ const pxCount = Math.min(openFrame.raw.length, teamworkFrame.raw.length) / 4;
704
+ for (let i = 0; i < pxCount * 4; i += 4) {
705
+ const d =
706
+ Math.abs(openFrame.raw[i]! - teamworkFrame.raw[i]!) +
707
+ Math.abs(openFrame.raw[i + 1]! - teamworkFrame.raw[i + 1]!) +
708
+ Math.abs(openFrame.raw[i + 2]! - teamworkFrame.raw[i + 2]!);
709
+ total += d;
710
+ if (d > 30) changed += 1;
711
+ }
712
+ const openStage = stagingByBeat.get("shot-moon-garden-open")?.characters.find((c) => c.id === "miko");
713
+ const teamStage = stagingByBeat.get("shot-glow-stone-teamwork")?.characters.find((c) => c.id === "miko");
714
+ const op = openStage?.position ?? [];
715
+ const tp = teamStage?.position ?? [];
716
+ const positionDelta =
717
+ op.length === 3 && tp.length === 3 ? Math.hypot(op[0]! - tp[0]!, op[1]! - tp[1]!, op[2]! - tp[2]!) : 0;
718
+ const changedPixelRatio = changed / pxCount;
719
+ stagedPositionProof = {
720
+ openBeat: "shot-moon-garden-open",
721
+ teamworkBeat: "shot-glow-stone-teamwork",
722
+ openTime: openFrame.time,
723
+ teamworkTime: teamworkFrame.time,
724
+ changedPixelRatio,
725
+ meanRgbDiff: total / pxCount / 3,
726
+ mikoOpenPosition: op,
727
+ mikoTeamworkPosition: tp,
728
+ positionDelta,
729
+ // Provable in BOTH pixels (frames differ) AND world coords (miko crossed to broom).
730
+ positionDiffersAcrossBeats: changedPixelRatio > 0.02 && positionDelta > 0.1
731
+ };
732
+ console.log(
733
+ `\nstaged-position pixel proof (open vs teamwork sweep beat): changedPixelRatio=${(changedPixelRatio * 100).toFixed(1)}% ` +
734
+ `meanRgbDiff=${stagedPositionProof.meanRgbDiff.toFixed(2)} mikoMoved=${positionDelta.toFixed(2)}u ` +
735
+ `=> positionDiffersAcrossBeats=${stagedPositionProof.positionDiffersAcrossBeats}`
736
+ );
737
+ }
738
+
739
+ // Toon-treat every frame; save the 4 fidelity-gate PNGs; collect PNG bytes for video.
740
+ const encoder = await createFfmpegFrameEncoderAdapter({ codec: "vp9", container: "webm", frameRate: FRAME_RATE });
741
+ let toonInfo = { bands: 0, outline: false, colorGrade: false };
742
+ const fidelityByIndex = new Map<number, (typeof FIDELITY_FRAME_IDS)[number]>();
743
+ for (const id of FIDELITY_FRAME_IDS) fidelityByIndex.set(FIDELITY_CAPTURE_INDEX[id], id);
744
+
745
+ for (let index = 0; index < captured.length; index += 1) {
746
+ const frame = captured[index]!;
747
+ const treated = applyToonTreatment(frame.raw, WIDTH, HEIGHT);
748
+ toonInfo = { bands: treated.bands, outline: treated.outline, colorGrade: treated.colorGrade };
749
+ const png = await rawRgbaToPng(treated.pixels, WIDTH, HEIGHT);
750
+
751
+ const fidelityId = fidelityByIndex.get(index);
752
+ if (fidelityId) {
753
+ writeFileSync(resolve(FRAMES_DIR, `${fidelityId}.png`), png);
754
+ }
755
+
756
+ encoder.encode({
757
+ frame: index,
758
+ time: frame.time,
759
+ viewport: { width: WIDTH, height: HEIGHT },
760
+ image: png
761
+ });
762
+ }
763
+
764
+ const durationSeconds = captured.length / FRAME_RATE;
765
+ const finalized = await encoder.finalize({
766
+ codec: "vp9",
767
+ container: "webm",
768
+ mimeType: "video/webm; codecs=vp9",
769
+ frameRate: FRAME_RATE,
770
+ viewport: { width: WIDTH, height: HEIGHT },
771
+ frameCount: captured.length,
772
+ duration: durationSeconds,
773
+ byteLength: 0,
774
+ chunks: []
775
+ });
776
+ if (!(finalized instanceof Uint8Array) || finalized.byteLength === 0) {
777
+ throw new Error(`ffmpeg produced no usable video bytes (got ${typeof finalized}).`);
778
+ }
779
+ const video = finalized;
780
+ writeFileSync(VIDEO_PATH, video);
781
+
782
+ // REAL DIALOGUE AUDIO (macOS `say` TTS) with graceful degrade to placeholder ambient.
783
+ // 1. Try to synthesize the episode's dialogue lines with `say` (distinct voice per
784
+ // character) and assemble an episode-length track (each line at its startTime over
785
+ // a faint ambient bed). 2. If `say` is available, mux that real dialogue (extending
786
+ // the video to the episode length so all lines survive). 3. Otherwise fall back to
787
+ // the soft placeholder ambient bed. Then ffprobe to confirm a real audio stream.
788
+ const dialogue = buildDialogueAudioTrack(OUTPUT_DIR);
789
+ let audioMux: AudioMuxResult;
790
+ if (dialogue.available) {
791
+ console.log(
792
+ `\ndialogue audio: synthesized ${dialogue.lines.length} lines via macOS \`say\` ` +
793
+ `(voices: ${Object.entries(dialogue.voices).map(([k, v]) => `${k}=${v}`).join(", ")})`
794
+ );
795
+ audioMux = muxDialogueAudio(VIDEO_PATH, dialogue);
796
+ if (!audioMux.muxed) {
797
+ console.warn(`dialogue mux failed, falling back to placeholder ambient. ${audioMux.note}`);
798
+ audioMux = muxPlaceholderAmbientAudio(VIDEO_PATH, durationSeconds);
799
+ }
800
+ } else {
801
+ console.log(`\ndialogue audio: macOS \`say\` unavailable — falling back to placeholder ambient.`);
802
+ audioMux = muxPlaceholderAmbientAudio(VIDEO_PATH, durationSeconds);
803
+ }
804
+ const audioProbe = audioMux.muxed ? probeAudioStream(VIDEO_PATH) : { raw: "" };
805
+ const audio = {
806
+ ...audioMux,
807
+ ...(audioProbe.codec ? { codec: audioProbe.codec } : {}),
808
+ ...(audioProbe.channels ? { channels: audioProbe.channels } : {}),
809
+ ...(audioProbe.sampleRate ? { sampleRate: audioProbe.sampleRate } : {}),
810
+ ffprobe: audioProbe.raw
811
+ };
812
+ const muxedBytes = audioMux.muxed ? statSync(VIDEO_PATH).size : video.byteLength;
813
+ console.log(
814
+ `\naudio mux: muxed=${audioMux.muxed} kind=${audioMux.audioKind} dialogue=${audioMux.dialogueAudio} ` +
815
+ `codec=${audioProbe.codec ?? "(none)"} ch=${audioProbe.channels ?? "?"} rate=${audioProbe.sampleRate ?? "?"}`
816
+ );
817
+
818
+ const summary = {
819
+ kind: "cartoon-studio-live-3d-render",
820
+ route: "live-route.html",
821
+ framesDir: FRAMES_DIR,
822
+ video: VIDEO_PATH,
823
+ videoBytes: muxedBytes,
824
+ silentVideoBytes: video.byteLength,
825
+ frameRate: FRAME_RATE,
826
+ captureTimes: CAPTURE_TIMES,
827
+ toon: toonInfo,
828
+ ready: readyProof,
829
+ // Phase 2 — REAL dialogue audio via macOS `say` TTS (distinct voice per character,
830
+ // each line at its dialogue startTime over a faint ambient bed), muxed as libopus
831
+ // with ffprobe confirmation. Gracefully degrades to placeholder ambient off-mac.
832
+ audio,
833
+ // Phase 2 — staged performance: miko crosses to the broom + sweep stand-in clip,
834
+ // proven in pixels (open-beat vs teamwork-beat frame diff) AND world coords.
835
+ stagedPositionProof,
836
+ stagedPerformance: Array.from(stagingByBeat.values()),
837
+ // Phase 2 — burned-in captions: per-beat caption text drawn into the frame pixels
838
+ // with a text-vs-plate contrast measurement (accessibility).
839
+ captionProofs,
840
+ // Per-frame seek proofs: per-shot camera framing + per-character lip-sync state
841
+ // (AuraVoice mouthOpenness, primitive mouth-indicator open height, GLB morph weight).
842
+ seekProofs,
843
+ // Isolated lip-sync A/B proof (same pose + camera, mouth open vs closed).
844
+ mouthProof
845
+ };
846
+ writeFileSync(resolve(OUTPUT_DIR, "render-live-summary.json"), `${JSON.stringify(summary, null, 2)}\n`);
847
+
848
+ console.log("\n--- render-live complete ---");
849
+ console.log(`frames dir: ${FRAMES_DIR}`);
850
+ console.log(`fidelity PNGs: ${FIDELITY_FRAME_IDS.map((id) => `${id}.png`).join(", ")}`);
851
+ console.log(`video: ${VIDEO_PATH} (${muxedBytes} bytes${audioMux.muxed ? `, audio: ${audioProbe.codec}` : ", NO audio"})`);
852
+ console.log(
853
+ `audio: kind=${audio.audioKind} dialogueAudio=${audio.dialogueAudio}` +
854
+ (audio.dialogueAudio
855
+ ? ` voiceSource=${audio.voiceSource} lines=${audio.dialogueLines?.length ?? 0} (REAL macOS-say TTS — robotic, placeholder-grade VO)`
856
+ : " (placeholder ambient, not dialogue)")
857
+ );
858
+ if (audio.dialogueAudio && audio.dialogueLines) {
859
+ console.log("--- per-line voice + timing (spoken dialogue) ---");
860
+ for (const l of audio.dialogueLines) {
861
+ console.log(
862
+ ` ${l.lineId}: voice=${l.voice} speaker=${l.speakerId} start=${l.startTime}s ` +
863
+ `spoken=${l.spokenDuration.toFixed(2)}s "${l.text.slice(0, 40)}"`
864
+ );
865
+ }
866
+ }
867
+ console.log(`toon: bands=${toonInfo.bands} outline=${toonInfo.outline} colorGrade=${toonInfo.colorGrade}`);
868
+ }
869
+
870
+ main().catch((error: unknown) => {
871
+ console.error("render-live failed:", error instanceof Error ? error.stack ?? error.message : error);
872
+ process.exitCode = 1;
873
+ });