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,262 @@
1
+ /**
2
+ * build-dialogue-audio.ts — REAL synthesized dialogue audio for the Cartoon Studio
3
+ * episode using the macOS built-in `say` TTS engine.
4
+ *
5
+ * HONESTY: `say` is a real, on-device text-to-speech synthesizer (this is a darwin
6
+ * machine). The spoken bytes below are GENUINELY synthesized from the episode's
7
+ * dialogue lines — not silence, not a placeholder tone. They ARE, however, robotic
8
+ * "system voice" quality (placeholder-grade VO), NOT studio voice acting. We label
9
+ * the result `voiceSource: "macos-say-tts"` so nobody mistakes it for finished VO.
10
+ *
11
+ * What this does, per dialogue line (read from the compiled episode dialogueTrack):
12
+ * 1. `say -v <voice> -o line.aiff "<text>"` — a distinct voice per character
13
+ * (miko = a higher/youthful voice, luma = a different adult voice).
14
+ * 2. Place each line's audio at its real dialogue `startTime` on an episode-length
15
+ * timeline via ffmpeg `adelay` (+ `apad` to the full episode duration).
16
+ * 3. Mix all delayed lines together with a faint, steady (non-flashing) ambient
17
+ * bed underneath, producing one episode-length dialogue track.
18
+ *
19
+ * GRACEFUL DEGRADE: if `say` is unavailable (non-mac / CI), this returns
20
+ * `{ available: false }` and the caller falls back to the placeholder ambient bed.
21
+ *
22
+ * This module is imported by `render-live.ts`; it can also be run standalone
23
+ * (`tsx scripts/build-dialogue-audio.ts`) to (re)build just the audio track.
24
+ */
25
+
26
+ import { spawnSync } from "node:child_process";
27
+ import { createRequire } from "node:module";
28
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
29
+ import { dirname, resolve } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import { episode } from "../src/episode.js";
32
+
33
+ const __dirname = dirname(fileURLToPath(import.meta.url));
34
+ const TEMPLATE_ROOT = resolve(__dirname, "..");
35
+
36
+ /** Distinct macOS `say` voices per character. Picked from `say -v '?'` (en_US):
37
+ * - miko: "Junior" — a younger, higher system voice (matches the hero kid robot).
38
+ * - luma: "Samantha" — a clearer adult system voice (the helper robot).
39
+ * Both are standard en_US voices present on a default macOS install. */
40
+ export const DIALOGUE_VOICE_BY_SPEAKER: Record<string, string> = {
41
+ miko: "Junior",
42
+ luma: "Samantha"
43
+ };
44
+ const FALLBACK_VOICE = "Samantha";
45
+
46
+ export interface DialogueLineAudio {
47
+ readonly lineId: string;
48
+ readonly speakerId: string;
49
+ readonly voice: string;
50
+ readonly text: string;
51
+ readonly startTime: number;
52
+ readonly endTime: number;
53
+ /** Actual synthesized clip duration (seconds), measured from the rendered file. */
54
+ readonly spokenDuration: number;
55
+ }
56
+
57
+ export interface DialogueAudioResult {
58
+ readonly available: boolean;
59
+ /** Absolute path to the assembled episode-length dialogue track (WAV), if built. */
60
+ readonly trackPath?: string;
61
+ readonly durationSeconds: number;
62
+ readonly sampleRate: number;
63
+ readonly voiceSource: "macos-say-tts";
64
+ readonly voices: Record<string, string>;
65
+ readonly lines: DialogueLineAudio[];
66
+ readonly note: string;
67
+ }
68
+
69
+ const SAMPLE_RATE = 48_000;
70
+
71
+ function resolveFfmpeg(): string {
72
+ const require = createRequire(import.meta.url);
73
+ try {
74
+ const installer = require("@ffmpeg-installer/ffmpeg") as { path?: string };
75
+ if (installer.path && existsSync(installer.path)) return installer.path;
76
+ } catch {
77
+ /* fall through */
78
+ }
79
+ try {
80
+ const ffmpegStatic = require("ffmpeg-static") as string | { default?: string };
81
+ const p = typeof ffmpegStatic === "string" ? ffmpegStatic : ffmpegStatic.default;
82
+ if (p && existsSync(p)) return p;
83
+ } catch {
84
+ /* fall through */
85
+ }
86
+ return "ffmpeg";
87
+ }
88
+
89
+ function resolveFfprobe(): string {
90
+ return "ffprobe";
91
+ }
92
+
93
+ /** Is the macOS `say` TTS binary usable on this host? */
94
+ export function isSayAvailable(): boolean {
95
+ if (process.platform !== "darwin") return false;
96
+ const probe = spawnSync("say", ["-v", "?"], { encoding: "utf8" });
97
+ return probe.status === 0 && (probe.stdout ?? "").length > 0;
98
+ }
99
+
100
+ function probeDuration(ffprobe: string, file: string): number {
101
+ const run = spawnSync(
102
+ ffprobe,
103
+ ["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", file],
104
+ { encoding: "utf8" }
105
+ );
106
+ const d = Number((run.stdout ?? "").trim());
107
+ return Number.isFinite(d) ? d : 0;
108
+ }
109
+
110
+ /**
111
+ * Synthesize every dialogue line with `say` and assemble one episode-length WAV with
112
+ * each line placed at its real `startTime`, plus a faint ambient bed underneath.
113
+ * Returns `{ available: false }` (no throw) when `say` is missing so callers degrade.
114
+ */
115
+ export function buildDialogueAudioTrack(outputDir: string): DialogueAudioResult {
116
+ const voices = DIALOGUE_VOICE_BY_SPEAKER;
117
+ const durationSeconds = episode.dialogueTrack.duration;
118
+ const trackLines = episode.dialogueTrack.lines;
119
+
120
+ if (!isSayAvailable()) {
121
+ return {
122
+ available: false,
123
+ durationSeconds,
124
+ sampleRate: SAMPLE_RATE,
125
+ voiceSource: "macos-say-tts",
126
+ voices,
127
+ lines: [],
128
+ note: "macOS `say` TTS not available on this host; caller should fall back to placeholder ambient."
129
+ };
130
+ }
131
+
132
+ const ffmpeg = resolveFfmpeg();
133
+ const ffprobe = resolveFfprobe();
134
+ const workDir = resolve(outputDir, "dialogue-audio");
135
+ mkdirSync(workDir, { recursive: true });
136
+
137
+ const lineResults: DialogueLineAudio[] = [];
138
+ const wavSegments: string[] = [];
139
+
140
+ for (const line of trackLines) {
141
+ const voice = voices[line.speakerId] ?? FALLBACK_VOICE;
142
+ const aiff = resolve(workDir, `${line.lineId.replace(/[^a-zA-Z0-9_-]/g, "_")}.aiff`);
143
+ const wav = resolve(workDir, `${line.lineId.replace(/[^a-zA-Z0-9_-]/g, "_")}.wav`);
144
+
145
+ // 1. REAL TTS: synthesize the line to AIFF with the character's distinct voice.
146
+ const say = spawnSync("say", ["-v", voice, "-o", aiff, line.text], { encoding: "utf8" });
147
+ if (say.status !== 0 || !existsSync(aiff)) {
148
+ throw new Error(`say failed for ${line.lineId} (voice=${voice}): ${(say.stderr ?? "").slice(-300)}`);
149
+ }
150
+ const spokenDuration = probeDuration(ffprobe, aiff);
151
+
152
+ // 2. Normalize each clip to a stereo 48k WAV at the episode sample rate, gained up
153
+ // a touch so dialogue sits clearly above the ambient bed.
154
+ const toWav = spawnSync(
155
+ ffmpeg,
156
+ [
157
+ "-y",
158
+ "-i", aiff,
159
+ "-ar", String(SAMPLE_RATE),
160
+ "-ac", "2",
161
+ "-af", "volume=1.6,aformat=sample_fmts=s16:channel_layouts=stereo",
162
+ wav
163
+ ],
164
+ { encoding: "utf8" }
165
+ );
166
+ if (toWav.status !== 0 || !existsSync(wav)) {
167
+ throw new Error(`ffmpeg AIFF->WAV failed for ${line.lineId}: ${(toWav.stderr ?? "").slice(-300)}`);
168
+ }
169
+
170
+ wavSegments.push(wav);
171
+ lineResults.push({
172
+ lineId: line.lineId,
173
+ speakerId: line.speakerId,
174
+ voice,
175
+ text: line.text,
176
+ startTime: line.startTime,
177
+ endTime: line.endTime,
178
+ spokenDuration
179
+ });
180
+ }
181
+
182
+ // 3. Build one episode-length mix: each line delayed to its startTime (adelay, ms),
183
+ // padded to the full episode duration (apad), then amix'd with a faint, STEADY
184
+ // ambient bed (two low sines + low pink noise, volume far below dialogue). The bed
185
+ // is non-tremolo so it stays reduced-flash / sensory-safe.
186
+ const trackPath = resolve(outputDir, "episode-dialogue.wav");
187
+ const inputs: string[] = [];
188
+ const filterParts: string[] = [];
189
+ const mixLabels: string[] = [];
190
+
191
+ wavSegments.forEach((seg, i) => {
192
+ inputs.push("-i", seg);
193
+ const delayMs = Math.round(lineResults[i]!.startTime * 1000);
194
+ // adelay both channels; apad to full length so amix keeps the full timeline.
195
+ filterParts.push(
196
+ `[${i}:a]adelay=${delayMs}|${delayMs},apad=whole_dur=${durationSeconds},aformat=sample_fmts=fltp:channel_layouts=stereo[d${i}]`
197
+ );
198
+ mixLabels.push(`[d${i}]`);
199
+ });
200
+
201
+ // Faint steady ambient bed generated inline (no extra input files).
202
+ const bedIndex = wavSegments.length;
203
+ filterParts.push(
204
+ `sine=frequency=174:sample_rate=${SAMPLE_RATE}:duration=${durationSeconds}[bedA]`,
205
+ `sine=frequency=220:sample_rate=${SAMPLE_RATE}:duration=${durationSeconds}[bedB]`,
206
+ `anoisesrc=color=pink:sample_rate=${SAMPLE_RATE}:amplitude=0.03:duration=${durationSeconds}[bedN]`,
207
+ `[bedA][bedB]amix=inputs=2:weights=0.6 0.4[bedTones]`,
208
+ `[bedTones][bedN]amix=inputs=2:weights=0.85 0.15,volume=0.05,aformat=sample_fmts=fltp:channel_layouts=stereo[bed]`
209
+ );
210
+ mixLabels.push("[bed]");
211
+
212
+ // Dialogue lines at full weight, ambient bed faint underneath. normalize=0 keeps
213
+ // dialogue from being attenuated when a line overlaps the bed.
214
+ const mixCount = mixLabels.length;
215
+ const dialogueWeights = lineResults.map(() => "1").join(" ");
216
+ const filter =
217
+ filterParts.join(";") +
218
+ `;${mixLabels.join("")}amix=inputs=${mixCount}:weights=${dialogueWeights} 0.4:normalize=0:duration=longest,` +
219
+ `volume=1.0,aformat=sample_fmts=s16:channel_layouts=stereo[out]`;
220
+
221
+ const assemble = spawnSync(
222
+ ffmpeg,
223
+ [
224
+ "-y",
225
+ ...inputs,
226
+ "-filter_complex", filter,
227
+ "-map", "[out]",
228
+ "-ar", String(SAMPLE_RATE),
229
+ "-t", durationSeconds.toFixed(3),
230
+ trackPath
231
+ ],
232
+ { encoding: "utf8" }
233
+ );
234
+ if (assemble.status !== 0 || !existsSync(trackPath)) {
235
+ throw new Error(`ffmpeg dialogue assembly failed (status=${assemble.status}): ${(assemble.stderr ?? "").slice(-600)}`);
236
+ }
237
+
238
+ // Clean up per-line temp files; keep the assembled track.
239
+ rmSync(workDir, { recursive: true, force: true });
240
+
241
+ return {
242
+ available: true,
243
+ trackPath,
244
+ durationSeconds,
245
+ sampleRate: SAMPLE_RATE,
246
+ voiceSource: "macos-say-tts",
247
+ voices,
248
+ lines: lineResults,
249
+ note:
250
+ "REAL synthesized dialogue via macOS `say` TTS, one distinct voice per character, " +
251
+ "each line placed at its episode dialogue startTime over a faint steady ambient bed. " +
252
+ "Honest caveat: `say` is robotic system-voice (placeholder-grade VO), not studio voice acting."
253
+ };
254
+ }
255
+
256
+ // Allow standalone invocation: `tsx scripts/build-dialogue-audio.ts [outDir]`.
257
+ if (import.meta.url === `file://${process.argv[1]}`) {
258
+ const outDir = resolve(process.argv[2] ?? resolve(TEMPLATE_ROOT, "dist/episodes/live-3d"));
259
+ mkdirSync(outDir, { recursive: true });
260
+ const result = buildDialogueAudioTrack(outDir);
261
+ console.log(JSON.stringify(result, null, 2));
262
+ }
@@ -58,7 +58,10 @@ async function writePackage(mode: CartoonEpisodePackageMode, options: { only?: R
58
58
  }
59
59
  }
