@gtanczyk/diorama 0.0.0-stage → 0.1.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 +152 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +248 -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 +31 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -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 +289 -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 +142 -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 +142 -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 +36 -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 +192 -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 +90 -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 +91 -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 +31 -0
- package/app/src/scene/cast.ts +192 -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 +55 -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 +375 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +207 -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 +417 -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 +554 -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 +421 -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/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 +677 -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 +1328 -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 +3751 -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,85 @@
|
|
|
1
|
+
// diorama setup: what this machine still needs, and the one place a key is typed in (hidden, in the terminal).
|
|
2
|
+
import { defineCommand, takeOption } from '../registry.ts';
|
|
3
|
+
import { findVault, NO_VAULT, SECRET_NAMES, SECRETS, SecretStore, type SecretName } from '../secrets.ts';
|
|
4
|
+
import { runChecks, type Check } from '../setup/checks.ts';
|
|
5
|
+
|
|
6
|
+
const MARK: Record<Check['state'], string> = { ok: ' ok ', missing: ' MISSING ', optional: ' - ', fail: ' FAIL ' };
|
|
7
|
+
|
|
8
|
+
export function printChecks(checks: Check[]) {
|
|
9
|
+
for (const c of checks) {
|
|
10
|
+
console.log(`${MARK[c.state]}${c.label}: ${c.detail}`);
|
|
11
|
+
for (const line of c.fix ?? []) console.log(` $ ${line}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Reads a line without echoing it. Only from a terminal: a pasted key never lands in shell history or argv. */
|
|
16
|
+
function askHidden(question: string): Promise<string> {
|
|
17
|
+
const stdin = process.stdin;
|
|
18
|
+
if (!stdin.isTTY) throw new Error('diorama setup reads keys from a terminal only; or set them as environment variables');
|
|
19
|
+
process.stdout.write(question);
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
let value = '';
|
|
22
|
+
stdin.setRawMode(true);
|
|
23
|
+
stdin.resume();
|
|
24
|
+
stdin.setEncoding('utf8');
|
|
25
|
+
const done = (error?: Error) => {
|
|
26
|
+
stdin.setRawMode(false);
|
|
27
|
+
stdin.pause();
|
|
28
|
+
stdin.off('data', onData);
|
|
29
|
+
process.stdout.write('\n');
|
|
30
|
+
if (error) reject(error);
|
|
31
|
+
else resolve(value.trim());
|
|
32
|
+
};
|
|
33
|
+
const onData = (chunk: string) => {
|
|
34
|
+
for (const ch of chunk) {
|
|
35
|
+
if (ch === '\r' || ch === '\n') return done();
|
|
36
|
+
if (ch === '\u0003') return done(new Error('cancelled'));
|
|
37
|
+
if (ch === '\u007f' || ch === '\b') value = value.slice(0, -1);
|
|
38
|
+
else if (ch >= ' ') value += ch;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
stdin.on('data', onData);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function storeKey(name: SecretName, remove: boolean) {
|
|
46
|
+
const vault = await findVault();
|
|
47
|
+
if (!vault) throw new Error(NO_VAULT);
|
|
48
|
+
const store = new SecretStore(vault);
|
|
49
|
+
if (remove) {
|
|
50
|
+
await store.set(name, null);
|
|
51
|
+
return console.log(`removed ${name} from ${store.file}`);
|
|
52
|
+
}
|
|
53
|
+
const value = await askHidden(`${SECRETS[name].label} (${name}), hidden, Enter to skip: `);
|
|
54
|
+
if (!value) return console.log('skipped');
|
|
55
|
+
await store.set(name, value);
|
|
56
|
+
console.log(`stored ${name}: sealed in ${store.file}, its key in the ${vault.name}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
defineCommand({
|
|
60
|
+
name: 'setup',
|
|
61
|
+
usage: 'setup check this machine and store keys, typed hidden [--key NAME] [--remove NAME] [--live]',
|
|
62
|
+
async run(args) {
|
|
63
|
+
const live = args.includes('--live');
|
|
64
|
+
const key = takeOption(args, 'key', '');
|
|
65
|
+
const remove = takeOption(args, 'remove', '');
|
|
66
|
+
for (const name of [key, remove].filter(Boolean)) {
|
|
67
|
+
if (!SECRET_NAMES.includes(name as SecretName)) throw new Error(`unknown key ${name}; diorama keeps ${SECRET_NAMES.join(', ')}`);
|
|
68
|
+
}
|
|
69
|
+
if (key || remove) return storeKey((key || remove) as SecretName, !!remove);
|
|
70
|
+
|
|
71
|
+
let checks = await runChecks({ live });
|
|
72
|
+
printChecks(checks);
|
|
73
|
+
// In a terminal, offer to store each missing key right away.
|
|
74
|
+
const missing = checks.filter((c) => SECRET_NAMES.includes(c.id as SecretName) && c.state !== 'ok');
|
|
75
|
+
if (process.stdin.isTTY && missing.length && (await findVault())) {
|
|
76
|
+
console.log('');
|
|
77
|
+
for (const c of missing) await storeKey(c.id as SecretName, false);
|
|
78
|
+
checks = await runChecks({ live });
|
|
79
|
+
console.log('');
|
|
80
|
+
printChecks(checks);
|
|
81
|
+
}
|
|
82
|
+
const blocking = checks.filter((c) => c.state === 'missing' || c.state === 'fail');
|
|
83
|
+
console.log(blocking.length ? `\n${blocking.length} to fix before a full build: ${blocking.map((c) => c.label).join(', ')}` : '\nready to build');
|
|
84
|
+
},
|
|
85
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// diorama sound <slug>: Spark plans what the world sounds like (sound.json), then every sound is generated,
|
|
2
|
+
// checked and levelled into sound/<id>.mp3 with sound/sounds.json, which the play page mixes live.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { defineCommand, takeOption } from '../registry.ts';
|
|
5
|
+
import { Brief } from '../scene/brief.ts';
|
|
6
|
+
import { Layout } from '../scene/layout.ts';
|
|
7
|
+
import { Kind, makeSounds, seedLibrary, SoundManifest } from '../sound/make.ts';
|
|
8
|
+
import { inputHash } from '../meta/memo.ts';
|
|
9
|
+
import { planIssues, SoundPlan, withoutStrayLoops, withoutSilentLoops, writeSoundPlan, type PlaceSources } from '../sound/plan.ts';
|
|
10
|
+
import { creditGuard, soundQuality } from '../sound/cost.ts';
|
|
11
|
+
import { readSoundFixes } from '../sound/fixes.ts';
|
|
12
|
+
import { elevenKey, musicgenPython } from '../sound/sources.ts';
|
|
13
|
+
import { Story } from '../story/schema.ts';
|
|
14
|
+
import { WorldFiles } from '../world/files.ts';
|
|
15
|
+
|
|
16
|
+
defineCommand({
|
|
17
|
+
name: 'sound',
|
|
18
|
+
usage: 'sound <slug> plan and generate ambience, loops, footsteps, one-shots and music [--replan] [--plan-only] [--only bed,loop,step,shot,music] [--redo <id,id>: make only these sounds again, with their hints from sound/fixes.json (sound_redo)] [--music auto|musicgen|eleven] [--draft: shorter loops and cheaper speech; final quality is the default]',
|
|
19
|
+
async run(args) {
|
|
20
|
+
// Keep this generation at one quality even if Setup changes while it runs.
|
|
21
|
+
process.env.DIORAMA_SOUND = soundQuality(args);
|
|
22
|
+
const replan = args.includes('--replan');
|
|
23
|
+
const planOnly = args.includes('--plan-only');
|
|
24
|
+
const only = takeOption(args, 'only', '');
|
|
25
|
+
const redo = takeOption(args, 'redo', '');
|
|
26
|
+
// auto: MusicGen when DIORAMA_PYTHON is set up for it, else ElevenLabs.
|
|
27
|
+
const musicFrom = takeOption(args, 'music', 'auto');
|
|
28
|
+
if (!['auto', 'musicgen', 'eleven'].includes(musicFrom)) throw new Error('--music takes auto, musicgen or eleven');
|
|
29
|
+
const [slug] = args.filter((a) => !a.startsWith('--'));
|
|
30
|
+
if (!slug) throw new Error('usage: diorama sound <slug>');
|
|
31
|
+
const files = new WorldFiles(slug);
|
|
32
|
+
const world = files.world();
|
|
33
|
+
const story = fs.existsSync(files.path('story.json')) ? Story.parse(files.readJson(files.path('story.json'))) : null;
|
|
34
|
+
const sources: Record<string, PlaceSources> = {};
|
|
35
|
+
for (const place of world.places) {
|
|
36
|
+
const read = <T>(file: string, schema: { parse: (v: unknown) => T }) => (fs.existsSync(files.place(place.key, file)) ? schema.parse(files.readJson(files.place(place.key, file))) : undefined);
|
|
37
|
+
sources[place.key] = { brief: read('brief.json', Brief), layout: read('layout.json', Layout) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// sound.json is the plan people may edit. It is made again with --replan, or when what it was made from
|
|
41
|
+
// (world, story, briefs, floors) has changed since, so its shots and loops keep matching the story's things.
|
|
42
|
+
const planFile = files.path('sound.json');
|
|
43
|
+
const from = inputHash(JSON.stringify(world), JSON.stringify(story), JSON.stringify(sources));
|
|
44
|
+
const old = fs.existsSync(planFile) ? SoundPlan.parse(files.readJson(planFile)) : undefined;
|
|
45
|
+
let plan: SoundPlan;
|
|
46
|
+
// A plan made before silent things lost their loops drops them here too, before anything is paid for.
|
|
47
|
+
if (old && !replan && old.from === from) plan = { ...old, ...withoutSilentLoops(old, sources) };
|
|
48
|
+
else {
|
|
49
|
+
if (old && !replan) console.log('the world, story or paintings changed since sound.json was planned: planning again (hand edits to it are replaced)');
|
|
50
|
+
plan = { from, ...withoutStrayLoops(await writeSoundPlan(files.memo, world, story, sources), world) };
|
|
51
|
+
files.write(planFile, plan);
|
|
52
|
+
}
|
|
53
|
+
const issues = planIssues(plan, world, sources);
|
|
54
|
+
const loops = Object.values(plan.places).reduce((n, p) => n + p.loops.length, 0);
|
|
55
|
+
console.log(`plan: music, ${Object.keys(plan.places).length} beds, ${loops} loops, ${Object.keys(plan.steps).length} surfaces, ${plan.shots.length} one-shots · ${planFile}`);
|
|
56
|
+
for (const issue of issues) console.log(` flag ${issue}`);
|
|
57
|
+
if (planOnly) return;
|
|
58
|
+
if (!elevenKey()) {
|
|
59
|
+
console.log('no ELEVENLABS_API_KEY: the plan is written, no sounds generated; the world plays silent');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const guard = await creditGuard(elevenKey(), 'sound');
|
|
64
|
+
const python = musicFrom === 'eleven' ? undefined : musicgenPython();
|
|
65
|
+
if (musicFrom === 'musicgen' && !python) throw new Error('--music musicgen needs DIORAMA_PYTHON pointing at a Python with torch, transformers and scipy');
|
|
66
|
+
console.log(`music: ${python ? 'MusicGen (Meta open weights) on this machine' : 'ElevenLabs (set DIORAMA_PYTHON to a Python with torch + transformers for MusicGen)'}`);
|
|
67
|
+
const manifestFile = files.path('sound', 'sounds.json');
|
|
68
|
+
const made0: SoundManifest = fs.existsSync(manifestFile) ? SoundManifest.parse(files.readJson(manifestFile)) : { sounds: {}, failures: {} };
|
|
69
|
+
const kinds = only ? only.split(',').map((k) => Kind.parse(k.trim())) : undefined;
|
|
70
|
+
const ids = redo ? redo.split(',').map((id) => id.trim()).filter(Boolean) : undefined;
|
|
71
|
+
const fixes = readSoundFixes(files);
|
|
72
|
+
const made = await makeSounds(files.memo, plan, (id, mp3) => {
|
|
73
|
+
files.write(files.path('sound', `${id}.mp3`), mp3);
|
|
74
|
+
return `sound/${id}.mp3`;
|
|
75
|
+
}, { python, only: kinds, ids, fixes, log: (line) => console.log(line), old: made0.sounds, failedBefore: made0.failures, have: (e) => fs.existsSync(files.path(e.file)) });
|
|
76
|
+
const partial = !!(kinds || ids);
|
|
77
|
+
// A partial run keeps what it did not touch.
|
|
78
|
+
const sounds = { ...(partial ? made0.sounds : {}), ...made.sounds };
|
|
79
|
+
// Failed replacements cannot silently retain the rejected prior asset in a partial rebuild.
|
|
80
|
+
for (const id of Object.keys(made.failures)) delete sounds[id];
|
|
81
|
+
const failures = { ...(partial ? made0.failures : {}), ...made.failures };
|
|
82
|
+
for (const id of Object.keys(made.sounds)) delete failures[id];
|
|
83
|
+
files.write(manifestFile, { sounds, failures });
|
|
84
|
+
for (const [id, e] of Object.entries(sounds)) if (fs.existsSync(files.path(e.file))) seedLibrary(id, e, fs.readFileSync(files.path(e.file)));
|
|
85
|
+
const s = files.memo.sent;
|
|
86
|
+
console.log(`sound ${slug}: ${Object.keys(sounds).length} sounds · sent: spark ${s.spark}, sound ${s.sound}, music ${s.music} · ${await guard.report()} · ${manifestFile}`);
|
|
87
|
+
// A world plays without a sound: one that failed stays silent and the build goes on. Studio shows it with a retry.
|
|
88
|
+
if (Object.keys(failures).length) console.log(`warning: ${Object.keys(failures).length} sounds stay silent (failed quality or generation): ${Object.keys(failures).join(', ')}; see sound/sounds.json failures`);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// diorama sprites <slug> [key]: walk and talk sprite sheets drawn by Muse over the rigged model's poses,
|
|
2
|
+
// only those the world and its story use (src/model/plan.ts).
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { bindExistingAnimation, bindAnimation } from '../scene/animation-source.ts';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { gestureIndex } from '../play/gestures.ts';
|
|
7
|
+
import { defineCommand, takeOption } from '../registry.ts';
|
|
8
|
+
import { planSprites, type CharacterPlan } from '../model/plan.ts';
|
|
9
|
+
import { cutSheet, FACINGS, flatGuide, onGrey, paintSheet, POSE_OK, renderGuides, modelSheet, SHEET, TRACED, uvFaceLost, type Facing } from '../model/sprites.ts';
|
|
10
|
+
import { animationSettings, animationOptions, saveAnimationSettings, type AnimationSettings } from '../model/animation.ts';
|
|
11
|
+
import { Story } from '../story/schema.ts';
|
|
12
|
+
import { fixedLooks, readCastFixes } from '../scene/cast-fixes.ts';
|
|
13
|
+
import { refitIfOld } from '../model/fit.ts';
|
|
14
|
+
import { modelProblem } from '../model/glb.ts';
|
|
15
|
+
import { castStyle, readStyled } from '../world/styled.ts';
|
|
16
|
+
import { WorldFiles } from '../world/files.ts';
|
|
17
|
+
|
|
18
|
+
const LEGS_OK = 0.6;
|
|
19
|
+
// Walk takes painted again at most, while the best one is traced or left the pose.
|
|
20
|
+
const RETAKES = 4;
|
|
21
|
+
const mean = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
|
|
22
|
+
|
|
23
|
+
defineCommand({
|
|
24
|
+
name: 'sprites',
|
|
25
|
+
usage: 'sprites <slug> [key] walk and talk sheets for what the story uses: automatic UV/16 for new textured models, painted/8 fallback (cast/<key>/sprites/; needs `diorama model` first) [--texture model|views] [--coat-correction on|off] [--frames 8|16|32: model texture only; choices persist per character] [--plan: only print what would be drawn] [--take N] [--tries N: up to N takes per walk facing while a frame has its legs wrong]',
|
|
26
|
+
async run(args) {
|
|
27
|
+
const options = animationOptions(takeOption(args, 'texture', ''), takeOption(args, 'frames', ''), takeOption(args, 'coat-correction', ''));
|
|
28
|
+
const takeArg = takeOption(args, 'take', '');
|
|
29
|
+
const tries = Number(takeOption(args, 'tries', '1'));
|
|
30
|
+
const planOnly = args.includes('--plan');
|
|
31
|
+
const [slug, keyArg] = args.filter((a) => !a.startsWith('--'));
|
|
32
|
+
if (!slug) throw new Error('usage: diorama sprites <slug> [key]');
|
|
33
|
+
const files = new WorldFiles(slug);
|
|
34
|
+
const story = fs.existsSync(files.path('story.json')) ? Story.parse(files.readJson(files.path('story.json'))) : undefined;
|
|
35
|
+
const plans = (await planSprites(files, story)).filter((p) => !keyArg || p.key === keyArg);
|
|
36
|
+
if (!plans.length) throw new Error(keyArg ? `${keyArg} needs no sprites (not the hero, says no lines)` : 'nobody needs sprites');
|
|
37
|
+
const settings = new Map(plans.map((p) => [p.key, animationSettings(files, p.key, options)]));
|
|
38
|
+
if (!planOnly) files.write(files.path('cast', 'sprites-plan.json'), plans);
|
|
39
|
+
// Whether a usable 3D model is there: a creature without one (flat reconstructions) moves as its cut-out.
|
|
40
|
+
const modelled = (key: string) => fs.existsSync(files.path('cast', key, 'model', 'model.json')) && !modelProblem(files.path('cast', key, 'model'));
|
|
41
|
+
for (const p of plans) {
|
|
42
|
+
const talk = Object.entries(p.talk).map(([f, g]) => `${f} [${g.join(', ')}]`).join('; ');
|
|
43
|
+
console.log(p.creature ? `${p.key}: a creature · 0 Muse sheets (${modelled(p.key) ? 'its 3D model moves' : 'no usable 3D model: the cut-out moves as one piece'})` : `${p.key}: ${settings.get(p.key)!.texture === 'model' ? `UV ${settings.get(p.key)!.frames} frames` : 'painted 8 frames'} · walk ${p.walk.join(', ') || '-'} · talk ${talk || '-'} · ${settings.get(p.key)!.texture === 'model' ? 0 : p.walk.length + Object.keys(p.talk).length} Muse sheets`);
|
|
44
|
+
}
|
|
45
|
+
if (planOnly) return;
|
|
46
|
+
for (const p of plans) {
|
|
47
|
+
// A creature has no walk or gestures to paint: its 3D model hops, squashes and blinks in the game itself.
|
|
48
|
+
if (p.creature) {
|
|
49
|
+
console.log(modelled(p.key) ? `${p.key}: a creature, no sheets: it moves as its 3D model` : `${p.key}: a creature, no sheets and no usable 3D model: the cut-out moves as one piece (cast_animate, or diorama model ${slug} ${p.key}, tries a model)`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!fs.existsSync(files.path('cast', p.key, 'model', 'model.json'))) {
|
|
53
|
+
console.log(`${p.key}: no model yet, skipped (diorama model ${slug} ${p.key})`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const bad = modelProblem(files.path('cast', p.key, 'model'));
|
|
57
|
+
if (bad) {
|
|
58
|
+
console.log(`${p.key}: model is ${bad}, skipped: walks with the 2D rig (diorama model ${slug} ${p.key} tries again)`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
bindExistingAnimation(files, p.key);
|
|
62
|
+
const selected = settings.get(p.key)!;
|
|
63
|
+
if (selected.texture === 'model') await drawModelCharacter(files, p, selected);
|
|
64
|
+
else {
|
|
65
|
+
// The person's sprites_redo rounds (cast/fixes.json) start past the takes painted before, also in a rebuild.
|
|
66
|
+
const take = takeArg ? Number(takeArg) : (RETAKES + 1) * (readCastFixes(files)[p.key]?.spriteTake ?? 0);
|
|
67
|
+
await drawCharacter(files, p, take, tries, selected);
|
|
68
|
+
}
|
|
69
|
+
if (selected.texture === 'model' || Object.keys(options).length || fs.existsSync(files.path('cast', p.key, 'animation.json'))) saveAnimationSettings(files, p.key, selected);
|
|
70
|
+
const modelFile = files.path('cast', p.key, 'model', 'model.json');
|
|
71
|
+
const model = files.readJson(modelFile) as Record<string, unknown>;
|
|
72
|
+
if (selected.texture === 'model') model.texture = 'model';
|
|
73
|
+
else delete model.texture;
|
|
74
|
+
if (selected.coatCorrection !== undefined) model.coatCorrection = selected.coatCorrection;
|
|
75
|
+
files.write(modelFile, model);
|
|
76
|
+
bindAnimation(files, p.key, 'sprites');
|
|
77
|
+
}
|
|
78
|
+
console.log(`calls sent: muse ${files.memo.sent.muse}, spark ${files.memo.sent.spark}`);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
async function drawCharacter(files: WorldFiles, plan: CharacterPlan, take: number, tries: number, settings: AnimationSettings) {
|
|
83
|
+
const { key } = plan;
|
|
84
|
+
const world = files.world();
|
|
85
|
+
const who = [world.hero, ...world.places.flatMap((p) => p.things)].find((w) => w.key === key);
|
|
86
|
+
const looks = who ? fixedLooks(files, who).looks : '';
|
|
87
|
+
// A world given another style names it, so the sheets follow the restyled character, not the gouache they were made for.
|
|
88
|
+
const style = readStyled(files).from ? castStyle(files, world, key) : undefined;
|
|
89
|
+
const modelDir = files.path('cast', key, 'model');
|
|
90
|
+
const projected: AnimationSettings = { ...settings, texture: 'views', frames: 8 };
|
|
91
|
+
// An older model's views were fitted on the whole silhouette only: the guides would show the profile's face twice.
|
|
92
|
+
if (await refitIfOld(modelDir)) console.log(`${key}: views fitted again, slice by slice`);
|
|
93
|
+
const view = (name: string) => fs.readFileSync(`${modelDir}/${name}.png`);
|
|
94
|
+
const out = (name: string) => files.path('cast', key, 'sprites', name);
|
|
95
|
+
|
|
96
|
+
// Walking: the first facing (the profile when it is used) is painted first; the others also see it,
|
|
97
|
+
// so patches and colours stay the same. Muse often hands the textured guide back with a filter over it
|
|
98
|
+
// (mean `same` above TRACED): that sheet looks like the 3D render, and as the first facing it passes the
|
|
99
|
+
// look on to every other facing. So a take that is traced or left the pose is painted again, up to
|
|
100
|
+
// RETAKES times, and the first drawn take that keeps the pose wins. Retakes cycle through three guides,
|
|
101
|
+
// because no one of them works for every facing (foghorn, 3 takes each): over a grey clay figure Muse
|
|
102
|
+
// draws the back view every time but turns the three-quarter back into a profile; over flat colour
|
|
103
|
+
// blocks it keeps the pose about half the time; over the textured render it traces about 3 takes in 4.
|
|
104
|
+
// A frame whose legs do not follow the guide makes the walk skip: with --tries, more takes, and the
|
|
105
|
+
// whole take whose legs follow best wins (frames mixed from several takes do not match).
|
|
106
|
+
const facings: Record<string, { sheet: string; yaw: number; stand: number; off: number[]; iou: number[]; legs: number[]; same: number[] }> = {};
|
|
107
|
+
if (plan.walk.length) {
|
|
108
|
+
const guides = await renderGuides(modelDir, plan.walk, undefined, false, projected);
|
|
109
|
+
const clay = await renderGuides(modelDir, plan.walk, undefined, true, projected);
|
|
110
|
+
const shown: Record<string, Buffer[]> = {};
|
|
111
|
+
for (const f of plan.walk) {
|
|
112
|
+
const grey = await onGrey(guides[f]);
|
|
113
|
+
files.write(out(`guide-${f}.png`), grey);
|
|
114
|
+
shown[f] = [grey, await onGrey(clay[f]), await flatGuide(grey)];
|
|
115
|
+
}
|
|
116
|
+
const first: Facing = plan.walk.includes('right') ? 'right' : plan.walk[0];
|
|
117
|
+
const refsFor = (f: Facing, painted?: Buffer) => [f.startsWith('back') ? view('back') : view('front'), painted ?? view('right')];
|
|
118
|
+
const painted: Record<string, Buffer> = {};
|
|
119
|
+
const walk = async (f: Facing, refs: Buffer[]) => {
|
|
120
|
+
type Take = Awaited<ReturnType<typeof cutSheet>> & { painted: Buffer; n: number };
|
|
121
|
+
const takes: Take[] = [];
|
|
122
|
+
let n = take;
|
|
123
|
+
const another = async () => {
|
|
124
|
+
const p = await paintSheet(files.memo, key, f, looks, shown[f][(n - take) % shown[f].length], refs, n, 0, style);
|
|
125
|
+
// Always measured against the textured guide: that is the look a traced sheet keeps.
|
|
126
|
+
takes.push({ ...(await cutSheet(guides[f], p)), painted: p, n: n++ });
|
|
127
|
+
};
|
|
128
|
+
const traced = (t: Take) => mean(t.same) > TRACED;
|
|
129
|
+
// A take that left the pose (Muse turned some frames to another facing) is worst, then traced takes;
|
|
130
|
+
// among drawn takes the legs that follow best win, among traced ones the least traced.
|
|
131
|
+
const rank = (t: Take) => (mean(t.iou) < POSE_OK ? 0 : traced(t) ? 1 : 2);
|
|
132
|
+
const pick = () => takes.reduce((a, b) => {
|
|
133
|
+
if (rank(a) !== rank(b)) return rank(b) > rank(a) ? b : a;
|
|
134
|
+
return traced(a) ? (mean(b.same) < mean(a.same) ? b : a) : (mean(b.legs) > mean(a.legs) ? b : a);
|
|
135
|
+
});
|
|
136
|
+
await another();
|
|
137
|
+
for (let t = 1; t <= RETAKES && rank(pick()) < 2; t++) await another();
|
|
138
|
+
for (let t = 1; t < tries && Math.min(...pick().legs) < LEGS_OK; t++) await another();
|
|
139
|
+
const best = pick();
|
|
140
|
+
painted[f] = best.painted;
|
|
141
|
+
files.write(out(`painted-${f}.png`), best.painted);
|
|
142
|
+
files.write(out(`${f}.png`), best.sheet);
|
|
143
|
+
facings[f] = { sheet: `${f}.png`, yaw: FACINGS[f], stand: best.stand, off: best.off, iou: best.iou, legs: best.legs, same: best.same };
|
|
144
|
+
console.log(`${key} walk ${f}: take ${best.n + 1} (${['textured', 'clay', 'flat'][(best.n - take) % 3]} guide, ${takes.length} painted), pose IoU ${mean(best.iou).toFixed(3)}, legs ${mean(best.legs).toFixed(3)} (worst ${Math.min(...best.legs)}), same ${mean(best.same).toFixed(3)}${best.off.length ? `, off the walk: cell ${best.off.join(', ')}` : ''}${traced(best) ? ' <- Muse kept the 3D look, try --take' : mean(best.iou) < POSE_OK ? ' <- frames left the pose, try --take' : ''}`);
|
|
145
|
+
};
|
|
146
|
+
await walk(first, [view('front'), view('right')]);
|
|
147
|
+
await Promise.all(plan.walk.filter((f) => f !== first).map((f) => walk(f, refsFor(f, painted[first]))));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Talking: the gestures this character's lines use, in the facing its conversation is held in. The
|
|
151
|
+
// painted views are the reference, not a walk sheet: with a walk sheet Muse draws the legs walking.
|
|
152
|
+
const talk: Record<string, { sheet: string; yaw: number; gestures: string[]; iou: number[]; same: number[] }> = {};
|
|
153
|
+
await Promise.all(Object.entries(plan.talk).map(async ([facing, names]) => {
|
|
154
|
+
const f = facing as Facing;
|
|
155
|
+
const used = names.map(gestureIndex);
|
|
156
|
+
// Muse redraws a half-empty grid as a few big figures, so the used gestures repeat to fill all 8
|
|
157
|
+
// cells (the sheet costs the same); cell i < used.length is gestures[i].
|
|
158
|
+
const cells = Array.from({ length: SHEET.cols * SHEET.rows }, (_, i) => used[i % used.length]);
|
|
159
|
+
const guide = (await renderGuides(modelDir, [f], cells, false, projected))[f];
|
|
160
|
+
const g = await onGrey(guide);
|
|
161
|
+
files.write(out(`talk-guide-${f}.png`), g);
|
|
162
|
+
const refs = [view('front'), view('right')];
|
|
163
|
+
let p = await paintSheet(files.memo, key, f, looks, g, refs, take, used.length, style);
|
|
164
|
+
let cut = await cutSheet(guide, p);
|
|
165
|
+
const score = () => mean(cut.same.slice(0, used.length));
|
|
166
|
+
// From some angles the hands hide in front of the body and Muse hands the guide back unchanged.
|
|
167
|
+
for (let t = 1; t <= 2 && score() > TRACED; t++) {
|
|
168
|
+
p = await paintSheet(files.memo, key, f, looks, g, refs, take + t, used.length, style);
|
|
169
|
+
cut = await cutSheet(guide, p);
|
|
170
|
+
}
|
|
171
|
+
files.write(out(`talk-painted-${f}.png`), p);
|
|
172
|
+
files.write(out(`talk-${f}.png`), cut.sheet);
|
|
173
|
+
talk[f] = { sheet: `talk-${f}.png`, yaw: FACINGS[f], gestures: names, iou: cut.iou.slice(0, used.length), same: cut.same.slice(0, used.length) };
|
|
174
|
+
console.log(`${key} talk ${f} [${names.join(', ')}]: pose IoU ${mean(cut.iou.slice(0, used.length)).toFixed(3)}, same ${score().toFixed(3)}${score() > TRACED ? ' <- Muse kept the 3D look, try --take' : ''}`);
|
|
175
|
+
}));
|
|
176
|
+
|
|
177
|
+
files.write(out('sprites.json'), {
|
|
178
|
+
cols: SHEET.cols, rows: SHEET.rows, frames: SHEET.cols * SHEET.rows, width: SHEET.width, height: SHEET.height,
|
|
179
|
+
// In each cell the feet stand at (cell width / 2, cell height * feetAt); the figure is about `px` tall.
|
|
180
|
+
feetAt: SHEET.feetAt, px: SHEET.px,
|
|
181
|
+
// Walk sheets: 8 frames of a full cycle; `stand` is the frame shown standing still; a walk skips the `off` cells.
|
|
182
|
+
facings,
|
|
183
|
+
// Talk sheets: cell i is gestures[i]; cell 0 is `listen` (also how an NPC stands when idle).
|
|
184
|
+
talk,
|
|
185
|
+
// The gesture for each line this character says, by its text.
|
|
186
|
+
lines: plan.lines,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Direct UV sheets keep anatomy tied to the mesh; no Muse repainting or silhouette fitting. */
|
|
191
|
+
export async function drawModelCharacter(files: WorldFiles, plan: CharacterPlan, settings: AnimationSettings) {
|
|
192
|
+
const modelDir = files.path('cast', plan.key, 'model');
|
|
193
|
+
const grid = modelSheet(settings.frames);
|
|
194
|
+
// TRELLIS often bakes a blank face: then the front painting goes on it (Spark compares the two heads once).
|
|
195
|
+
const modelFile = path.join(modelDir, 'model.json');
|
|
196
|
+
if (fs.existsSync(modelFile) && fs.existsSync(path.join(modelDir, 'front.png'))) {
|
|
197
|
+
const fromViews = await uvFaceLost(files.memo, plan.key, modelDir, settings).catch((error) => {
|
|
198
|
+
console.log(`${plan.key}: face check failed (${error instanceof Error ? error.message : String(error)}); the model texture's face stays`);
|
|
199
|
+
return false;
|
|
200
|
+
});
|
|
201
|
+
const info = files.readJson(modelFile) as Record<string, unknown>;
|
|
202
|
+
if (fromViews) info.face = 'views';
|
|
203
|
+
else delete info.face;
|
|
204
|
+
files.write(modelFile, info);
|
|
205
|
+
console.log(`${plan.key}: face ${fromViews ? 'from the front painting (the UV face came out blank)' : 'from the model texture'}`);
|
|
206
|
+
}
|
|
207
|
+
const facings: Record<string, { sheet: string; yaw: number; stand: number; off: number[] }> = {};
|
|
208
|
+
const talk: Record<string, { sheet: string; yaw: number; gestures: string[] }> = {};
|
|
209
|
+
const images: Record<string, Buffer> = {};
|
|
210
|
+
// Finish rendering everything before replacing any active sheet or its metadata.
|
|
211
|
+
if (plan.walk.length) {
|
|
212
|
+
const walk = await renderGuides(modelDir, plan.walk, undefined, false, settings);
|
|
213
|
+
for (const f of plan.walk) {
|
|
214
|
+
const sheet = `model-${settings.frames}-${f}.png`;
|
|
215
|
+
images[sheet] = walk[f];
|
|
216
|
+
facings[f] = { sheet, yaw: FACINGS[f], stand: settings.frames, off: [] };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const [facing, names] of Object.entries(plan.talk)) {
|
|
220
|
+
const f = facing as Facing;
|
|
221
|
+
const gestures = names.map(gestureIndex);
|
|
222
|
+
if (gestures.some((i) => i < 0)) throw new Error(`${plan.key}: unknown talking gesture`);
|
|
223
|
+
const sheet = `model-${settings.frames}-talk-${f}.png`;
|
|
224
|
+
images[sheet] = (await renderGuides(modelDir, [f], gestures, false, settings))[f];
|
|
225
|
+
talk[f] = { sheet, yaw: FACINGS[f], gestures: names };
|
|
226
|
+
}
|
|
227
|
+
const out = (name: string) => files.path('cast', plan.key, 'sprites', name);
|
|
228
|
+
for (const [name, image] of Object.entries(images)) files.write(out(name), image);
|
|
229
|
+
const { elevation: _elevation, ...layout } = grid;
|
|
230
|
+
files.write(out('sprites.json'), { ...layout, facings, talk, lines: plan.lines });
|
|
231
|
+
console.log(`${plan.key}: UV sprites · ${settings.frames} walk frames + standing · ${Object.keys(talk).length} talk sheets`);
|
|
232
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// diorama story <slug>: puzzles, items, dialogue and endings for world.json, checked by playing every state.
|
|
2
|
+
// The person's wish and reworded lines (story-fixes.json) are applied every time.
|
|
3
|
+
// diorama story <slug> --check: re-check story.json.
|
|
4
|
+
import { defineCommand } from '../registry.ts';
|
|
5
|
+
import { checkStory } from '../story/check.ts';
|
|
6
|
+
import { designStory } from '../story/design.ts';
|
|
7
|
+
import { applyTexts, readStoryFixes } from '../story/fixes.ts';
|
|
8
|
+
import { WorldFiles } from '../world/files.ts';
|
|
9
|
+
|
|
10
|
+
defineCommand({
|
|
11
|
+
name: 'story',
|
|
12
|
+
usage: 'story <slug> write story.json with Muse Spark and check every ending is reachable [--check]',
|
|
13
|
+
async run(args) {
|
|
14
|
+
const checkOnly = args.includes('--check');
|
|
15
|
+
const [slug] = args.filter((a) => !a.startsWith('--'));
|
|
16
|
+
if (!slug) throw new Error('usage: diorama story <slug> [--check]');
|
|
17
|
+
const files = new WorldFiles(slug);
|
|
18
|
+
const world = files.world();
|
|
19
|
+
const fixes = readStoryFixes(files);
|
|
20
|
+
let report;
|
|
21
|
+
if (checkOnly) report = checkStory(world, files.readJson(files.path('story.json')));
|
|
22
|
+
else {
|
|
23
|
+
if (fixes.wish) console.log(` your wish: ${fixes.wish}`);
|
|
24
|
+
const designed = await designStory(files.memo, world, fixes.wish);
|
|
25
|
+
const { story, skipped } = applyTexts(designed.story, fixes);
|
|
26
|
+
for (const path of skipped) console.log(` your wording for ${path} no longer fits the story; left out`);
|
|
27
|
+
if (Object.keys(fixes.texts).length > skipped.length) console.log(` ${Object.keys(fixes.texts).length - skipped.length} line(s) in your wording`);
|
|
28
|
+
files.write(files.path('story.json'), story);
|
|
29
|
+
report = checkStory(world, story);
|
|
30
|
+
}
|
|
31
|
+
if (report.issues.length) throw new Error(`story.json has ${report.issues.length} issue(s):\n- ${report.issues.join('\n- ')}`);
|
|
32
|
+
files.write(files.path('routes.json'), report.routes);
|
|
33
|
+
console.log(`${report.states} states played, every ending reachable, no dead ends`);
|
|
34
|
+
for (const r of report.routes) console.log(` ${r.ending} in ${r.steps.length} steps: ${r.steps.join(' → ')}`);
|
|
35
|
+
if (!checkOnly) console.log(`spark calls sent: ${files.memo.sent.spark}`);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// diorama studio [slug]: the GUI. A local page to start, watch and fix worlds, driven by the same actions
|
|
2
|
+
// as `diorama action` and the Muse Code agent.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { actions } from '../actions/registry.ts';
|
|
5
|
+
import { defineCommand, takeOption } from '../registry.ts';
|
|
6
|
+
import { writeStudioFile } from '../studio/discovery.ts';
|
|
7
|
+
import type { StudioEvent } from '../studio/events.ts';
|
|
8
|
+
import { startStudio } from '../studio/server.ts';
|
|
9
|
+
|
|
10
|
+
defineCommand({
|
|
11
|
+
name: 'studio',
|
|
12
|
+
usage: 'studio [slug] open Diorama Studio, the GUI, on 127.0.0.1 [--port N] [--no-open] [--quiet] [--verbose: every line jobs print]',
|
|
13
|
+
async run(args) {
|
|
14
|
+
const port = Number(takeOption(args, 'port', '0'));
|
|
15
|
+
const noOpen = args.includes('--no-open');
|
|
16
|
+
const quiet = args.includes('--quiet');
|
|
17
|
+
const verbose = args.includes('--verbose');
|
|
18
|
+
const slug = args.find((a) => !a.startsWith('--'));
|
|
19
|
+
const studio = await startStudio({ port });
|
|
20
|
+
const forget = writeStudioFile(process.cwd(), { url: studio.url, agentToken: studio.agentToken });
|
|
21
|
+
// The token rides in the fragment: browsers never send it to a server or put it in a Referer.
|
|
22
|
+
const link = `${studio.url}${slug ? `w/${slug}` : ''}#${studio.token}`;
|
|
23
|
+
console.log(`Diorama Studio: ${link}\n(only this link opens it; Ctrl+C to stop)`);
|
|
24
|
+
if (!quiet) studio.events.subscribe((e) => {
|
|
25
|
+
const line = describe(e, verbose);
|
|
26
|
+
if (line) console.log(`${new Date(e.at).toTimeString().slice(0, 8)} ${e.world ? `${e.world}: ` : ''}${line}`);
|
|
27
|
+
});
|
|
28
|
+
if (!noOpen) {
|
|
29
|
+
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
30
|
+
spawn(opener, [link], { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
|
|
31
|
+
}
|
|
32
|
+
const stop = async () => {
|
|
33
|
+
forget();
|
|
34
|
+
await studio.close();
|
|
35
|
+
process.exit(0);
|
|
36
|
+
};
|
|
37
|
+
process.on('SIGINT', stop);
|
|
38
|
+
process.on('SIGTERM', stop);
|
|
39
|
+
await new Promise(() => {});
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** One terminal line per event worth seeing: what runs, what ends, what the agent does. Keys are already redacted. */
|
|
44
|
+
function describe(e: StudioEvent, verbose: boolean): string | null {
|
|
45
|
+
const job = e.job as { label?: string; by?: string; state?: string; error?: string } | undefined;
|
|
46
|
+
switch (e.type) {
|
|
47
|
+
case 'job.start': return `started ${job?.label} (${job?.by})`;
|
|
48
|
+
case 'job.end': return `${job?.state} ${job?.label}${job?.error ? `: ${job.error}` : ''}`;
|
|
49
|
+
case 'step.start': return `> ${e.step}`;
|
|
50
|
+
case 'step.end': return `ok ${e.step} ${e.seconds}s`;
|
|
51
|
+
case 'step.fail': return `FAILED ${e.step}: ${e.error}`;
|
|
52
|
+
case 'place.verdict': return `${e.place}: ${e.ok ? 'playable' : 'NOT playable'}`;
|
|
53
|
+
case 'action.start': return e.by === 'agent' || !actions.get(String(e.action))?.reads ? `${e.by}: ${e.action}` : null;
|
|
54
|
+
case 'action.end': return e.ok ? null : `${e.by}: ${e.action} failed: ${e.error}`;
|
|
55
|
+
case 'history.undo': return `${e.by}: undo to ${e.id}`;
|
|
56
|
+
case 'lock.changed': return `${e.by}: ${e.locked ? 'locked' : 'unlocked'} ${e.place ?? 'world'}`;
|
|
57
|
+
case 'agent.user': return `you -> agent: ${String(e.text).slice(0, 120)}`;
|
|
58
|
+
case 'agent.message': return `agent: ${String(e.text).slice(0, 200)}`;
|
|
59
|
+
case 'agent.turn.end': return e.status === 'completed' ? null : `agent stopped: ${e.error ?? e.status}`;
|
|
60
|
+
case 'agent.error': return `agent error: ${e.text}`;
|
|
61
|
+
case 'agent.agent': return `agent is now ${e.label}`;
|
|
62
|
+
case 'log': return verbose || e.stream === 'err' ? ` ${e.line}` : null;
|
|
63
|
+
default: return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// diorama style <slug> "<style>": give a world (or one place, or the cast) another painting style, and repaint
|
|
2
|
+
// what is made in it with the composition kept, so masks, walk grids and the person's fixes still fit.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { defineCommand, registry, takeOption } from '../registry.ts';
|
|
5
|
+
import { readCastFixes, castFixesFile } from '../scene/cast-fixes.ts';
|
|
6
|
+
import { readCorrections, writeCorrections } from '../scene/corrections.ts';
|
|
7
|
+
import { WorldFiles } from '../world/files.ts';
|
|
8
|
+
import { castStyle, placeStyle, readStyled, writeStyled } from '../world/styled.ts';
|
|
9
|
+
import { castKeys, planLine, stylePlan, type Scope } from '../world/style-plan.ts';
|
|
10
|
+
|
|
11
|
+
const run = (name: string, args: string[]) => registry.get(name)!.run(args);
|
|
12
|
+
|
|
13
|
+
defineCommand({
|
|
14
|
+
name: 'style',
|
|
15
|
+
usage: 'style <slug> "<style>" paint the world in another style: each place repainted with its composition kept (then masks to verify again), the cast repainted, their models and sprites made again [--place KEY: only that place] [--cast KEY|all: only the cast] [--estimate: only say what it redoes and costs]',
|
|
16
|
+
async run(args) {
|
|
17
|
+
const placeKey = takeOption(args, 'place', '');
|
|
18
|
+
const castKey = takeOption(args, 'cast', '');
|
|
19
|
+
const estimate = args.includes('--estimate');
|
|
20
|
+
const [slug, style] = args.filter((a) => a !== '--estimate');
|
|
21
|
+
if (!slug || !style) throw new Error('usage: diorama style <slug> "<style>" [--place KEY] [--cast KEY|all] [--estimate]');
|
|
22
|
+
if (style.trim().length < 10) throw new Error('say the style in a few words: medium, palette, light (at least 10 letters)');
|
|
23
|
+
const files = new WorldFiles(slug);
|
|
24
|
+
const world = files.world();
|
|
25
|
+
const scope: Scope = placeKey ? 'place' : castKey ? 'cast' : 'world';
|
|
26
|
+
const key = placeKey || (castKey && castKey !== 'all' ? castKey : undefined);
|
|
27
|
+
if (placeKey && !world.places.some((p) => p.key === placeKey)) throw new Error(`${slug} has no place ${placeKey}: ${world.places.map((p) => p.key).join(', ')}`);
|
|
28
|
+
if (scope === 'cast' && key && !castKeys(world, files).includes(key)) throw new Error(`${slug} has no character ${key}: ${castKeys(world, files).join(', ')}`);
|
|
29
|
+
const plan = stylePlan(files, world, scope, key);
|
|
30
|
+
console.log(`style ${scope}${key ? ` ${key}` : ''}: ${planLine(plan)}`);
|
|
31
|
+
if (estimate) return;
|
|
32
|
+
|
|
33
|
+
// What is painted already keeps the style it was painted in, so paint and cast see the change and repaint it.
|
|
34
|
+
const styled = readStyled(files);
|
|
35
|
+
for (const p of world.places) if (!styled.places[p.key] && fs.existsSync(files.place(p.key, 'painting.png'))) styled.places[p.key] = placeStyle(files, world, p.key);
|
|
36
|
+
for (const k of castKeys(world, files)) if (!styled.cast[k] && fs.existsSync(files.path('cast', `${k}-painting.png`))) styled.cast[k] = castStyle(files, world, k);
|
|
37
|
+
styled.from ??= world.style;
|
|
38
|
+
writeStyled(files, styled);
|
|
39
|
+
|
|
40
|
+
// The new style: the world's (a place's or a character's own style gives way to it), or one place's or character's own.
|
|
41
|
+
const fixes = readCastFixes(files);
|
|
42
|
+
const castFix = (k: string, s: string | undefined) => {
|
|
43
|
+
if (s) fixes[k] = { ...fixes[k], style: s };
|
|
44
|
+
else if (fixes[k]) delete fixes[k].style;
|
|
45
|
+
};
|
|
46
|
+
if (scope === 'world') {
|
|
47
|
+
files.write(files.path('world.json'), { ...JSON.parse(fs.readFileSync(files.path('world.json'), 'utf8')), style });
|
|
48
|
+
for (const p of world.places) {
|
|
49
|
+
const c = readCorrections(files.place(p.key));
|
|
50
|
+
if (!c.style) continue;
|
|
51
|
+
delete c.style;
|
|
52
|
+
writeCorrections(files.place(p.key), c);
|
|
53
|
+
}
|
|
54
|
+
for (const k of Object.keys(fixes)) castFix(k, undefined);
|
|
55
|
+
} else if (scope === 'place') {
|
|
56
|
+
const c = readCorrections(files.place(placeKey));
|
|
57
|
+
c.style = style;
|
|
58
|
+
fs.mkdirSync(files.place(placeKey), { recursive: true });
|
|
59
|
+
writeCorrections(files.place(placeKey), c);
|
|
60
|
+
} else {
|
|
61
|
+
for (const k of key ? [key] : castKeys(world, files)) castFix(k, style);
|
|
62
|
+
}
|
|
63
|
+
if (scope !== 'place') files.write(castFixesFile(files), fixes);
|
|
64
|
+
|
|
65
|
+
if (plan.places.length) {
|
|
66
|
+
if (scope === 'place') await run('build', [slug, '--from', 'paint', '--to', 'verify', '--place', placeKey]);
|
|
67
|
+
else await run('build', [slug, '--from', 'paint', '--to', 'reactions']);
|
|
68
|
+
// Icons cut from a painting are cut again from the new one; painted ones are painted in the new style.
|
|
69
|
+
if (scope === 'place') await run('icons', [slug]);
|
|
70
|
+
}
|
|
71
|
+
if (plan.cast.length) {
|
|
72
|
+
console.log('\n# cast');
|
|
73
|
+
await run('cast', [slug, ...plan.cast]);
|
|
74
|
+
for (const k of plan.models) {
|
|
75
|
+
console.log(`\n# model ${k}`);
|
|
76
|
+
await run('model', [slug, k]);
|
|
77
|
+
}
|
|
78
|
+
for (const k of plan.sprites) {
|
|
79
|
+
console.log(`\n# sprites ${k}`);
|
|
80
|
+
await run('sprites', [slug, k]);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (plan.faces.length) await run('faces', [slug, ...plan.faces]);
|
|
84
|
+
if (scope === 'world' && plan.places.length) await run('build', [slug, '--from', 'verify', '--to', 'verify']);
|
|
85
|
+
console.log(`\nstyle ${slug}: ${scope}${key ? ` ${key}` : ''} now "${style}"`);
|
|
86
|
+
},
|
|
87
|
+
});
|