@gtanczyk/diorama 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +157 -2
- package/app/bin/diorama.mjs +13 -0
- package/app/src/actions/budget.ts +160 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +267 -0
- package/app/src/actions/depth.ts +95 -0
- package/app/src/actions/extend.ts +36 -0
- package/app/src/actions/faces.ts +160 -0
- package/app/src/actions/feedback.ts +87 -0
- package/app/src/actions/fix.ts +382 -0
- package/app/src/actions/history.ts +93 -0
- package/app/src/actions/idea-preview.ts +65 -0
- package/app/src/actions/idea-review.ts +102 -0
- package/app/src/actions/idea-sketch.ts +172 -0
- package/app/src/actions/index.ts +32 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/language.ts +52 -0
- package/app/src/actions/models.ts +64 -0
- package/app/src/actions/motion.ts +119 -0
- package/app/src/actions/pictures.ts +49 -0
- package/app/src/actions/reactions.ts +36 -0
- package/app/src/actions/registry.ts +101 -0
- package/app/src/actions/share.ts +32 -0
- package/app/src/actions/sound.ts +73 -0
- package/app/src/actions/start.ts +154 -0
- package/app/src/actions/step-work.ts +150 -0
- package/app/src/actions/story.ts +101 -0
- package/app/src/actions/studio.ts +133 -0
- package/app/src/actions/style.ts +72 -0
- package/app/src/actions/video.ts +46 -0
- package/app/src/actions/view.ts +109 -0
- package/app/src/actions/weather.ts +38 -0
- package/app/src/actions/world.ts +290 -0
- package/app/src/commands/action.ts +39 -0
- package/app/src/commands/backdrop.ts +188 -0
- package/app/src/commands/browsers.ts +21 -0
- package/app/src/commands/build.ts +503 -0
- package/app/src/commands/cast.ts +161 -0
- package/app/src/commands/clip.ts +431 -0
- package/app/src/commands/derive.ts +167 -0
- package/app/src/commands/doctor.ts +74 -0
- package/app/src/commands/explore.ts +123 -0
- package/app/src/commands/export.ts +158 -0
- package/app/src/commands/extend.ts +75 -0
- package/app/src/commands/faces.ts +80 -0
- package/app/src/commands/icons.ts +69 -0
- package/app/src/commands/index.ts +37 -0
- package/app/src/commands/language.ts +87 -0
- package/app/src/commands/layout.ts +31 -0
- package/app/src/commands/masks.ts +117 -0
- package/app/src/commands/mcp.ts +52 -0
- package/app/src/commands/model.ts +284 -0
- package/app/src/commands/paint.ts +182 -0
- package/app/src/commands/plates.ts +97 -0
- package/app/src/commands/play.ts +195 -0
- package/app/src/commands/playthrough.ts +111 -0
- package/app/src/commands/publish.ts +78 -0
- package/app/src/commands/reactions.ts +40 -0
- package/app/src/commands/reel.ts +46 -0
- package/app/src/commands/setup.ts +85 -0
- package/app/src/commands/sound.ts +100 -0
- package/app/src/commands/sprites.ts +232 -0
- package/app/src/commands/story.ts +37 -0
- package/app/src/commands/studio.ts +65 -0
- package/app/src/commands/style.ts +87 -0
- package/app/src/commands/verify.ts +45 -0
- package/app/src/commands/voice-retry.ts +14 -0
- package/app/src/commands/voices.ts +145 -0
- package/app/src/commands/weather.ts +52 -0
- package/app/src/commands/world.ts +104 -0
- package/app/src/film/clip.ts +386 -0
- package/app/src/film/reel.ts +126 -0
- package/app/src/film/screencast.ts +123 -0
- package/app/src/image/mask.ts +29 -0
- package/app/src/image/overlay.ts +38 -0
- package/app/src/image/raster.ts +224 -0
- package/app/src/main.ts +28 -0
- package/app/src/meta/choice.ts +27 -0
- package/app/src/meta/http.ts +99 -0
- package/app/src/meta/memo.ts +105 -0
- package/app/src/meta/muse.ts +70 -0
- package/app/src/meta/openrouter.ts +93 -0
- package/app/src/meta/prices.ts +25 -0
- package/app/src/meta/sam.ts +83 -0
- package/app/src/meta/schema.ts +63 -0
- package/app/src/meta/spark.ts +220 -0
- package/app/src/meta/step.ts +14 -0
- package/app/src/meta/voice-file.ts +78 -0
- package/app/src/meta/voice.ts +103 -0
- package/app/src/model/animation.ts +38 -0
- package/app/src/model/blender/relax.py +284 -0
- package/app/src/model/blender.ts +115 -0
- package/app/src/model/build.ts +50 -0
- package/app/src/model/face.ts +21 -0
- package/app/src/model/fit.ts +181 -0
- package/app/src/model/glb.ts +241 -0
- package/app/src/model/plan.ts +151 -0
- package/app/src/model/quality.ts +48 -0
- package/app/src/model/space.ts +168 -0
- package/app/src/model/sprites.ts +293 -0
- package/app/src/model/views.ts +163 -0
- package/app/src/play/creature.ts +43 -0
- package/app/src/play/depth-occlusion.ts +150 -0
- package/app/src/play/exit-outline.ts +48 -0
- package/app/src/play/face3d.ts +34 -0
- package/app/src/play/focus.ts +78 -0
- package/app/src/play/geometry.ts +272 -0
- package/app/src/play/gestures.ts +23 -0
- package/app/src/play/glow.ts +187 -0
- package/app/src/play/lipsync.ts +69 -0
- package/app/src/play/living.ts +83 -0
- package/app/src/play/outline-path.ts +130 -0
- package/app/src/play/parallax.ts +200 -0
- package/app/src/play/path.ts +262 -0
- package/app/src/play/preview.ts +71 -0
- package/app/src/play/reactions.ts +92 -0
- package/app/src/play/rig.ts +172 -0
- package/app/src/play/shadow.ts +253 -0
- package/app/src/play/sound.ts +51 -0
- package/app/src/play/weather.ts +58 -0
- package/app/src/qa/explore.ts +136 -0
- package/app/src/qa/probe.ts +139 -0
- package/app/src/qa/route.ts +118 -0
- package/app/src/registry.ts +23 -0
- package/app/src/scene/animation-source.ts +36 -0
- package/app/src/scene/assets.ts +37 -0
- package/app/src/scene/autofix.ts +133 -0
- package/app/src/scene/backdrop.ts +326 -0
- package/app/src/scene/blockin.ts +189 -0
- package/app/src/scene/brief.ts +74 -0
- package/app/src/scene/carry.ts +177 -0
- package/app/src/scene/cast-fixes.ts +33 -0
- package/app/src/scene/cast.ts +199 -0
- package/app/src/scene/corrections.ts +164 -0
- package/app/src/scene/critic.ts +169 -0
- package/app/src/scene/depthmap.ts +149 -0
- package/app/src/scene/derive.ts +475 -0
- package/app/src/scene/diagnose.ts +132 -0
- package/app/src/scene/face-calibration.ts +65 -0
- package/app/src/scene/face-registration.ts +21 -0
- package/app/src/scene/face-work.ts +71 -0
- package/app/src/scene/faces-data.ts +48 -0
- package/app/src/scene/faces-settings.ts +20 -0
- package/app/src/scene/faces.ts +435 -0
- package/app/src/scene/glows.ts +153 -0
- package/app/src/scene/held.ts +109 -0
- package/app/src/scene/icons.ts +185 -0
- package/app/src/scene/layers.ts +822 -0
- package/app/src/scene/layout.ts +216 -0
- package/app/src/scene/locate.ts +118 -0
- package/app/src/scene/maskcheck.ts +43 -0
- package/app/src/scene/masks.ts +392 -0
- package/app/src/scene/occluders.ts +123 -0
- package/app/src/scene/occlusion-check.ts +138 -0
- package/app/src/scene/paint-layout.ts +135 -0
- package/app/src/scene/paint.ts +81 -0
- package/app/src/scene/photo.ts +123 -0
- package/app/src/scene/plates.ts +256 -0
- package/app/src/scene/playbook.ts +121 -0
- package/app/src/scene/preview.ts +23 -0
- package/app/src/scene/reactions.ts +118 -0
- package/app/src/scene/restyle.ts +53 -0
- package/app/src/scene/rig-check.ts +68 -0
- package/app/src/scene/rig.ts +379 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +215 -0
- package/app/src/scene/stairs.ts +183 -0
- package/app/src/scene/status.ts +32 -0
- package/app/src/scene/verify.ts +156 -0
- package/app/src/scene/walk.ts +326 -0
- package/app/src/scene/warp.ts +419 -0
- package/app/src/scene/weather.ts +118 -0
- package/app/src/scene/words.ts +65 -0
- package/app/src/secrets.ts +219 -0
- package/app/src/setup/checks.ts +110 -0
- package/app/src/sound/cost.ts +74 -0
- package/app/src/sound/data.ts +89 -0
- package/app/src/sound/fixes.ts +23 -0
- package/app/src/sound/issues.ts +21 -0
- package/app/src/sound/language.ts +11 -0
- package/app/src/sound/lipsync-make.ts +98 -0
- package/app/src/sound/lipsync.ts +24 -0
- package/app/src/sound/make.ts +229 -0
- package/app/src/sound/musicgen.py +20 -0
- package/app/src/sound/pcm.ts +280 -0
- package/app/src/sound/plan.ts +154 -0
- package/app/src/sound/retry.ts +43 -0
- package/app/src/sound/settings.ts +28 -0
- package/app/src/sound/sources.ts +81 -0
- package/app/src/sound/spoken.ts +70 -0
- package/app/src/sound/voices.ts +579 -0
- package/app/src/speech/transcribe.ts +38 -0
- package/app/src/speech/types.ts +18 -0
- package/app/src/story/check.ts +230 -0
- package/app/src/story/design.ts +108 -0
- package/app/src/story/engine.ts +233 -0
- package/app/src/story/fixes.ts +54 -0
- package/app/src/story/npc-guidance.ts +7 -0
- package/app/src/story/phrases.ts +4 -0
- package/app/src/story/schema.ts +92 -0
- package/app/src/studio/agent.ts +555 -0
- package/app/src/studio/discovery.ts +28 -0
- package/app/src/studio/events.ts +48 -0
- package/app/src/studio/history.ts +124 -0
- package/app/src/studio/jobs.ts +291 -0
- package/app/src/studio/locks.ts +33 -0
- package/app/src/studio/mcp.ts +86 -0
- package/app/src/studio/operation.ts +18 -0
- package/app/src/studio/repair.ts +31 -0
- package/app/src/studio/server.ts +423 -0
- package/app/src/studio/trouble.ts +48 -0
- package/app/src/world/design.ts +72 -0
- package/app/src/world/drawings.ts +198 -0
- package/app/src/world/extend-stage.ts +109 -0
- package/app/src/world/extend.ts +123 -0
- package/app/src/world/extension-progress.ts +10 -0
- package/app/src/world/files.ts +62 -0
- package/app/src/world/idea-review.ts +32 -0
- package/app/src/world/language.ts +116 -0
- package/app/src/world/photo-limits.ts +2 -0
- package/app/src/world/pool.ts +41 -0
- package/app/src/world/schedule.ts +64 -0
- package/app/src/world/schema.ts +74 -0
- package/app/src/world/sketch.ts +81 -0
- package/app/src/world/style-plan.ts +90 -0
- package/app/src/world/style-reference.ts +16 -0
- package/app/src/world/styled.ts +51 -0
- package/app/src/world/told.ts +105 -0
- package/app/src/world/video-limits.ts +3 -0
- package/app/src/world/video.ts +98 -0
- package/app/studio/activity.tsx +124 -0
- package/app/studio/agent.tsx +384 -0
- package/app/studio/api.ts +111 -0
- package/app/studio/atelier.tsx +234 -0
- package/app/studio/audio-missing.tsx +25 -0
- package/app/studio/audio-setup.tsx +33 -0
- package/app/studio/blender-setup.tsx +30 -0
- package/app/studio/build.tsx +679 -0
- package/app/studio/camera.tsx +59 -0
- package/app/studio/cast-work.tsx +207 -0
- package/app/studio/cast.tsx +178 -0
- package/app/studio/concept-edit.tsx +47 -0
- package/app/studio/creator-idea.tsx +46 -0
- package/app/studio/custom-look.tsx +36 -0
- package/app/studio/faces-setup.tsx +28 -0
- package/app/studio/favicon.svg +11 -0
- package/app/studio/figure.tsx +314 -0
- package/app/studio/fonts/OFL.txt +93 -0
- package/app/studio/fonts/fonts.css +7 -0
- package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
- package/app/studio/icons.tsx +42 -0
- package/app/studio/idea-card.tsx +42 -0
- package/app/studio/idea-review.tsx +181 -0
- package/app/studio/idea-storyboard.tsx +134 -0
- package/app/studio/index.html +15 -0
- package/app/studio/job-words.ts +33 -0
- package/app/studio/lightbox.tsx +49 -0
- package/app/studio/main.tsx +1329 -0
- package/app/studio/nav.ts +14 -0
- package/app/studio/place.tsx +264 -0
- package/app/studio/review-sketch.tsx +47 -0
- package/app/studio/say-it.tsx +49 -0
- package/app/studio/step-work.tsx +134 -0
- package/app/studio/story.tsx +394 -0
- package/app/studio/style-menu.tsx +81 -0
- package/app/studio/style.css +1390 -0
- package/app/studio/sure.tsx +54 -0
- package/app/studio/tip.tsx +74 -0
- package/app/studio/tsconfig.json +5 -0
- package/app/studio/video.tsx +73 -0
- package/app/studio/world.tsx +188 -0
- package/app/tsconfig.json +13 -0
- package/app/web/audio.ts +315 -0
- package/app/web/face.ts +131 -0
- package/app/web/face3d.ts +35 -0
- package/app/web/glows.ts +387 -0
- package/app/web/hero3d.ts +728 -0
- package/app/web/index.html +118 -0
- package/app/web/living.ts +404 -0
- package/app/web/main.ts +3769 -0
- package/app/web/pad.ts +174 -0
- package/app/web/reactions.ts +79 -0
- package/app/web/sheet.ts +36 -0
- package/app/web/synth.ts +140 -0
- package/app/web/tsconfig.json +5 -0
- package/app/web/ui.ts +263 -0
- package/app/web/weather.ts +306 -0
- package/app/web/words.ts +12 -0
- package/docs/characters.md +58 -0
- package/docs/npc-motion.md +69 -0
- package/docs/scenes.md +75 -0
- package/docs/setup.md +60 -0
- package/docs/sound.md +23 -0
- package/docs/studio.md +62 -0
- package/package.json +70 -4
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Keys typed into `diorama setup` are kept encrypted on disk, never as plain text and never in a file the
|
|
2
|
+
// repo or a world could pick up. A random 32-byte master key lives in the OS vault (macOS Keychain via
|
|
3
|
+
// `security`, Linux Secret Service via `secret-tool`); the secrets themselves are AES-256-GCM sealed in
|
|
4
|
+
// ~/.config/diorama/credentials.bin (0600). Environment variables always win over stored keys.
|
|
5
|
+
import { execFile, spawn } from 'node:child_process';
|
|
6
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
/** The keys diorama knows how to keep, with the other names each one is accepted under. */
|
|
12
|
+
export const SECRETS = {
|
|
13
|
+
META_API_KEY: { label: 'Meta Model API key', aliases: ['META_MUSE_API_KEY', 'META_MODEL_API_KEY'], required: true },
|
|
14
|
+
ELEVENLABS_API_KEY: { label: 'ElevenLabs key', aliases: ['ELEVEN_API_KEY'], required: false },
|
|
15
|
+
HF_TOKEN: { label: 'Hugging Face token', aliases: ['HUGGING_FACE_API_KEY'], required: false },
|
|
16
|
+
// For DIORAMA_SPARK_VIA=claude. Its own name first, so setting it never switches Claude Code itself to API billing.
|
|
17
|
+
DIORAMA_ANTHROPIC_API_KEY: { label: 'Anthropic API key (Spark via Claude)', aliases: ['ANTHROPIC_API_KEY', 'CLAUDE_API_KEY'], required: false },
|
|
18
|
+
OPENROUTER_API_KEY: { label: 'OpenRouter key (Gemini pictures, other Spark models)', aliases: [], required: false },
|
|
19
|
+
GEMINI_API_KEY: { label: 'Google Gemini key (Gemini pictures straight from Google)', aliases: ['GOOGLE_API_KEY'], required: false },
|
|
20
|
+
} as const;
|
|
21
|
+
export type SecretName = keyof typeof SECRETS;
|
|
22
|
+
export const SECRET_NAMES = Object.keys(SECRETS) as SecretName[];
|
|
23
|
+
|
|
24
|
+
/** Holds the master key. */
|
|
25
|
+
export interface Vault {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
get(): Promise<Buffer | null>;
|
|
28
|
+
set(key: Buffer): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SERVICE = 'diorama';
|
|
32
|
+
const ACCOUNT = 'credentials-key';
|
|
33
|
+
|
|
34
|
+
function run(cmd: string, args: string[], input?: string): Promise<{ code: number; out: string }> {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
37
|
+
let out = '';
|
|
38
|
+
child.stdout.on('data', (d) => (out += d));
|
|
39
|
+
child.on('error', () => resolve({ code: -1, out: '' }));
|
|
40
|
+
child.on('close', (code) => resolve({ code: code ?? -1, out }));
|
|
41
|
+
child.stdin.end(input ?? '');
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function onPath(cmd: string): Promise<boolean> {
|
|
46
|
+
return new Promise((resolve) => execFile('/bin/sh', ['-c', `command -v ${cmd}`], (error) => resolve(!error)));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** macOS Keychain. The key goes in through `security -i` on stdin, so it never shows up in a process list. */
|
|
50
|
+
const keychain: Vault = {
|
|
51
|
+
name: 'macOS Keychain',
|
|
52
|
+
async get() {
|
|
53
|
+
const { code, out } = await run('security', ['find-generic-password', '-s', SERVICE, '-a', ACCOUNT, '-w']);
|
|
54
|
+
return code === 0 && out.trim() ? Buffer.from(out.trim(), 'hex') : null;
|
|
55
|
+
},
|
|
56
|
+
async set(key) {
|
|
57
|
+
const { code } = await run('security', ['-i'], `add-generic-password -U -s ${SERVICE} -a ${ACCOUNT} -l "diorama credentials" -w ${key.toString('hex')}\n`);
|
|
58
|
+
if (code !== 0) throw new Error('could not write the diorama key to the macOS Keychain');
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Linux Secret Service (GNOME Keyring, KWallet); `secret-tool store` reads the value from stdin. */
|
|
63
|
+
const secretService: Vault = {
|
|
64
|
+
name: 'Secret Service',
|
|
65
|
+
async get() {
|
|
66
|
+
const { code, out } = await run('secret-tool', ['lookup', 'service', SERVICE, 'account', ACCOUNT]);
|
|
67
|
+
return code === 0 && out.trim() ? Buffer.from(out.trim(), 'hex') : null;
|
|
68
|
+
},
|
|
69
|
+
async set(key) {
|
|
70
|
+
const { code } = await run('secret-tool', ['store', '--label=diorama credentials', 'service', SERVICE, 'account', ACCOUNT], key.toString('hex'));
|
|
71
|
+
if (code !== 0) throw new Error('could not write the diorama key to the Secret Service (is a keyring unlocked?)');
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Opt-in only: the master key in a 0600 file next to the secrets. Anyone who can read your home can read both. */
|
|
76
|
+
export function fileVault(dir: string): Vault {
|
|
77
|
+
const file = path.join(dir, 'credentials.key');
|
|
78
|
+
return {
|
|
79
|
+
name: 'key file (DIORAMA_ALLOW_FILE_KEYCHAIN)',
|
|
80
|
+
async get() {
|
|
81
|
+
return fs.existsSync(file) ? Buffer.from(fs.readFileSync(file, 'utf8').trim(), 'hex') : null;
|
|
82
|
+
},
|
|
83
|
+
async set(key) {
|
|
84
|
+
writeAtomic(file, key.toString('hex'));
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function configDir(): string {
|
|
90
|
+
if (process.env.DIORAMA_CONFIG_DIR) return path.resolve(process.env.DIORAMA_CONFIG_DIR);
|
|
91
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'diorama');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The vault this machine offers, or null when there is none and the file fallback was not asked for. */
|
|
95
|
+
export async function findVault(dir = configDir()): Promise<Vault | null> {
|
|
96
|
+
if (process.platform === 'darwin' && (await onPath('security'))) return keychain;
|
|
97
|
+
if (process.platform === 'linux' && (await onPath('secret-tool'))) return secretService;
|
|
98
|
+
if (process.env.DIORAMA_ALLOW_FILE_KEYCHAIN === 'true') return fileVault(dir);
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const NO_VAULT = 'No system keychain found (macOS Keychain, or secret-tool on Linux). Set the key as an environment variable instead, '
|
|
103
|
+
+ 'or run with DIORAMA_ALLOW_FILE_KEYCHAIN=true to keep it in a key file under ~/.config/diorama (weaker: anyone who can read your home can read it).';
|
|
104
|
+
|
|
105
|
+
function writeAtomic(file: string, data: string | Buffer) {
|
|
106
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
107
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
108
|
+
fs.writeFileSync(tmp, data, { mode: 0o600 });
|
|
109
|
+
fs.renameSync(tmp, file);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const MAGIC = Buffer.from('DIO1');
|
|
113
|
+
|
|
114
|
+
export function seal(key: Buffer, plain: Record<string, string>): Buffer {
|
|
115
|
+
const iv = randomBytes(12);
|
|
116
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
117
|
+
cipher.setAAD(MAGIC);
|
|
118
|
+
const body = Buffer.concat([cipher.update(JSON.stringify(plain), 'utf8'), cipher.final()]);
|
|
119
|
+
return Buffer.concat([MAGIC, iv, cipher.getAuthTag(), body]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function open(key: Buffer, sealed: Buffer): Record<string, string> {
|
|
123
|
+
if (!sealed.subarray(0, 4).equals(MAGIC)) throw new Error('credentials.bin is not a diorama credentials file');
|
|
124
|
+
const decipher = createDecipheriv('aes-256-gcm', key, sealed.subarray(4, 16));
|
|
125
|
+
decipher.setAAD(MAGIC);
|
|
126
|
+
decipher.setAuthTag(sealed.subarray(16, 32));
|
|
127
|
+
return JSON.parse(Buffer.concat([decipher.update(sealed.subarray(32)), decipher.final()]).toString('utf8'));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The encrypted secrets file. Writes take a lock file, so two `diorama setup` runs cannot lose a key. */
|
|
131
|
+
export class SecretStore {
|
|
132
|
+
readonly file: string;
|
|
133
|
+
|
|
134
|
+
constructor(readonly vault: Vault, readonly dir = configDir()) {
|
|
135
|
+
this.file = path.join(dir, 'credentials.bin');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async read(): Promise<Record<string, string>> {
|
|
139
|
+
if (!fs.existsSync(this.file)) return {};
|
|
140
|
+
const key = await this.vault.get();
|
|
141
|
+
if (!key) throw new Error(`credentials.bin exists but its key is missing from the ${this.vault.name}; run diorama setup to store your keys again`);
|
|
142
|
+
return open(key, fs.readFileSync(this.file));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async set(name: SecretName, value: string | null) {
|
|
146
|
+
await this.locked(async () => {
|
|
147
|
+
let key = await this.vault.get();
|
|
148
|
+
if (!key || key.length !== 32) {
|
|
149
|
+
key = randomBytes(32);
|
|
150
|
+
await this.vault.set(key);
|
|
151
|
+
}
|
|
152
|
+
const all = fs.existsSync(this.file) ? open(key, fs.readFileSync(this.file)) : {};
|
|
153
|
+
if (value === null) delete all[name];
|
|
154
|
+
else all[name] = value;
|
|
155
|
+
writeAtomic(this.file, seal(key, all));
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async locked(work: () => Promise<void>) {
|
|
160
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
161
|
+
const lock = path.join(this.dir, 'credentials.lock');
|
|
162
|
+
for (let tries = 0; ; tries++) {
|
|
163
|
+
try {
|
|
164
|
+
fs.closeSync(fs.openSync(lock, 'wx', 0o600));
|
|
165
|
+
break;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
const stale = fs.existsSync(lock) && Date.now() - fs.statSync(lock).mtimeMs > 30_000;
|
|
168
|
+
if (stale) fs.rmSync(lock, { force: true });
|
|
169
|
+
else if (tries > 50) throw new Error(`credentials are locked by another diorama process (${lock})`);
|
|
170
|
+
else await new Promise((r) => setTimeout(r, 100));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
await work();
|
|
175
|
+
} finally {
|
|
176
|
+
fs.rmSync(lock, { force: true });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Where a key comes from right now: the environment (under any of its names), the store, or nowhere. */
|
|
182
|
+
export function envValue(name: SecretName): string | undefined {
|
|
183
|
+
return [name, ...SECRETS[name].aliases].map((n) => process.env[n]).find(Boolean);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** At startup: stored keys fill in whatever the environment does not set. Quiet when nothing is stored. */
|
|
187
|
+
export async function loadSecrets(dir = configDir()): Promise<Partial<Record<SecretName, 'env' | 'store'>>> {
|
|
188
|
+
const sources: Partial<Record<SecretName, 'env' | 'store'>> = {};
|
|
189
|
+
for (const name of SECRET_NAMES) if (envValue(name)) sources[name] = 'env';
|
|
190
|
+
if (fs.existsSync(path.join(dir, 'credentials.bin')) && SECRET_NAMES.some((n) => !sources[n])) {
|
|
191
|
+
const vault = await findVault(dir);
|
|
192
|
+
if (vault) {
|
|
193
|
+
const stored = await new SecretStore(vault, dir).read();
|
|
194
|
+
for (const name of SECRET_NAMES) {
|
|
195
|
+
if (sources[name] || !stored[name]) continue;
|
|
196
|
+
process.env[name] = stored[name];
|
|
197
|
+
sources[name] = 'store';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return sources;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Every known key value in this process, long enough to be worth hiding. */
|
|
205
|
+
function knownValues(): string[] {
|
|
206
|
+
return SECRET_NAMES.flatMap((n) => [n, ...SECRETS[n].aliases]).map((n) => process.env[n]).filter((v): v is string => !!v && v.length >= 8);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Replaces any key value in text, for logs, recordings and anything sent to a browser or agent. */
|
|
210
|
+
export function redact(text: string): string {
|
|
211
|
+
for (const value of knownValues()) text = text.split(value).join('[redacted]');
|
|
212
|
+
return text.replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{16,}/g, '$1[redacted]');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The environment without any provider key, for a process that must not hold them (the agent). */
|
|
216
|
+
export function withoutSecrets(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
217
|
+
const drop = new Set<string>(SECRET_NAMES.flatMap((n) => [n, ...SECRETS[n].aliases]));
|
|
218
|
+
return Object.fromEntries(Object.entries(env).filter(([k]) => !drop.has(k)));
|
|
219
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// What a machine needs before diorama can build: shown by `diorama setup`, the Studio's Setup screen and
|
|
2
|
+
// the setup_status action. A check never returns a key, only where it comes from.
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { blenderState } from '../model/blender.ts';
|
|
7
|
+
import { envValue, findVault, loadSecrets, NO_VAULT, type SecretName } from '../secrets.ts';
|
|
8
|
+
|
|
9
|
+
export type Check = {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
/** ok, missing (needed to build), optional (missing but not needed), fail (present but broken). */
|
|
13
|
+
state: 'ok' | 'missing' | 'optional' | 'fail';
|
|
14
|
+
detail: string;
|
|
15
|
+
/** Commands that fix it, typed in a terminal. */
|
|
16
|
+
fix?: string[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function sh(cmd: string, args: string[], timeout = 8000): Promise<string | null> {
|
|
20
|
+
return new Promise((resolve) => execFile(cmd, args, { timeout }, (error, out) => resolve(error ? null : String(out).trim())));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function keyCheck(name: SecretName, label: string, required: boolean, what: string, source?: 'env' | 'store'): Promise<Check> {
|
|
24
|
+
const value = envValue(name);
|
|
25
|
+
if (!value) return { id: name, label, state: required ? 'missing' : 'optional', detail: what, fix: [`diorama setup --key ${name}`] };
|
|
26
|
+
return { id: name, label, state: 'ok', detail: source === 'store' ? 'in your system keychain' : 'from the environment' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runChecks(opts: { live?: boolean } = {}): Promise<Check[]> {
|
|
30
|
+
const sources = await loadSecrets().catch(() => ({}) as Partial<Record<SecretName, 'env' | 'store'>>);
|
|
31
|
+
const checks: Check[] = [];
|
|
32
|
+
|
|
33
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
34
|
+
checks.push({ id: 'node', label: 'Node.js', state: major >= 22 ? 'ok' : 'fail', detail: `${process.versions.node}${major >= 22 ? '' : ': diorama needs 22 or newer'}` });
|
|
35
|
+
|
|
36
|
+
const vault = await findVault();
|
|
37
|
+
checks.push(vault
|
|
38
|
+
? { id: 'vault', label: 'Key storage', state: 'ok', detail: vault.name }
|
|
39
|
+
: { id: 'vault', label: 'Key storage', state: 'optional', detail: NO_VAULT });
|
|
40
|
+
|
|
41
|
+
const meta = await keyCheck('META_API_KEY', 'Meta Model API key', true, 'Plans, paints and cuts every scene (Muse Spark, Muse Image, SAM 3.1). Needed to build.', sources.META_API_KEY);
|
|
42
|
+
if (meta.state === 'ok' && opts.live) {
|
|
43
|
+
const { askSparkJson } = await import('../meta/spark.ts');
|
|
44
|
+
const { Memo } = await import('../meta/memo.ts');
|
|
45
|
+
const started = Date.now();
|
|
46
|
+
try {
|
|
47
|
+
// A fresh label each time, so the memo cannot answer for the API.
|
|
48
|
+
await askSparkJson(new Memo(path.resolve('.diorama/setup/memo')), { label: 'setup', prompt: `Reply as JSON {"ok": true}. (${Date.now()})` });
|
|
49
|
+
meta.detail += ` · live check ${Date.now() - started} ms`;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
Object.assign(meta, { state: 'fail', detail: `the API refused it: ${error instanceof Error ? error.message.slice(0, 200) : error}` });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
checks.push(meta);
|
|
55
|
+
|
|
56
|
+
const muse = await sh('muse', ['--version']);
|
|
57
|
+
// Other agents the Studio can switch to (genaicode drives them the same way, with the Studio's MCP tools).
|
|
58
|
+
const others = await Promise.all([['claude', 'Claude Code'], ['codex', 'Codex']].map(async ([cmd, label]) => ({ label, version: await sh(cmd, ['--version']) })));
|
|
59
|
+
const found = others.filter((o) => o.version);
|
|
60
|
+
const install = ['curl -fsSL https://dev.meta.ai/install.sh | bash', 'muse login', 'muse --version'];
|
|
61
|
+
checks.push(muse
|
|
62
|
+
? { id: 'muse', label: 'Muse Code', state: 'ok', detail: muse.split('\n')[0] }
|
|
63
|
+
: found.length
|
|
64
|
+
// Another agent stands in, so the Studio works; Muse Code stays the recommended one.
|
|
65
|
+
? { id: 'muse', label: 'Muse Code', state: 'optional', detail: `Not found on PATH, so the Studio's agent is ${found[0].label} for now. Muse Code is the default once installed. macOS and Linux; on Windows use WSL2.`, fix: install }
|
|
66
|
+
: { id: 'muse', label: 'Muse Code', state: 'missing', detail: 'The agent that builds and fixes worlds with you. Not found on PATH. macOS and Linux; on Windows use WSL2.', fix: install });
|
|
67
|
+
checks.push({
|
|
68
|
+
id: 'agents',
|
|
69
|
+
label: 'Other agents',
|
|
70
|
+
state: found.length ? 'ok' : 'optional',
|
|
71
|
+
detail: `${others.map((o) => (o.version ? `${o.label} ${/\d+\.\d+[\w.-]*/.exec(o.version)?.[0] ?? 'found'}` : `${o.label} not found`)).join(' · ')}. Switch in the agent panel; DIORAMA_AGENT=muse, claude or codex sets the default.`,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
let chromium: string | null = null;
|
|
75
|
+
try {
|
|
76
|
+
const { chromium: pw } = await import('playwright');
|
|
77
|
+
chromium = pw.executablePath();
|
|
78
|
+
} catch {}
|
|
79
|
+
checks.push(chromium && fs.existsSync(chromium)
|
|
80
|
+
? { id: 'chromium', label: 'Chromium for Explore and Playthrough', state: 'ok', detail: 'installed' }
|
|
81
|
+
: { id: 'chromium', label: 'Chromium for Explore and Playthrough', state: 'missing', detail: 'Explore and the playthrough bot drive a browser.', fix: ['diorama browsers'] });
|
|
82
|
+
|
|
83
|
+
checks.push(await keyCheck('ELEVENLABS_API_KEY', 'ElevenLabs key', false, 'Optional: sound effects, music and voices. Without it the world plays silent.', sources.ELEVENLABS_API_KEY));
|
|
84
|
+
checks.push(await keyCheck('HF_TOKEN', 'Hugging Face token', false, 'Optional: 3D hero figures (TRELLIS, auto-rig) on ZeroGPU Spaces.', sources.HF_TOKEN));
|
|
85
|
+
checks.push(await keyCheck('DIORAMA_ANTHROPIC_API_KEY', 'Anthropic API key', false, 'Optional: with DIORAMA_SPARK_VIA=claude, Spark\'s text and vision asks go to Claude (Sonnet 5.5) instead of Meta.', sources.DIORAMA_ANTHROPIC_API_KEY));
|
|
86
|
+
checks.push(await keyCheck('OPENROUTER_API_KEY', 'OpenRouter key', false, 'Optional: DIORAMA_IMAGE_VIA=gemini-flash, gemini-lite, nano-banana or gemini-pro paints with Gemini instead of Muse Image; DIORAMA_SPARK_VIA=openrouter:<model> for any chat model.', sources.OPENROUTER_API_KEY));
|
|
87
|
+
checks.push(await keyCheck('GEMINI_API_KEY', 'Google Gemini key', false, 'Optional: with DIORAMA_IMAGE_VIA=gemini-flash (or gemini-lite, nano-banana, gemini-pro) Gemini paints straight from Google instead of through OpenRouter.', sources.GEMINI_API_KEY));
|
|
88
|
+
|
|
89
|
+
const blender = await blenderState();
|
|
90
|
+
const blenderWhat = 'With Blender 3.6+ installed, 3D figures get a calm rest pose (arms down, feet under the hips) before they walk, so they stop walking straddle-legged with arms out.';
|
|
91
|
+
checks.push(blender.installed
|
|
92
|
+
? { id: 'blender', label: 'Blender for natural 3D walks', state: blender.enabled ? 'ok' : 'optional',
|
|
93
|
+
detail: blender.enabled ? `Blender ${blender.installed.version} (${blender.installed.bin}): on. Turn it off in Setup.` : `Blender ${blender.installed.version} found but turned off in Setup.` }
|
|
94
|
+
: { id: 'blender', label: 'Blender for natural 3D walks', state: 'optional', detail: `Optional: ${blenderWhat} Without it they walk from the pose TRELLIS built. Or set DIORAMA_BLENDER to its path.`,
|
|
95
|
+
fix: [process.platform === 'darwin' ? 'brew install --cask blender' : 'https://www.blender.org/download/'] });
|
|
96
|
+
|
|
97
|
+
const py = process.env.DIORAMA_PYTHON;
|
|
98
|
+
checks.push(!py
|
|
99
|
+
? { id: 'python', label: 'Python for local music', state: 'optional', detail: 'DIORAMA_PYTHON not set: music comes from ElevenLabs. MusicGen needs torch, transformers, scipy.' }
|
|
100
|
+
: fs.existsSync(py)
|
|
101
|
+
? { id: 'python', label: 'Python for local music', state: 'ok', detail: py }
|
|
102
|
+
: { id: 'python', label: 'Python for local music', state: 'fail', detail: `DIORAMA_PYTHON points at ${py}, which does not exist` });
|
|
103
|
+
|
|
104
|
+
const remote = await sh('git', ['remote', 'get-url', 'origin']);
|
|
105
|
+
checks.push(remote
|
|
106
|
+
? { id: 'publish', label: 'Publishing', state: 'ok', detail: `git remote ${remote.replace(/\/\/[^@/]+@/, '//')}; diorama publish pushes to gh-pages` }
|
|
107
|
+
: { id: 'publish', label: 'Publishing', state: 'optional', detail: 'No git remote: diorama export --zip still makes an itch.io upload.' });
|
|
108
|
+
|
|
109
|
+
return checks;
|
|
110
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// What sound costs. ElevenLabs bills every effect, line and design, so while a world is being developed the
|
|
2
|
+
// pipeline uses final quality unless an author explicitly requests a cheaper draft.
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { readSoundQuality } from './settings.ts';
|
|
6
|
+
import { trackOperation } from '../studio/operation.ts';
|
|
7
|
+
|
|
8
|
+
/** CLI flags > DIORAMA_SOUND > Studio Setup > final. */
|
|
9
|
+
export type Quality = 'draft' | 'final';
|
|
10
|
+
|
|
11
|
+
export function soundQuality(args: string[] = []): Quality {
|
|
12
|
+
if (args.includes('--draft') && args.includes('--final')) throw new Error('choose --draft or --final, not both');
|
|
13
|
+
if (args.includes('--final')) process.env.DIORAMA_SOUND = 'final';
|
|
14
|
+
if (args.includes('--draft')) process.env.DIORAMA_SOUND = 'draft';
|
|
15
|
+
const quality = process.env.DIORAMA_SOUND ?? readSoundQuality();
|
|
16
|
+
if (quality !== 'final' && quality !== 'draft') throw new Error('DIORAMA_SOUND must be final or draft');
|
|
17
|
+
return quality;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Speech: Flash v2.5 costs half of Multilingual v2 per character. */
|
|
21
|
+
export const FINAL_TTS = 'eleven_multilingual_v2';
|
|
22
|
+
export const ttsModel = () => (soundQuality() === 'final' ? FINAL_TTS : 'eleven_flash_v2_5');
|
|
23
|
+
/** Takes per line before it is flagged: each take is paid. */
|
|
24
|
+
export const lineTakes = () => (soundQuality() === 'final' ? 3 : 2);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sounds, speech and voice designs every world on this machine shares: the same request is never paid twice,
|
|
28
|
+
* and generic sounds (footsteps per surface, clicks, pick-ups) are reused across worlds in draft quality.
|
|
29
|
+
*/
|
|
30
|
+
export function sharedDir(...parts: string[]): string {
|
|
31
|
+
return path.join(process.env.DIORAMA_CACHE || path.join(os.homedir(), '.cache', 'diorama'), ...parts);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let outOf: string | undefined;
|
|
35
|
+
|
|
36
|
+
/** Called with every ElevenLabs refusal: an empty account stops all later calls at once instead of each failing. */
|
|
37
|
+
export function noteRefusal(status: number, body: string) {
|
|
38
|
+
if (/quota_exceeded|insufficient_credits|credits remaining/i.test(body) || (status === 402)) outOf = 'ElevenLabs has no credits left (top up, or the world stays silent)';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function checkCredits() {
|
|
42
|
+
if (outOf) throw new Error(outOf);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type Credits = { used: number; limit: number; left: number };
|
|
46
|
+
|
|
47
|
+
/** The account's credits, or undefined when the key cannot read them. */
|
|
48
|
+
export async function credits(key: string | undefined): Promise<Credits | undefined> {
|
|
49
|
+
if (!key) return undefined;
|
|
50
|
+
try {
|
|
51
|
+
return await trackOperation('elevenlabs', 'check subscription', async () => {
|
|
52
|
+
const res = await fetch('https://api.elevenlabs.io/v1/user/subscription', { headers: { 'xi-api-key': key } });
|
|
53
|
+
if (!res.ok) return undefined;
|
|
54
|
+
const s = (await res.json()) as { character_count: number; character_limit: number };
|
|
55
|
+
return { used: s.character_count, limit: s.character_limit, left: s.character_limit - s.character_count };
|
|
56
|
+
});
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Credits a step may still spend: DIORAMA_ELEVEN_FLOOR (default 2000) are always left for other work. */
|
|
63
|
+
export async function creditGuard(key: string | undefined, step: string): Promise<{ start?: Credits; report: () => Promise<string> }> {
|
|
64
|
+
const start = await credits(key);
|
|
65
|
+
const floor = Number(process.env.DIORAMA_ELEVEN_FLOOR ?? 2000);
|
|
66
|
+
if (start && start.left <= floor) throw new Error(`${step}: ElevenLabs has ${start.left} credits left (floor ${floor}); top up or set DIORAMA_ELEVEN_FLOOR lower. The world plays silent until then.`);
|
|
67
|
+
return {
|
|
68
|
+
start,
|
|
69
|
+
report: async () => {
|
|
70
|
+
const end = start && (await credits(key));
|
|
71
|
+
return start && end ? `credits: ${end.used - start.used} used, ${end.left} left (${soundQuality()} quality)` : `${soundQuality()} quality`;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The play page's SoundData from sound.json (the plan) and sound/sounds.json (what was made).
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import type { SoundData } from '../play/sound.ts';
|
|
4
|
+
import { Layout } from '../scene/layout.ts';
|
|
5
|
+
import type { WorldFiles } from '../world/files.ts';
|
|
6
|
+
import { SoundManifest } from './make.ts';
|
|
7
|
+
import { SoundPlan, soundIds } from './plan.ts';
|
|
8
|
+
import { jobsOf } from './make.ts';
|
|
9
|
+
import { readSoundFixes } from './fixes.ts';
|
|
10
|
+
import { storyLines, Voices } from './voices.ts';
|
|
11
|
+
import { Story } from '../story/schema.ts';
|
|
12
|
+
import { lipSyncData } from './lipsync.ts';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The spoken lines the last voices run could not make that the story still has. A line rewritten since (story_text)
|
|
16
|
+
* is history, not a missing recording: story_status unvoiced lists the new wording until voices runs again.
|
|
17
|
+
*/
|
|
18
|
+
export function liveVoiceFailures(files: WorldFiles): Voices['failures'] {
|
|
19
|
+
const voicesFile = files.path('voices.json');
|
|
20
|
+
if (!fs.existsSync(voicesFile)) return [];
|
|
21
|
+
const voices = Voices.safeParse(files.readJson(voicesFile));
|
|
22
|
+
if (!voices.success) return [];
|
|
23
|
+
try {
|
|
24
|
+
const world = files.world();
|
|
25
|
+
const story = Story.parse(files.readJson(files.path('story.json')));
|
|
26
|
+
const live = new Set(storyLines(world, story, true).map((l) => JSON.stringify([l.speaker, l.text])));
|
|
27
|
+
return voices.data.failures.filter((f) => live.has(JSON.stringify([f.speaker, f.text])));
|
|
28
|
+
} catch {
|
|
29
|
+
return voices.data.failures;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function soundData(files: WorldFiles): SoundData | undefined {
|
|
34
|
+
const sound = effects(files);
|
|
35
|
+
const voicesFile = files.path('voices.json');
|
|
36
|
+
if (!fs.existsSync(voicesFile)) return sound;
|
|
37
|
+
const recordings = Voices.parse(files.readJson(voicesFile)).lines
|
|
38
|
+
.filter((l) => !l.flags.some((f) => f.startsWith('heard')) && fs.existsSync(files.path(l.file)));
|
|
39
|
+
const lines: NonNullable<SoundData['lines']> = {};
|
|
40
|
+
const bySpeaker: NonNullable<SoundData['bySpeaker']> = {};
|
|
41
|
+
const sync = lipSyncData(files);
|
|
42
|
+
for (const l of recordings) {
|
|
43
|
+
const entry = { file: l.file, seconds: l.seconds, speaker: l.speaker, ...(sync[l.file] && { lipsync: sync[l.file] }) };
|
|
44
|
+
(bySpeaker[l.speaker] ??= {})[l.text] = entry;
|
|
45
|
+
if (recordings.filter((r) => r.text === l.text).length === 1) lines[l.text] = entry;
|
|
46
|
+
}
|
|
47
|
+
return { ...(sound ?? { files: {}, places: {}, shots: {} }), lines, bySpeaker };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function effects(files: WorldFiles): SoundData | undefined {
|
|
51
|
+
const planFile = files.path('sound.json');
|
|
52
|
+
const madeFile = files.path('sound', 'sounds.json');
|
|
53
|
+
if (!fs.existsSync(planFile) || !fs.existsSync(madeFile)) return undefined;
|
|
54
|
+
const plan = SoundPlan.parse(files.readJson(planFile));
|
|
55
|
+
const made = SoundManifest.parse(files.readJson(madeFile)).sounds;
|
|
56
|
+
const wanted = new Map(jobsOf(plan, readSoundFixes(files)).map((j) => [j.id, j.prompt]));
|
|
57
|
+
// A take made from an older wording of its prompt still plays until a new one is made: the plan changing (a
|
|
58
|
+
// sound fix, a re-plan) once left a finished world with no music and no beds at all, silent instead of close.
|
|
59
|
+
// Only for music, beds and loops (mood under the game); a one-shot or a step made for other words may be the
|
|
60
|
+
// wrong sound for its moment, so those wait for their new take.
|
|
61
|
+
const have = (id: string) => (made[id] && (made[id].prompt === wanted.get(id) || (wanted.has(id) && ['music', 'bed', 'loop'].includes(made[id].kind)))
|
|
62
|
+
&& !made[id].flags.some((f) => /does not loop well|still hissy|only \d+ footstep/.test(f))
|
|
63
|
+
&& fs.existsSync(files.path(made[id].file)) ? id : undefined);
|
|
64
|
+
const data: SoundData = {
|
|
65
|
+
files: Object.fromEntries(Object.entries(made).filter(([id]) => have(id)).map(([id, s]) => [id, { file: s.file, kind: s.kind, level: s.level, ...(s.slices && { slices: s.slices }) }])),
|
|
66
|
+
music: have(soundIds.music()),
|
|
67
|
+
places: {},
|
|
68
|
+
shots: {},
|
|
69
|
+
};
|
|
70
|
+
for (const place of files.world().places) {
|
|
71
|
+
const p = plan.places[place.key];
|
|
72
|
+
if (!p) continue;
|
|
73
|
+
const layoutFile = files.place(place.key, 'layout.json');
|
|
74
|
+
const floors = fs.existsSync(layoutFile) ? Layout.parse(files.readJson(layoutFile)).floors : [];
|
|
75
|
+
data.places[place.key] = {
|
|
76
|
+
bed: have(soundIds.bed(place.key)),
|
|
77
|
+
loops: p.loops.flatMap((l) => {
|
|
78
|
+
const id = have(soundIds.loop(place.key, l.thing));
|
|
79
|
+
return id ? [{ id, thing: l.thing, level: l.level, when: l.when }] : [];
|
|
80
|
+
}),
|
|
81
|
+
zones: floors.map((f) => (p.floors[f.key] ? have(soundIds.step(p.floors[f.key])) ?? null : null)),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
for (const shot of plan.shots) {
|
|
85
|
+
const id = have(soundIds.shot(shot.on));
|
|
86
|
+
if (id) data.shots[shot.on] = id;
|
|
87
|
+
}
|
|
88
|
+
return data;
|
|
89
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The person's corrections to single sounds (sound_redo): a hint added to Spark's prompt, or simply another take.
|
|
2
|
+
// Kept in sound/fixes.json, so a rebuild or a new sound plan keeps them; the plan itself stays Spark's.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import type { WorldFiles } from '../world/files.ts';
|
|
6
|
+
|
|
7
|
+
export const SoundFixes = z.record(z.string(), z.object({
|
|
8
|
+
/** Words added to the sound's prompt ("louder, a clear clock tick"). */
|
|
9
|
+
hint: z.string().optional(),
|
|
10
|
+
/** Takes asked for again: each new round asks ElevenLabs for new takes instead of the cached ones. */
|
|
11
|
+
take: z.number().int().min(0).optional(),
|
|
12
|
+
}));
|
|
13
|
+
export type SoundFixes = z.infer<typeof SoundFixes>;
|
|
14
|
+
|
|
15
|
+
export const soundFixesFile = (files: WorldFiles) => files.path('sound', 'fixes.json');
|
|
16
|
+
|
|
17
|
+
export function readSoundFixes(files: WorldFiles): SoundFixes {
|
|
18
|
+
try {
|
|
19
|
+
return SoundFixes.parse(JSON.parse(fs.readFileSync(soundFixesFile(files), 'utf8')));
|
|
20
|
+
} catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Shared with Studio; old manifests are classified too, without regenerating any audio. */
|
|
2
|
+
export type AudioMisses = {
|
|
3
|
+
sounds: Array<{ id: string; reason: string }>;
|
|
4
|
+
lines: Array<{ speaker: string; text: string; reason: string }>;
|
|
5
|
+
};
|
|
6
|
+
type Kind = 'validation' | 'design' | 'rejected' | 'unavailable';
|
|
7
|
+
export type AudioIssue = { kind: Kind; speaker?: string; id?: string; count: number; reason: string };
|
|
8
|
+
|
|
9
|
+
export function audioIssues(misses: AudioMisses): AudioIssue[] {
|
|
10
|
+
const groups = new Map<string, AudioIssue>();
|
|
11
|
+
for (const line of misses.lines) {
|
|
12
|
+
const kind: Kind = /validation pending|transcribe .*?(backlog|busy|overload|socket|timed out|closed)|transcription unavailable/i.test(line.reason) ? 'validation'
|
|
13
|
+
: /waiting for a designed voice|Voice Design pending/i.test(line.reason) ? 'design'
|
|
14
|
+
: /speech rejected/i.test(line.reason) ? 'rejected' : 'unavailable';
|
|
15
|
+
const key = `${kind}:${line.speaker}`;
|
|
16
|
+
const group = groups.get(key);
|
|
17
|
+
if (group) group.count++;
|
|
18
|
+
else groups.set(key, { kind, speaker: line.speaker, count: 1, reason: line.reason });
|
|
19
|
+
}
|
|
20
|
+
return [...groups.values(), ...misses.sounds.map((s): AudioIssue => ({ kind: /rejected/i.test(s.reason) ? 'rejected' : 'unavailable', id: s.id, count: 1, reason: s.reason }))];
|
|
21
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** World language names and voice-library language labels use different formats. */
|
|
2
|
+
export function languageKey(language = 'English'): string {
|
|
3
|
+
const name = language.trim().toLowerCase();
|
|
4
|
+
const names: Record<string, string> = {
|
|
5
|
+
polish: 'pl', polski: 'pl', english: 'en', german: 'de', deutsch: 'de', french: 'fr', français: 'fr',
|
|
6
|
+
spanish: 'es', español: 'es', italian: 'it', portuguese: 'pt', dutch: 'nl', ukrainian: 'uk',
|
|
7
|
+
russian: 'ru', czech: 'cs', slovak: 'sk', swedish: 'sv', norwegian: 'no', danish: 'da', finnish: 'fi',
|
|
8
|
+
japanese: 'ja', korean: 'ko', chinese: 'zh', arabic: 'ar', hindi: 'hi', turkish: 'tr',
|
|
9
|
+
};
|
|
10
|
+
return names[name] ?? (/^[a-z]{2}(-[a-z]{2})?$/.test(name) ? name.slice(0, 2) : name);
|
|
11
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Offline, cached analysis of the final recordings. No TTS request and no changes to paid audio.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import type { WorldFiles } from '../world/files.ts';
|
|
9
|
+
import { sha256 } from '../scene/faces-data.ts';
|
|
10
|
+
import { MOUTHS, type LipSync } from '../play/lipsync.ts';
|
|
11
|
+
import { decode, envelope, RATE } from './pcm.ts';
|
|
12
|
+
import { lipSyncData } from './lipsync.ts';
|
|
13
|
+
|
|
14
|
+
const exec = promisify(execFile);
|
|
15
|
+
const VERSION = '1.14.0';
|
|
16
|
+
const Cue = z.object({ start: z.number().nonnegative(), end: z.number().positive(), value: z.enum(MOUTHS as ['X', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']) }).refine(c => c.end > c.start);
|
|
17
|
+
export type SyncEntry = LipSync & { sha256: string };
|
|
18
|
+
export type SyncManifest = { version: 1; recognizer: string; byFile: Record<string, SyncEntry>; failures?: Array<{ speaker: string; text: string; reason: string }> };
|
|
19
|
+
|
|
20
|
+
/** A pinned official release, kept locally. An explicit executable is useful on managed machines. */
|
|
21
|
+
export async function rhubarbExecutable(explicit = process.env.DIORAMA_RHUBARB): Promise<string> {
|
|
22
|
+
if (explicit) { await exec(explicit, ['--version']); return explicit; }
|
|
23
|
+
try { await exec('rhubarb', ['--version']); return 'rhubarb'; } catch { /* Prepare the local dependency. */ }
|
|
24
|
+
const platform = process.platform === 'darwin' ? 'macOS' : process.platform === 'linux' ? 'Linux' : process.platform === 'win32' ? 'Windows' : undefined;
|
|
25
|
+
if (!platform) throw new Error('Install Rhubarb Lip Sync and set DIORAMA_RHUBARB to its executable');
|
|
26
|
+
const name = `Rhubarb-Lip-Sync-${VERSION}-${platform}`;
|
|
27
|
+
const root = process.env.DIORAMA_TOOLS_DIR ?? path.join(process.env.DIORAMA_CACHE ?? path.join(os.homedir(), '.cache', 'diorama'), 'tools');
|
|
28
|
+
const executable = path.join(root, name, `rhubarb${process.platform === 'win32' ? '.exe' : ''}`);
|
|
29
|
+
if (fs.existsSync(executable)) { await exec(executable, ['--version']); return executable; }
|
|
30
|
+
fs.mkdirSync(root, { recursive: true });
|
|
31
|
+
const temporary = fs.mkdtempSync(path.join(root, '.rhubarb-'));
|
|
32
|
+
try {
|
|
33
|
+
console.log(`faces: preparing Rhubarb ${VERSION} (${platform})`);
|
|
34
|
+
const response = await fetch(`https://github.com/DanielSWolf/rhubarb-lip-sync/releases/download/v${VERSION}/${name}.zip`, { signal: AbortSignal.timeout(120_000) });
|
|
35
|
+
if (!response.ok) throw new Error(`Rhubarb download: HTTP ${response.status}`);
|
|
36
|
+
const archive = path.join(temporary, 'rhubarb.zip');
|
|
37
|
+
fs.writeFileSync(archive, Buffer.from(await response.arrayBuffer()));
|
|
38
|
+
if (process.platform === 'win32') await exec('tar', ['-xf', archive, '-C', temporary]);
|
|
39
|
+
else await exec('unzip', ['-q', archive, '-d', temporary]);
|
|
40
|
+
const unpacked = path.join(temporary, name);
|
|
41
|
+
const binary = path.join(unpacked, path.basename(executable));
|
|
42
|
+
if (process.platform !== 'win32') fs.chmodSync(binary, 0o755);
|
|
43
|
+
await exec(binary, ['--version']);
|
|
44
|
+
// A concurrent run may have installed the same release while this one downloaded it.
|
|
45
|
+
if (!fs.existsSync(executable)) fs.renameSync(unpacked, path.dirname(executable));
|
|
46
|
+
return executable;
|
|
47
|
+
} finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function analyseRecording(audio: string, executable: string, language?: string): Promise<LipSync> {
|
|
51
|
+
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'diorama-lipsync-'));
|
|
52
|
+
try {
|
|
53
|
+
const wav = path.join(temporary, 'line.wav'), json = path.join(temporary, 'cues.json');
|
|
54
|
+
await exec('ffmpeg', ['-v', 'error', '-y', '-i', audio, '-ac', '1', '-ar', '16000', wav]);
|
|
55
|
+
await exec(executable, ['-r', /^(en|english)\b/i.test(language ?? '') ? 'pocketSphinx' : 'phonetic', '-f', 'json', '--quiet', '-o', json, wav], { timeout: 120_000 });
|
|
56
|
+
const cues = z.array(Cue).parse(JSON.parse(fs.readFileSync(json, 'utf8')).mouthCues);
|
|
57
|
+
if (!cues.length || cues.some((c, i) => i > 0 && c.start < cues[i - 1].end)) throw new Error('Rhubarb returned an empty or overlapping timeline');
|
|
58
|
+
const pcm = decode(fs.readFileSync(audio));
|
|
59
|
+
if (cues.at(-1)!.end > pcm.left.length / RATE + 0.05) throw new Error('Rhubarb timeline exceeds the recording');
|
|
60
|
+
const rms = envelope(pcm, 40);
|
|
61
|
+
const sorted = [...rms].sort((a, b) => a - b), scale = sorted[Math.floor(sorted.length * 0.95)] || 1;
|
|
62
|
+
return { cues, step: 0.04, envelope: rms.map(v => Math.round(Math.min(1, v / scale) * 100) / 100) };
|
|
63
|
+
} finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function prepareLipSync(files: WorldFiles, only: string[] = [], explicit?: string): Promise<SyncManifest> {
|
|
67
|
+
const recordings = files.readJson(files.path('voices.json')) as { lines: Array<{ speaker: string; text: string; file: string }> };
|
|
68
|
+
const previous = lipSyncData(files);
|
|
69
|
+
const byFile: SyncManifest['byFile'] = {};
|
|
70
|
+
const failures: NonNullable<SyncManifest['failures']> = [];
|
|
71
|
+
let executable: Promise<string> | undefined;
|
|
72
|
+
const language = files.world().language;
|
|
73
|
+
for (const line of recordings.lines) {
|
|
74
|
+
if (line.speaker === 'narrator' || !fs.existsSync(files.path(line.file))) continue;
|
|
75
|
+
const sha = sha256(fs.readFileSync(files.path(line.file)));
|
|
76
|
+
if (previous[line.file]) { byFile[line.file] = { ...previous[line.file], sha256: sha }; continue; }
|
|
77
|
+
if (only.length && !only.includes(line.speaker)) continue;
|
|
78
|
+
try {
|
|
79
|
+
executable ??= rhubarbExecutable(explicit);
|
|
80
|
+
byFile[line.file] = { ...await analyseRecording(files.path(line.file), await executable, language), sha256: sha };
|
|
81
|
+
console.log(`${line.speaker}: lip-sync ${byFile[line.file].cues.length} cues — ${line.text.slice(0, 70)}`);
|
|
82
|
+
} catch (e) { failures.push({ speaker: line.speaker, text: line.text, reason: String(e instanceof Error ? e.message : e) }); }
|
|
83
|
+
}
|
|
84
|
+
const manifest: SyncManifest = { version: 1, recognizer: `rhubarb-${VERSION}`, byFile, failures };
|
|
85
|
+
const target = files.path('sound', 'lipsync.json');
|
|
86
|
+
files.write(`${target}.tmp`, manifest);
|
|
87
|
+
fs.renameSync(`${target}.tmp`, target);
|
|
88
|
+
return manifest;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Existing speaking characters keep their timing when a voice is retried; preparing art is a separate operation. */
|
|
92
|
+
export async function refreshExistingLipSync(files: WorldFiles, only: string[] = []) {
|
|
93
|
+
if (!fs.existsSync(files.path('sound', 'lipsync.json'))) return;
|
|
94
|
+
try {
|
|
95
|
+
const result = await prepareLipSync(files, only);
|
|
96
|
+
if (result.failures?.length) console.log(`warning: ${result.failures.length} lines need lip-sync preparation; recordings are retained`);
|
|
97
|
+
} catch (e) { console.log(`warning: lip-sync preparation failed; recordings are retained: ${e instanceof Error ? e.message : e}`); }
|
|
98
|
+
}
|