60
60
 
61
- if (!options.only && (mode === "render" || mode === "package")) await renderEpisodeMedia(pkg);
61
+ if (!options.only && (mode === "render" || mode === "package")) {
62
+ await renderEpisodeMedia(pkg);
63
+ await verifyEncodedOutputs(pkg);
64
+ }
62
65
  if (!options.only && mode === "package") await captureRouteThumbnail(pkg);
63
66
  if (!options.only) await writeChecksumManifest(pkg);
64
67
  console.log(JSON.stringify({
@@ -101,6 +104,50 @@ async function writeChecksumManifest(pkg: CartoonEpisodePackageBuild) {
101
104
  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
102
105
  }
103
106
 
107
+ // After encoding, rewrite the encoded-video claims in the package JSON so they are
108
+ // VERIFIED facts (true only when episode.webm actually exists at a real size) instead
109
+ // of the hardcoded `true` the artifact builder emits before encoding runs.
110
+ async function verifyEncodedOutputs(pkg: CartoonEpisodePackageBuild) {
111
+ const webmPath = path.join(pkg.packageDirectory, "episode.webm");
112
+ const webmExists = existsSync(webmPath);
113
+ const webmBytes = webmExists ? (await stat(webmPath)).size : 0;
114
+ const realWebm = webmExists && webmBytes > 32_768;
115
+
116
+ await patchPackageJson(path.join(pkg.packageDirectory, "render-manifest.json"), (m) => {
117
+ m.hasEncodedVideo = realWebm;
118
+ if (m.encodedVideo && typeof m.encodedVideo === "object") {
119
+ (m.encodedVideo as Record<string, unknown>).verified = realWebm;
120
+ (m.encodedVideo as Record<string, unknown>).byteLength = webmBytes;
121
+ }
122
+ });
123
+ await patchPackageJson(path.join(pkg.packageDirectory, "visual-acceptance.json"), (m) => {
124
+ m.encodedVideoPresent = realWebm;
125
+ const checks = m.checks;
126
+ if (Array.isArray(checks)) {
127
+ const check = checks.find((c) => (c as Record<string, unknown>)?.id === "real-encoded-video") as Record<string, unknown> | undefined;
128
+ if (check) {
129
+ check.passed = realWebm;
130
+ check.evidence = { ...(check.evidence as Record<string, unknown>), byteLength: webmBytes, verified: realWebm };
131
+ }
132
+ }
133
+ });
134
+ await patchPackageJson(path.join(pkg.packageDirectory, "metadata.json"), (m) => {
135
+ const boundary = m.outputBoundary as Record<string, unknown> | undefined;
136
+ if (boundary) boundary.webmPresent = realWebm;
137
+ });
138
+ await patchPackageJson(path.join(pkg.packageDirectory, "prompt-animation-evidence.json"), (m) => {
139
+ const renderOutput = m.renderOutput as Record<string, unknown> | undefined;
140
+ if (renderOutput) renderOutput.encodedVideoPresent = realWebm;
141
+ });
142
+ }
143
+
144
+ async function patchPackageJson(filePath: string, patch: (data: Record<string, unknown>) => void) {
145
+ if (!existsSync(filePath)) return;
146
+ const data = JSON.parse(await readFile(filePath, "utf8")) as Record<string, unknown>;
147
+ patch(data);
148
+ await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
149
+ }
150
+
104
151
  async function renderEpisodeMedia(pkg: CartoonEpisodePackageBuild) {
105
152
  const frameDirectory = path.join(pkg.packageDirectory, "frames");
106
153
  const imageTool = executable("sips") ?? executable("magick") ?? executable("convert");