@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,116 @@
|
|
|
1
|
+
// A world told in another language: every word the player reads or hears (title, pitch, names, the whole story)
|
|
2
|
+
// translated in one Spark pass, so names stay the same everywhere they appear. What the painters read (style,
|
|
3
|
+
// shows, mood, looks, a change's looks) is English already and stays; keys and flags never change, so the
|
|
4
|
+
// puzzles, masks, paintings and sounds all still fit. The voices are cast again for the new language afterwards.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
8
|
+
import type { Memo } from '../meta/memo.ts';
|
|
9
|
+
import { soundQuality } from '../sound/cost.ts';
|
|
10
|
+
import { languageKey } from '../sound/language.ts';
|
|
11
|
+
import { needsDesign, storyLines, Voices } from '../sound/voices.ts';
|
|
12
|
+
import type { Story } from '../story/schema.ts';
|
|
13
|
+
import type { WorldFiles } from './files.ts';
|
|
14
|
+
import type { World } from './schema.ts';
|
|
15
|
+
|
|
16
|
+
/** Story fields the player reads or hears. A change's `looks` is for the painter: it stays English. */
|
|
17
|
+
const STORY_TEXT = new Set(['intro', 'say', 'says', 'text', 'title', 'name', 'nope']);
|
|
18
|
+
|
|
19
|
+
/** Every player-facing text of a world and its story, by an id that says where it lives. */
|
|
20
|
+
export function worldTexts(world: World, story: Story | null): Record<string, string> {
|
|
21
|
+
const out: Record<string, string> = { 'world.title': world.title, 'world.pitch': world.pitch };
|
|
22
|
+
for (const [key, name] of Object.entries(world.names ?? {})) out[`names.${key}`] = name;
|
|
23
|
+
const walk = (node: unknown, path: string[]) => {
|
|
24
|
+
if (Array.isArray(node)) node.forEach((n, i) => walk(n, [...path, String(i)]));
|
|
25
|
+
else if (node && typeof node === 'object') {
|
|
26
|
+
for (const [k, v] of Object.entries(node)) {
|
|
27
|
+
if (typeof v === 'string') {
|
|
28
|
+
if (STORY_TEXT.has(k) && !(path[0] === 'changes')) out[`story.${[...path, k].join('.')}`] = v;
|
|
29
|
+
} else walk(v, [...path, k]);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
if (story) walk(story, []);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The world and story with the translated texts put back at their ids. */
|
|
38
|
+
export function applyWorldTexts(world: World, story: Story | null, texts: Record<string, string>, language: string): { world: World; story: Story | null } {
|
|
39
|
+
const w: World = structuredClone(world);
|
|
40
|
+
const s: Story | null = story && structuredClone(story);
|
|
41
|
+
for (const [id, text] of Object.entries(texts)) {
|
|
42
|
+
const [head, ...rest] = id.split('.');
|
|
43
|
+
if (head === 'world') (w as Record<string, unknown>)[rest[0]] = text;
|
|
44
|
+
else if (head === 'names') w.names = { ...w.names, [rest.join('.')]: text };
|
|
45
|
+
else if (head === 'story' && s) {
|
|
46
|
+
let at: unknown = s;
|
|
47
|
+
const last = rest.pop()!;
|
|
48
|
+
for (const p of rest) at = Array.isArray(at) ? at[Number(p)] : (at as Record<string, unknown>)[p];
|
|
49
|
+
if (at && typeof at === 'object') (at as Record<string, unknown>)[last] = text;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (languageKey(language) === 'en') delete w.language;
|
|
53
|
+
else w.language = language;
|
|
54
|
+
return { world: w, story: s };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const Answer = z.object({ texts: z.record(z.string(), z.string()) });
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One Spark pass over all the texts, so a name reads the same in the title, the dialogue and the inventory.
|
|
61
|
+
* Ids missing from the answer are asked for once more, with the names already chosen; an empty answer throws.
|
|
62
|
+
*/
|
|
63
|
+
export async function translateTexts(memo: Memo, texts: Record<string, string>, from: string, to: string, kids: boolean): Promise<Record<string, string>> {
|
|
64
|
+
const ask = (todo: Record<string, string>, names: Record<string, string>) => askSparkJson(memo, {
|
|
65
|
+
label: `language:${languageKey(to)}`,
|
|
66
|
+
schema: Answer,
|
|
67
|
+
prompt: `Translate the player-facing text of a point-and-click adventure game from ${from} to ${to}.
|
|
68
|
+
Write it as a native ${to} game writer would${kids ? ' for children aged 5 to 7, who hear every line read aloud' : ''}: natural, idiomatic and warm, not word for word. Keep each line's meaning, mood and humour, and keep it about as short (never longer by more than a few words).
|
|
69
|
+
- Names of characters, places and items ("names.*", items' "name"): give each a natural ${to} name that keeps the idea or pun of the original (a cat named after butter keeps a buttery name). Use exactly the same name every time it appears in any line, with the same capitalisation style as the original (a lowercase item name stays lowercase).
|
|
70
|
+
- A line that is a statement stays a statement; a question stays a question. Keep sound tags in square brackets ([laughs], [whispers]) exactly as they are, untranslated.
|
|
71
|
+
- Numbers, riddles and wordplay must still work in ${to}: adapt them, keep the answer the same.
|
|
72
|
+
- Every text must be fully in ${to}: no ${from} words left, except a name that is the same in both languages.
|
|
73
|
+
${Object.keys(names).length ? `Names already chosen (use them): ${JSON.stringify(names)}\n` : ''}Texts (id: text): ${JSON.stringify(todo)}
|
|
74
|
+
Reply with JSON only: {"texts": {"<the same id>": "<${to} text>", ...}} with every id above exactly once.`,
|
|
75
|
+
});
|
|
76
|
+
const first = (await ask(texts, {})).texts;
|
|
77
|
+
const done = Object.fromEntries(Object.keys(texts).filter((id) => first[id]?.trim()).map((id) => [id, first[id].trim()]));
|
|
78
|
+
const missing = Object.fromEntries(Object.entries(texts).filter(([id]) => !done[id]));
|
|
79
|
+
if (Object.keys(missing).length) {
|
|
80
|
+
const names = Object.fromEntries(Object.entries(done).filter(([id]) => id.startsWith('names.') || /\.name$/.test(id)));
|
|
81
|
+
const again = (await ask(missing, names)).texts;
|
|
82
|
+
for (const id of Object.keys(missing)) if (again[id]?.trim()) done[id] = again[id].trim();
|
|
83
|
+
}
|
|
84
|
+
const still = Object.keys(texts).filter((id) => !done[id]);
|
|
85
|
+
if (still.length) throw new Error(`Spark left ${still.length} text(s) untranslated: ${still.slice(0, 5).join(', ')}`);
|
|
86
|
+
return done;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A person's voice wish written for the old language ("Native Polish speaker…") says the new one instead. */
|
|
90
|
+
export function relanguage(text: string, from: string, to: string): string {
|
|
91
|
+
return text.replace(new RegExp(`\\b${from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi'), to);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type LanguagePlan = { from: string; to: string; same: boolean; texts: number; lines: number; characters: number; credits: number; designs: string[]; quality: 'draft' | 'final' };
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What a change of language redoes: one Spark pass over the texts, then every spoken line read again (about one
|
|
98
|
+
* ElevenLabs credit a character in final quality, half in draft) and a Voice Design for each child, teen or old speaker.
|
|
99
|
+
* The lines are counted in the old language: a translation is about as long.
|
|
100
|
+
*/
|
|
101
|
+
export function languagePlan(files: WorldFiles, world: World, story: Story | null, language: string): LanguagePlan {
|
|
102
|
+
const from = world.language ?? 'English';
|
|
103
|
+
const same = languageKey(from) === languageKey(language);
|
|
104
|
+
const kids = world.audience === 'kids';
|
|
105
|
+
const lines = story ? storyLines(world, story, kids) : [];
|
|
106
|
+
const characters = lines.reduce((n, l) => n + l.text.length, 0);
|
|
107
|
+
const quality = soundQuality();
|
|
108
|
+
const voices = fs.existsSync(files.path('voices.json')) ? Voices.safeParse(files.readJson(files.path('voices.json'))) : undefined;
|
|
109
|
+
const designs = voices?.success ? Object.entries(voices.data.cast).filter(([, c]) => c.profile && needsDesign(c.profile)).map(([k]) => k) : [];
|
|
110
|
+
return { from, to: language, same, texts: Object.keys(worldTexts(world, story)).length, lines: lines.length, characters, credits: Math.round(characters * (quality === 'final' ? 1 : 0.5)), designs, quality };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const planLine = (p: LanguagePlan) => (p.same
|
|
114
|
+
? `already in ${p.to}: the texts stay; the voices are read again only where they are not in ${p.to} yet`
|
|
115
|
+
: `${p.texts} texts translated from ${p.from} to ${p.to} in one Spark pass (cents); then ${p.lines} spoken lines read again, about ${p.characters} characters, ~${p.credits} ElevenLabs credits (${p.quality})`
|
|
116
|
+
+ `${p.designs.length ? `, plus a new designed voice for ${p.designs.join(', ')}` : ''}; paintings, masks, sounds and puzzles stay`);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Work that may run side by side, a few at a time: the cast's 3D bodies and faces, each character on its own.
|
|
2
|
+
|
|
3
|
+
/** Runs `work` on every item, at most `limit` at once; results keep the items' order. Errors are kept per item. */
|
|
4
|
+
export async function mapLimit<T, R>(items: readonly T[], limit: number, work: (item: T, index: number) => Promise<R>): Promise<PromiseSettledResult<R>[]> {
|
|
5
|
+
const results: PromiseSettledResult<R>[] = new Array(items.length);
|
|
6
|
+
let next = 0;
|
|
7
|
+
const runner = async () => {
|
|
8
|
+
while (next < items.length) {
|
|
9
|
+
const i = next++;
|
|
10
|
+
try {
|
|
11
|
+
results[i] = { status: 'fulfilled', value: await work(items[i], i) };
|
|
12
|
+
} catch (reason) {
|
|
13
|
+
results[i] = { status: 'rejected', reason };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, runner));
|
|
18
|
+
return results;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const open = new Map<string, number>();
|
|
22
|
+
const waiting = new Map<string, Array<() => void>>();
|
|
23
|
+
|
|
24
|
+
/** At most `limit` of `lane`'s work at once in this process (a Hugging Face Space queue, a local GPU). */
|
|
25
|
+
export async function inLane<T>(lane: string, limit: number, work: () => Promise<T>): Promise<T> {
|
|
26
|
+
if ((open.get(lane) ?? 0) >= limit) await new Promise<void>((go) => (waiting.get(lane) ?? waiting.set(lane, []).get(lane)!).push(go));
|
|
27
|
+
else open.set(lane, (open.get(lane) ?? 0) + 1);
|
|
28
|
+
try {
|
|
29
|
+
return await work();
|
|
30
|
+
} finally {
|
|
31
|
+
const go = waiting.get(lane)?.shift();
|
|
32
|
+
if (go) go();
|
|
33
|
+
else open.set(lane, (open.get(lane) ?? 1) - 1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** How many characters are worked on at once: DIORAMA_CAST_LANES, default 3. */
|
|
38
|
+
export function castLanes(): number {
|
|
39
|
+
const n = Number(process.env.DIORAMA_CAST_LANES);
|
|
40
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 3;
|
|
41
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// What each build step waits for. The build is not a line of steps but a web: every place goes through its own
|
|
2
|
+
// steps as fast as it can, and the world's steps (cast, voices, sound...) start as soon as what they read is there.
|
|
3
|
+
// Plain data, so the Studio can work out the time left from the same web the build runs.
|
|
4
|
+
|
|
5
|
+
export const STEPS = ['world', 'story', 'layout', 'paint', 'masks', 'plates', 'icons', 'derive', 'backdrop', 'weather', 'reactions', 'cast', 'model', 'sprites', 'sound', 'voices', 'faces', 'verify'] as const;
|
|
6
|
+
export type Step = (typeof STEPS)[number];
|
|
7
|
+
/** The steps that work place by place; `--place` runs only these, for that one place. */
|
|
8
|
+
export const PLACE_STEPS: readonly Step[] = ['layout', 'paint', 'masks', 'plates', 'derive', 'backdrop', 'weather', 'reactions', 'verify'];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What a step reads, as steps. A place step needs the same place's step before it (PLACE_STEPS order) plus these;
|
|
12
|
+
* a place step named in a world step's list means every place has finished it (or was set aside).
|
|
13
|
+
* - layout and paint read world.json only: places are painted while Spark still writes the story.
|
|
14
|
+
* - plates takes the story's carried-off things out of the painting.
|
|
15
|
+
* - icons: an item is cut from the painting of the place it is picked up in, once that place has masks
|
|
16
|
+
* (the build runs it per place; items picked up nowhere are painted once the story is there).
|
|
17
|
+
* - cast reads the looks in world.json, so the figures are painted alongside the places.
|
|
18
|
+
* - model needs the story (who speaks, patrols or travels); sprites need that plan and every place's geometry.
|
|
19
|
+
* NPC stage flags/conditions travel with story.json; they need no separate build step.
|
|
20
|
+
* - sound plans from every place's painting brief and floors; voices from the story's lines.
|
|
21
|
+
*/
|
|
22
|
+
export const NEEDS: Record<Step, readonly Step[]> = {
|
|
23
|
+
world: [],
|
|
24
|
+
story: ['world'],
|
|
25
|
+
layout: ['world'],
|
|
26
|
+
paint: [],
|
|
27
|
+
masks: [],
|
|
28
|
+
plates: ['story'],
|
|
29
|
+
icons: ['story', 'masks'],
|
|
30
|
+
derive: [],
|
|
31
|
+
backdrop: [],
|
|
32
|
+
weather: [],
|
|
33
|
+
reactions: ['story'],
|
|
34
|
+
cast: ['world'],
|
|
35
|
+
model: ['story', 'cast'],
|
|
36
|
+
sprites: ['model', 'derive'],
|
|
37
|
+
sound: ['story', 'paint'],
|
|
38
|
+
voices: ['story'],
|
|
39
|
+
// Last: talking mouths are polish. They start once every place is verified, so they never hold up a playable game.
|
|
40
|
+
faces: ['cast', 'sprites', 'voices', 'verify'],
|
|
41
|
+
verify: [],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Every step `step` waits for: its own list, plus the place step before it. */
|
|
45
|
+
export function needs(step: Step): Step[] {
|
|
46
|
+
const i = PLACE_STEPS.indexOf(step);
|
|
47
|
+
return [...(i > 0 ? [PLACE_STEPS[i - 1]] : []), ...NEEDS[step]];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Seconds until every step in `plan` is done, when each starts as soon as what it needs is: the longest chain,
|
|
52
|
+
* not the sum. `left(step)` is what a step still takes (0 when done); steps outside the plan count as done.
|
|
53
|
+
*/
|
|
54
|
+
export function timeLeft(plan: readonly string[], left: (step: Step) => number): number {
|
|
55
|
+
const end = new Map<Step, number>();
|
|
56
|
+
const finish = (step: Step): number => {
|
|
57
|
+
if (end.has(step)) return end.get(step)!;
|
|
58
|
+
const after = Math.max(0, ...needs(step).filter((s) => plan.includes(s)).map(finish));
|
|
59
|
+
const at = after + (plan.includes(step) ? left(step) : 0);
|
|
60
|
+
end.set(step, at);
|
|
61
|
+
return at;
|
|
62
|
+
};
|
|
63
|
+
return Math.max(0, ...STEPS.filter((s) => plan.includes(s)).map(finish));
|
|
64
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// world.json: the places of a world and how they connect. Spark writes it, people may edit it.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
export const Slug = z.string().regex(/^[a-z][a-z0-9-]*$/, 'lowercase-kebab key');
|
|
5
|
+
|
|
6
|
+
export const Thing = z.object({
|
|
7
|
+
key: Slug,
|
|
8
|
+
role: z.enum(['prop', 'exit', 'item', 'npc']),
|
|
9
|
+
looks: z.string().min(3),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const Place = z.object({
|
|
13
|
+
key: Slug,
|
|
14
|
+
shows: z.string().min(10),
|
|
15
|
+
mood: z.string().min(3),
|
|
16
|
+
things: z.array(Thing).min(1).max(10),
|
|
17
|
+
exits: z.array(z.object({ to: Slug, via: Slug })),
|
|
18
|
+
/** Props the person took off the list (corrections.json, not world.json): the story's look lines on them are never clicked. */
|
|
19
|
+
taken: z.array(Slug).optional(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const World = z.object({
|
|
23
|
+
title: z.string().min(1),
|
|
24
|
+
pitch: z.string().min(10),
|
|
25
|
+
style: z.string().min(10),
|
|
26
|
+
start: Slug,
|
|
27
|
+
hero: z.object({ key: Slug, looks: z.string().min(10) }),
|
|
28
|
+
places: z.array(Place).min(1).max(8),
|
|
29
|
+
/** The language the game's text is written and voiced in, as a name ("Polish"); English when left out. */
|
|
30
|
+
language: z.string().min(2).optional(),
|
|
31
|
+
/** Who plays: "kids" are 5 to 7 and cannot read yet, so the story is gentler, simpler and heard. */
|
|
32
|
+
audience: z.enum(['kids']).optional(),
|
|
33
|
+
/** What the players call the hero and each npc, in the world's language ("Smok Zielek" in a Polish world); else a name made from the key. */
|
|
34
|
+
names: z.record(z.string(), z.string().min(1).max(40)).optional(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export type World = z.infer<typeof World>;
|
|
38
|
+
export type Place = z.infer<typeof Place>;
|
|
39
|
+
|
|
40
|
+
/** What a schema can't say: exits must lead somewhere and back, and every place must be reachable. */
|
|
41
|
+
export function worldIssues(world: World): string[] {
|
|
42
|
+
const issues: string[] = [];
|
|
43
|
+
const byKey = new Map(world.places.map((p) => [p.key, p]));
|
|
44
|
+
if (byKey.size < world.places.length) issues.push('place keys repeat');
|
|
45
|
+
if (!byKey.has(world.start)) issues.push(`start "${world.start}" is not a place`);
|
|
46
|
+
for (const place of world.places) {
|
|
47
|
+
const keys = place.things.map((t) => t.key);
|
|
48
|
+
if (new Set(keys).size < keys.length) issues.push(`${place.key}: thing keys repeat`);
|
|
49
|
+
for (const exit of place.exits) {
|
|
50
|
+
const target = byKey.get(exit.to);
|
|
51
|
+
const via = place.things.find((t) => t.key === exit.via);
|
|
52
|
+
if (!target) issues.push(`${place.key}: exit leads to unknown place "${exit.to}"`);
|
|
53
|
+
if (!via) issues.push(`${place.key}: exit uses "${exit.via}", which is not one of its things`);
|
|
54
|
+
else if (via.role !== 'exit') issues.push(`${place.key}: "${exit.via}" is used as an exit, so its role must be "exit"`);
|
|
55
|
+
if (target && !target.exits.some((back) => back.to === place.key)) issues.push(`${exit.to} has no exit back to ${place.key}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const reached = new Set<string>();
|
|
59
|
+
const visit = (key: string) => {
|
|
60
|
+
if (reached.has(key) || !byKey.has(key)) return;
|
|
61
|
+
reached.add(key);
|
|
62
|
+
byKey.get(key)!.exits.forEach((e) => visit(e.to));
|
|
63
|
+
};
|
|
64
|
+
visit(world.start);
|
|
65
|
+
for (const place of world.places) if (!reached.has(place.key)) issues.push(`${place.key} cannot be reached from ${world.start}`);
|
|
66
|
+
return issues;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function checkWorld(raw: unknown): { world?: World; issues: string[] } {
|
|
70
|
+
const parsed = World.safeParse(raw);
|
|
71
|
+
if (!parsed.success) return { issues: parsed.error.issues.map((i) => `${i.path.join('.') || 'world'}: ${i.message}`) };
|
|
72
|
+
const issues = worldIssues(parsed.data);
|
|
73
|
+
return issues.length ? { issues } : { world: parsed.data, issues };
|
|
74
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// A reviewed world plan is immutable. Only its selected visual takes vary before publication.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { inputHash } from '../meta/memo.ts';
|
|
6
|
+
import { ActionError, type ActionContext } from '../actions/registry.ts';
|
|
7
|
+
import { WorldFiles } from './files.ts';
|
|
8
|
+
import { type World, Slug } from './schema.ts';
|
|
9
|
+
import { type CreativeChoices, refinedIdea } from './idea-review.ts';
|
|
10
|
+
import { videoDir } from './video.ts';
|
|
11
|
+
|
|
12
|
+
export const SketchId = z.string().regex(/^[a-f0-9]{32}$/);
|
|
13
|
+
export const SketchApproval = z.object({ id: SketchId, selections: z.record(z.number().int().min(0).max(9)) });
|
|
14
|
+
export type SketchApproval = z.infer<typeof SketchApproval>;
|
|
15
|
+
export type SketchInput = { idea: string; choices?: CreativeChoices; photos?: Array<{ name: string; data: string }>; videos?: string[]; places?: number; language?: string; slug?: string };
|
|
16
|
+
export type SketchAsset = { key: string; kind: 'character' | 'place'; label: string; role: 'hero' | 'npc' | 'place'; looks: string };
|
|
17
|
+
export type Sketch = { id: string; title: string; pitch: string; assets: SketchAsset[] };
|
|
18
|
+
export type SketchRecord = { fingerprint: string; slug: string; worldHash: string; publication?: { slug: string; selections: string; result?: unknown } };
|
|
19
|
+
export const sketchFiles = (root: string, id: string) => new WorldFiles(SketchId.parse(id), path.join(root, '.start', 'sketches'));
|
|
20
|
+
export const sketchFingerprint = (input: SketchInput) => inputHash(JSON.stringify({ idea: input.idea, choices: input.choices ?? null, photos: input.photos ?? [], videos: input.videos ?? [], places: input.places ?? null, language: input.language ?? null }));
|
|
21
|
+
|
|
22
|
+
export function sketchAssets(world: World): SketchAsset[] {
|
|
23
|
+
const people = [world.hero, ...world.places.flatMap(p => p.things.filter(t => t.role === 'npc'))];
|
|
24
|
+
return [
|
|
25
|
+
...[...new Map(people.map(p => [p.key, p])).values()].map(p => ({ key: p.key, kind: 'character' as const, label: world.names?.[p.key] ?? p.key.replace(/-/g, ' '), role: p.key === world.hero.key ? 'hero' as const : 'npc' as const, looks: p.looks })),
|
|
26
|
+
...world.places.map(p => ({ key: p.key, kind: 'place' as const, label: world.names?.[p.key] ?? p.key.replace(/-/g, ' '), role: 'place' as const, looks: `${p.shows}. ${p.mood}` })),
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
export const assetId = (asset: Pick<SketchAsset, 'kind' | 'key'>) => `${asset.kind}:${asset.key}`;
|
|
30
|
+
export const sketchImagePath = (files: WorldFiles, asset: Pick<SketchAsset, 'kind' | 'key'>, take: number) => files.path('concepts', asset.kind, `${Slug.parse(asset.key)}-${take}.png`);
|
|
31
|
+
|
|
32
|
+
/** Clean, unwatermarked reference. The PREVIEW label belongs to the UI, never to the painting. */
|
|
33
|
+
export function approvedConcept(files: WorldFiles, kind: SketchAsset['kind'], key: string): Buffer | undefined {
|
|
34
|
+
const file = files.path('concepts', kind, `${Slug.parse(key)}.png`);
|
|
35
|
+
return fs.existsSync(file) ? fs.readFileSync(file) : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const publishing = new Map<string, Promise<unknown>>();
|
|
39
|
+
export async function publishSketch(ctx: ActionContext, input: SketchInput, approval: SketchApproval): Promise<unknown> {
|
|
40
|
+
const files = sketchFiles(ctx.root, approval.id), lock = files.dir;
|
|
41
|
+
// Serialize conflicting submissions as well as double clicks; only one world can own this approval.
|
|
42
|
+
const previous = publishing.get(lock);
|
|
43
|
+
if (previous) { await previous.catch(() => {}); return publishSketch(ctx, input, approval); }
|
|
44
|
+
const task = (async () => {
|
|
45
|
+
const record = files.readJson(files.path('sketch.json')) as SketchRecord;
|
|
46
|
+
if (record.fingerprint !== sketchFingerprint(input)) throw new ActionError(409, 'Your idea changed. Preview it again before building this version.');
|
|
47
|
+
const worldBytes = fs.readFileSync(files.path('world.json'));
|
|
48
|
+
if (record.worldHash !== inputHash(worldBytes)) throw new ActionError(409, 'The world plan changed. Preview it again.');
|
|
49
|
+
const world = files.world(), assets = sketchAssets(world);
|
|
50
|
+
if (Object.keys(approval.selections).length !== assets.length || assets.some(a => !Number.isInteger(approval.selections[assetId(a)]))) throw new ActionError(409, 'Choose a preview for every character and place, or skip the preview.');
|
|
51
|
+
for (const a of assets) if (!fs.existsSync(sketchImagePath(files, a, approval.selections[assetId(a)]))) throw new ActionError(409, `The preview for ${a.label} is not ready yet.`);
|
|
52
|
+
const selectionHash = inputHash(JSON.stringify(assets.map(a => [assetId(a), approval.selections[assetId(a)]])));
|
|
53
|
+
if (record.publication) {
|
|
54
|
+
if (record.publication.selections !== selectionHash || input.slug && input.slug !== record.publication.slug) throw new ActionError(409, 'This preview has already been used to build a different selection.');
|
|
55
|
+
if (record.publication.result) return record.publication.result;
|
|
56
|
+
} else {
|
|
57
|
+
const base = Slug.safeParse(input.slug ?? record.slug);
|
|
58
|
+
let name = base.success ? base.data : 'my-world', n = 2;
|
|
59
|
+
const first = name;
|
|
60
|
+
if (input.slug && fs.existsSync(path.join(ctx.root, name))) throw new ActionError(409, `${name} already exists: pick another name`);
|
|
61
|
+
while (fs.existsSync(path.join(ctx.root, name))) name = `${first}-${n++}`;
|
|
62
|
+
const dest = new WorldFiles(`publish-${approval.id}`, path.join(ctx.root, '.start'));
|
|
63
|
+
// Retain all paid concepts and source drawings, but never make the temporary folder a visible world.
|
|
64
|
+
fs.cpSync(files.dir, dest.dir, { recursive: true });
|
|
65
|
+
for (const a of assets) dest.write(dest.path('concepts', a.kind, `${a.key}.png`), fs.readFileSync(sketchImagePath(files, a, approval.selections[assetId(a)])));
|
|
66
|
+
dest.write(dest.path('approved-sketch.json'), { ...approval, approvedAt: new Date().toISOString() });
|
|
67
|
+
for (const id of input.videos ?? []) fs.cpSync(videoDir(ctx.root, id), dest.path('videos', id), { recursive: true });
|
|
68
|
+
fs.renameSync(dest.dir, path.join(ctx.root, name));
|
|
69
|
+
record.publication = { slug: name, selections: selectionHash };
|
|
70
|
+
files.write(files.path('sketch.json'), record);
|
|
71
|
+
}
|
|
72
|
+
const name = record.publication.slug;
|
|
73
|
+
const job = await ctx.jobs.start(name, ['build', name, refinedIdea(input.idea, input.choices), '--from', 'story'], 'build approved world > verify', ctx.by);
|
|
74
|
+
const result = { slug: name, title: world.title, places: world.places.length, language: world.language ?? 'English', job };
|
|
75
|
+
record.publication.result = result;
|
|
76
|
+
files.write(files.path('sketch.json'), record);
|
|
77
|
+
return result;
|
|
78
|
+
})();
|
|
79
|
+
publishing.set(lock, task);
|
|
80
|
+
try { return await task; } finally { if (publishing.get(lock) === task) publishing.delete(lock); }
|
|
81
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// What changing a world's style redoes, and what that costs: the steps per scope, and an estimate of the model
|
|
2
|
+
// calls from what those steps sent when this world was built (a rough table when it has no record yet).
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { readBudget } from '../actions/budget.ts';
|
|
5
|
+
import { players } from './drawings.ts';
|
|
6
|
+
import type { WorldFiles } from './files.ts';
|
|
7
|
+
import type { World } from './schema.ts';
|
|
8
|
+
|
|
9
|
+
export const SCOPES = ['world', 'place', 'cast'] as const;
|
|
10
|
+
export type Scope = (typeof SCOPES)[number];
|
|
11
|
+
|
|
12
|
+
/** The place steps a new painting runs again; the restyle edit takes paint's place. Sound and voices stay as they are. */
|
|
13
|
+
export const PLACE_REDO = ['paint', 'masks', 'plates', 'icons', 'derive', 'backdrop', 'weather', 'reactions', 'verify'] as const;
|
|
14
|
+
/** The character steps; model and sprites only for those who had them. */
|
|
15
|
+
export const CAST_REDO = ['cast', 'model', 'sprites', 'faces'] as const;
|
|
16
|
+
|
|
17
|
+
type Calls = Record<string, number>;
|
|
18
|
+
/** Calls one place (or one character) costs per step when the world has no record of its own. */
|
|
19
|
+
const ROUGH: Record<string, Calls> = {
|
|
20
|
+
paint: { spark: 2, muse: 2 }, masks: { spark: 1, sam: 8 }, plates: { muse: 1 }, icons: { muse: 1 }, derive: { spark: 3 },
|
|
21
|
+
backdrop: { muse: 1, sam: 2 }, weather: { spark: 1 }, reactions: { spark: 1 }, verify: {},
|
|
22
|
+
cast: { muse: 1, sam: 1 }, model: { muse: 3 }, sprites: { muse: 5 }, faces: { spark: 4, muse: 2 },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Everyone the cast step paints: the hero, each NPC and the heroes for more gamepads. */
|
|
26
|
+
export const castKeys = (world: World, files: WorldFiles) => [...new Set([world.hero.key, ...world.places.flatMap((p) => p.things.filter((t) => t.role === 'npc').map((t) => t.key)), ...players(files).map((p) => p.key)])];
|
|
27
|
+
|
|
28
|
+
export type StylePlan = {
|
|
29
|
+
scope: Scope;
|
|
30
|
+
places: string[];
|
|
31
|
+
cast: string[];
|
|
32
|
+
/** Of the cast, who has a 3D model and sprite sheets to make again. */
|
|
33
|
+
models: string[];
|
|
34
|
+
sprites: string[];
|
|
35
|
+
faces: string[];
|
|
36
|
+
calls: Calls;
|
|
37
|
+
usd: number | null;
|
|
38
|
+
minutes: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function stylePlan(files: WorldFiles, world: World, scope: Scope, key?: string): StylePlan {
|
|
42
|
+
const places = scope === 'world' ? world.places.map((p) => p.key) : scope === 'place' ? [key!] : [];
|
|
43
|
+
const cast = scope === 'place' ? [] : scope === 'cast' && key ? [key] : castKeys(world, files);
|
|
44
|
+
const painted = places.filter((p) => fs.existsSync(files.place(p, 'painting.png')));
|
|
45
|
+
const figures = cast.filter((k) => fs.existsSync(files.path('cast', `${k}-painting.png`)));
|
|
46
|
+
const models = figures.filter((k) => fs.existsSync(files.path('cast', k, 'model', 'model.json')));
|
|
47
|
+
const sprites = models.filter((k) => fs.existsSync(files.path('cast', k, 'sprites', 'sprites.json')));
|
|
48
|
+
const faces = figures.filter((k) => fs.existsSync(files.path('cast', k, 'face.json')));
|
|
49
|
+
let log: { steps?: Record<string, { seconds?: number; sent?: Calls }> } = {};
|
|
50
|
+
try {
|
|
51
|
+
log = JSON.parse(fs.readFileSync(files.path('build.json'), 'utf8'));
|
|
52
|
+
} catch {}
|
|
53
|
+
const calls: Calls = {};
|
|
54
|
+
let seconds = 0;
|
|
55
|
+
// What a step sent for the whole world, shared out over the `of` it made then; the rough table is per place or character.
|
|
56
|
+
const add = (step: string, count: number, of: number) => {
|
|
57
|
+
const sent = log.steps?.[step]?.sent;
|
|
58
|
+
const own = !!sent && Object.values(sent).some((n) => n > 0);
|
|
59
|
+
const share = own ? count / Math.max(1, of) : count;
|
|
60
|
+
for (const [m, n] of Object.entries(own ? sent : ROUGH[step] ?? {})) calls[m] = (calls[m] ?? 0) + n * share;
|
|
61
|
+
// Places and characters are made side by side: a step takes about as long for one as for all.
|
|
62
|
+
seconds += log.steps?.[step]?.seconds ?? 60;
|
|
63
|
+
};
|
|
64
|
+
if (painted.length) for (const step of PLACE_REDO) add(step, painted.length, world.places.length);
|
|
65
|
+
if (figures.length) add('cast', figures.length, castKeys(world, files).length);
|
|
66
|
+
// Model and sprites ran for fewer characters than the cast (those who walk and talk): shared out over theirs.
|
|
67
|
+
const had = (part: string) => castKeys(world, files).filter((k) => fs.existsSync(files.path('cast', k, part))).length;
|
|
68
|
+
if (models.length) add('model', models.length, had('model/model.json'));
|
|
69
|
+
if (sprites.length) add('sprites', sprites.length, had('sprites/sprites.json'));
|
|
70
|
+
if (faces.length) add('faces', faces.length, had('face.json'));
|
|
71
|
+
for (const m of Object.keys(calls)) calls[m] = Math.ceil(calls[m]);
|
|
72
|
+
for (const m of Object.keys(calls)) if (!calls[m]) delete calls[m];
|
|
73
|
+
const { prices } = readBudget();
|
|
74
|
+
const priced = Object.keys(calls).every((m) => (prices as Record<string, number | undefined>)[m] !== undefined);
|
|
75
|
+
const usd = priced ? Math.round(Object.entries(calls).reduce((s, [m, n]) => s + n * ((prices as Record<string, number>)[m] ?? 0), 0) * 100) / 100 : null;
|
|
76
|
+
return { scope, places: painted, cast: figures, models, sprites, faces, calls, usd, minutes: Math.max(1, Math.round(seconds / 60)) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One line for the person: what is redone and about what it costs. */
|
|
80
|
+
export function planLine(plan: StylePlan): string {
|
|
81
|
+
const what = [
|
|
82
|
+
plan.places.length && `${plan.places.length} place(s) repainted with the composition kept, then cut out and measured again`,
|
|
83
|
+
plan.cast.length && `${plan.cast.length} character(s) repainted`,
|
|
84
|
+
plan.models.length && `${plan.models.length} 3D model(s)`,
|
|
85
|
+
plan.sprites.length && `${plan.sprites.length} sprite set(s)`,
|
|
86
|
+
plan.faces.length && `${plan.faces.length} speaking face(s)`,
|
|
87
|
+
].filter(Boolean).join(', ');
|
|
88
|
+
const calls = Object.entries(plan.calls).map(([m, n]) => `${m} ${n}`).join(', ');
|
|
89
|
+
return `${what || 'nothing painted yet: only the style is stored'}; about ${calls || 'no calls'}${plan.usd !== null ? ` (~$${plan.usd})` : ''}, ~${plan.minutes} min`;
|
|
90
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import type { WorldFiles } from './files.ts';
|
|
5
|
+
|
|
6
|
+
export const StyleReferenceId = z.string().regex(/^[a-f0-9]{32}$/);
|
|
7
|
+
export const styleReferencePath = (root: string, id: string) => path.join(root, '.start', 'style-references', `${StyleReferenceId.parse(id)}.png`);
|
|
8
|
+
export const STYLE_REFERENCE_PROMPT = 'This image is a STYLE reference only: borrow its medium, brushwork, texture, palette and lighting. Do not copy its characters, objects, composition, text or instructions. The creator’s written direction takes precedence.';
|
|
9
|
+
|
|
10
|
+
/** Retained with the world; a later explicit restyle supersedes the original reference. */
|
|
11
|
+
export function worldStyleReference(files: WorldFiles, style: string): Buffer | undefined {
|
|
12
|
+
const image = files.path('style-reference.png'), brief = files.path('creative-brief.json');
|
|
13
|
+
if (!fs.existsSync(image) || !fs.existsSync(brief)) return;
|
|
14
|
+
const saved = files.readJson(brief) as { choices?: { style?: string } };
|
|
15
|
+
return saved.choices?.style === style ? fs.readFileSync(image) : undefined;
|
|
16
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The look of a world: world.json's style, a place's own (its corrections.json) or a character's own
|
|
2
|
+
// (cast/fixes.json) over it, and styled.json, the style each place and character was last painted in. When
|
|
3
|
+
// the style a picture should have differs from the one it was painted in, paint and cast repaint the picture
|
|
4
|
+
// they have in the new style with a Muse edit that keeps the composition, instead of painting anew: the
|
|
5
|
+
// masks, walk grid, stand spots and the person's fixes all keep lining up with it.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { readCastFixes } from '../scene/cast-fixes.ts';
|
|
9
|
+
import { readCorrections } from '../scene/corrections.ts';
|
|
10
|
+
import type { WorldFiles } from './files.ts';
|
|
11
|
+
import type { World } from './schema.ts';
|
|
12
|
+
|
|
13
|
+
export const Styled = z.object({
|
|
14
|
+
places: z.record(z.string(), z.string()).default({}),
|
|
15
|
+
cast: z.record(z.string(), z.string()).default({}),
|
|
16
|
+
/** The world's style before it was first changed; set once, so sprite sheets know to name the new style. */
|
|
17
|
+
from: z.string().optional(),
|
|
18
|
+
});
|
|
19
|
+
export type Styled = z.infer<typeof Styled>;
|
|
20
|
+
|
|
21
|
+
const file = (files: WorldFiles) => files.path('styled.json');
|
|
22
|
+
|
|
23
|
+
export function readStyled(files: WorldFiles): Styled {
|
|
24
|
+
try {
|
|
25
|
+
return Styled.parse(JSON.parse(fs.readFileSync(file(files), 'utf8')));
|
|
26
|
+
} catch {
|
|
27
|
+
return Styled.parse({});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Records the style a place or character was just painted in. Read and written at once, so places painted side by side keep each other's. */
|
|
32
|
+
export function markStyled(files: WorldFiles, kind: 'places' | 'cast', key: string, style: string) {
|
|
33
|
+
const styled = readStyled(files);
|
|
34
|
+
if (styled[kind][key] === style) return;
|
|
35
|
+
styled[kind][key] = style;
|
|
36
|
+
files.write(file(files), styled);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function writeStyled(files: WorldFiles, styled: Styled) {
|
|
40
|
+
files.write(file(files), styled);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The style a place is painted in: its own when the person set one, else the world's. */
|
|
44
|
+
export function placeStyle(files: WorldFiles, world: World, place: string): string {
|
|
45
|
+
return readCorrections(files.place(place)).style ?? world.style;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The style a character is painted in: their own when the person set one, else the world's. */
|
|
49
|
+
export function castStyle(files: WorldFiles, world: World, key: string): string {
|
|
50
|
+
return readCastFixes(files)[key]?.style ?? world.style;
|
|
51
|
+
}
|