@gtanczyk/diorama 0.1.0 → 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/app/src/actions/budget.ts +11 -3
- package/app/src/actions/cast.ts +19 -0
- package/app/src/actions/index.ts +1 -0
- package/app/src/actions/language.ts +52 -0
- package/app/src/actions/world.ts +4 -3
- package/app/src/commands/cast.ts +32 -13
- package/app/src/commands/export.ts +16 -0
- package/app/src/commands/index.ts +1 -0
- package/app/src/commands/language.ts +87 -0
- package/app/src/commands/play.ts +4 -1
- package/app/src/commands/sound.ts +16 -6
- package/app/src/meta/memo.ts +14 -0
- package/app/src/meta/voice.ts +2 -2
- package/app/src/scene/cast-fixes.ts +5 -3
- package/app/src/scene/cast.ts +16 -9
- package/app/src/scene/held.ts +62 -8
- package/app/src/scene/rig.ts +6 -2
- package/app/src/scene/shadows.ts +10 -2
- package/app/src/scene/warp.ts +5 -3
- package/app/src/speech/transcribe.ts +2 -2
- package/app/src/studio/agent.ts +3 -2
- package/app/src/studio/server.ts +4 -2
- package/app/src/world/language.ts +116 -0
- package/app/studio/build.tsx +5 -3
- package/app/studio/main.tsx +3 -2
- package/app/web/main.ts +42 -24
- package/docs/characters.md +1 -1
- package/docs/setup.md +1 -1
- package/package.json +2 -2
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
-
import type
|
|
7
|
+
import { callVia, MUSE_VOICE, type ModelName } from '../meta/memo.ts';
|
|
8
8
|
import { imageVia } from '../meta/muse.ts';
|
|
9
9
|
import { sparkTier, sparkVia } from '../meta/spark.ts';
|
|
10
10
|
import { configDir } from '../secrets.ts';
|
|
@@ -54,10 +54,18 @@ function tally(dir: string, since: string, credits?: string) {
|
|
|
54
54
|
for (const line of fs.readFileSync(log, 'utf8').split('\n')) {
|
|
55
55
|
if (!line) continue;
|
|
56
56
|
try {
|
|
57
|
-
const c = JSON.parse(line) as { at: string; model: ModelName; hit: boolean; usd?: number; via?: string };
|
|
57
|
+
const c = JSON.parse(line) as { at: string; model: ModelName; hit: boolean; hash?: string; usd?: number; via?: string };
|
|
58
58
|
if (c.hit) continue;
|
|
59
|
+
const via = callVia(dir, c);
|
|
60
|
+
// Muse Voice transcribing a take: Meta's, not an ElevenLabs call, so the ElevenLabs price is not put on it.
|
|
61
|
+
if (via === MUSE_VOICE) {
|
|
62
|
+
usd.all += c.usd ?? 0;
|
|
63
|
+
if (c.at >= since) usd.recent += c.usd ?? 0;
|
|
64
|
+
if (credits && c.at >= credits) usd.sinceCredits += c.usd ?? 0;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
59
67
|
// Answered by another model (Claude, Gemini through OpenRouter): not on Meta's bill, counted apart.
|
|
60
|
-
if (
|
|
68
|
+
if (via && via !== 'contributor') {
|
|
61
69
|
usd.elsewhereCalls++;
|
|
62
70
|
usd.elsewhereUsd += c.usd ?? 0;
|
|
63
71
|
continue;
|
package/app/src/actions/cast.ts
CHANGED
|
@@ -89,6 +89,8 @@ defineAction({
|
|
|
89
89
|
looksNow: fixes[c.key]?.looks ?? c.looks,
|
|
90
90
|
fix: fixes[c.key] ?? null,
|
|
91
91
|
figure: pic(`cast/${c.key}.png`),
|
|
92
|
+
// What the cut-out keeps beyond the body (cast_holds) and the share of its height the body takes.
|
|
93
|
+
cut: fs.existsSync(files.path('cast', c.key, 'cut.json')) ? files.readJson(files.path('cast', c.key, 'cut.json')) : null,
|
|
92
94
|
face: faceStatus(files, c.key),
|
|
93
95
|
painting: pic(`cast/${c.key}-painting.png`),
|
|
94
96
|
rig: animationCurrent(files,c.key,'rig') && fs.existsSync(files.path('cast', c.key, 'rig.json')),
|
|
@@ -152,6 +154,23 @@ defineAction({
|
|
|
152
154
|
},
|
|
153
155
|
});
|
|
154
156
|
|
|
157
|
+
defineAction({
|
|
158
|
+
name: 'cast_holds',
|
|
159
|
+
description: 'Keep what a character holds or carries on their cut-out (a giant slice of toast over the head, a lantern, a net), however big: SAM finds each '
|
|
160
|
+
+ 'thing by its name and it is cut out with them and walks with their hands. Without holds, what their description says they hold is used (read by Spark); '
|
|
161
|
+
+ 'holds names the things yourself in a few words each ("slice of toast"), [] keeps nothing extra. Kept across rebuilds. Cuts the figure again from its '
|
|
162
|
+
+ 'painting and makes its 2D walk again: no Muse calls, a few SAM calls. Use it when the person says the figure lost what it holds. '
|
|
163
|
+
+ 'A 3D model and its sprites never hold anything (held things tear its rig): when the held thing is who they are, say so. Then play_reload. Runs as a job.',
|
|
164
|
+
input: z.object({ slug: Slug, key: z.string().min(1), holds: z.array(z.string().min(2).max(60)).max(4).optional() }),
|
|
165
|
+
job: true,
|
|
166
|
+
async run({ slug, key, holds }, ctx) {
|
|
167
|
+
const prepare = prepareFix(ctx, slug, key, (f) => {
|
|
168
|
+
if (holds) f.holds = holds;
|
|
169
|
+
});
|
|
170
|
+
return ctx.jobs.start(slug, ['rig', slug, key], `cut out ${key} with what they hold`, ctx.by, prepare);
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
155
174
|
defineAction({
|
|
156
175
|
name: 'cast_animate',
|
|
157
176
|
description: 'Give a character a 3D model now, also one the build skips (an NPC with no lines, a creature or an animated object): '
|
package/app/src/actions/index.ts
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The language a world is told in: world_language translates every word the player reads or hears and has the
|
|
2
|
+
// voices cast and read again in it.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { credits } from '../sound/cost.ts';
|
|
6
|
+
import { elevenKey } from '../sound/sources.ts';
|
|
7
|
+
import { Story } from '../story/schema.ts';
|
|
8
|
+
import { agentBlocked } from '../studio/locks.ts';
|
|
9
|
+
import { WorldFiles } from '../world/files.ts';
|
|
10
|
+
import { languagePlan, planLine } from '../world/language.ts';
|
|
11
|
+
import { Slug } from '../world/schema.ts';
|
|
12
|
+
import { ActionError, defineAction } from './registry.ts';
|
|
13
|
+
|
|
14
|
+
defineAction({
|
|
15
|
+
name: 'world_language',
|
|
16
|
+
description: 'Tell the world in another language ("English", "Polish", "German"): the title, pitch, character and item names and every line of the story '
|
|
17
|
+
+ '(intro, remarks, dialogue, answers, item names, endings) are translated in one cheap Spark pass, with names kept the same everywhere; the game\'s '
|
|
18
|
+
+ 'own words (hints, goodbye, buttons) follow world.json\'s language by themselves. Keys, puzzles, paintings, masks and sounds stay. Then the voices are '
|
|
19
|
+
+ 'cast again as native speakers of the new language (no accent of the old one), every spoken line is read again and the talking faces follow. '
|
|
20
|
+
+ 'voices false translates only (cheap; the game shows text until run_step voices). Without go it only answers what it redoes and about what '
|
|
21
|
+
+ 'it costs: tell the person, then call again with go true once they agree. Runs as a job; history_undo takes it back. Then play_reload.',
|
|
22
|
+
input: z.object({
|
|
23
|
+
slug: Slug,
|
|
24
|
+
language: z.string().trim().min(2).max(30).describe('The new language as its English name: "English", "Polish".'),
|
|
25
|
+
voices: z.boolean().optional().describe('false: translate the text only and leave the voices for later. Default true.'),
|
|
26
|
+
go: z.boolean().optional().describe('true runs it; leave out to get the estimate first.'),
|
|
27
|
+
}),
|
|
28
|
+
job: true,
|
|
29
|
+
async run({ slug, language, voices = true, go }, ctx) {
|
|
30
|
+
const files = new WorldFiles(slug, ctx.root);
|
|
31
|
+
if (!fs.existsSync(files.path('world.json'))) throw new ActionError(404, `no world ${slug} yet`);
|
|
32
|
+
const name = language[0].toUpperCase() + language.slice(1);
|
|
33
|
+
const world = files.world();
|
|
34
|
+
const story = fs.existsSync(files.path('story.json')) ? Story.parse(files.readJson(files.path('story.json'))) : null;
|
|
35
|
+
const plan = languagePlan(files, world, story, name);
|
|
36
|
+
if (!go) {
|
|
37
|
+
const left = await credits(elevenKey());
|
|
38
|
+
return {
|
|
39
|
+
estimate: plan,
|
|
40
|
+
line: voices ? planLine(plan) : `${plan.texts} texts translated from ${plan.from} to ${plan.to} in one Spark pass; voices left for later`,
|
|
41
|
+
...(left && voices && { elevenLabsCreditsLeft: left.left }),
|
|
42
|
+
next: 'tell the person what it redoes and costs; call again with go: true when they agree',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const blocked = agentBlocked(files.dir, ctx.by);
|
|
46
|
+
if (blocked) throw new ActionError(423, blocked);
|
|
47
|
+
const busy = ctx.jobs.busy(slug);
|
|
48
|
+
if (busy) throw new ActionError(409, `${busy.label} is ${busy.state}; wait before changing the language`);
|
|
49
|
+
const job = ctx.jobs.start(slug, ['language', slug, name, ...(voices ? [] : ['--text-only'])], `language ${name}`, ctx.by);
|
|
50
|
+
return { job, line: voices ? planLine(plan) : 'text only' };
|
|
51
|
+
},
|
|
52
|
+
});
|
package/app/src/actions/world.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { placeTrouble } from '../studio/trouble.ts';
|
|
|
10
10
|
import { withThingEdits, WorldFiles } from '../world/files.ts';
|
|
11
11
|
import { liveVoiceFailures } from '../sound/data.ts';
|
|
12
12
|
import { Slug, World } from '../world/schema.ts';
|
|
13
|
+
import { callVia } from '../meta/memo.ts';
|
|
13
14
|
import { storyNeeds } from '../story/check.ts';
|
|
14
15
|
import { ActionError, defineAction } from './registry.ts';
|
|
15
16
|
|
|
@@ -57,9 +58,9 @@ export function callCounts(dir: string, byVia = false): Record<string, number> {
|
|
|
57
58
|
for (const line of fs.readFileSync(log, 'utf8').split('\n')) {
|
|
58
59
|
if (!line) continue;
|
|
59
60
|
try {
|
|
60
|
-
const call = JSON.parse(line) as { model: string; hit: boolean; via?: string };
|
|
61
|
-
// byVia: "model|via", so Studio names who answered (Claude standing in for Spark) as the live counts do.
|
|
62
|
-
const key = byVia ? `${call.model}|${
|
|
61
|
+
const call = JSON.parse(line) as { model: string; hit: boolean; hash?: string; via?: string };
|
|
62
|
+
// byVia: "model|via", so Studio names who answered (Claude standing in for Spark, Muse Voice transcribing) as the live counts do.
|
|
63
|
+
const key = byVia && !call.hit ? `${call.model}|${callVia(dir, call) ?? ''}` : call.model;
|
|
63
64
|
if (!call.hit) counts[key] = (counts[key] ?? 0) + 1;
|
|
64
65
|
} catch {}
|
|
65
66
|
}
|
package/app/src/commands/cast.ts
CHANGED
|
@@ -6,8 +6,8 @@ import { museEdit } from '../meta/muse.ts';
|
|
|
6
6
|
import { askSparkJson } from '../meta/spark.ts';
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { defineCommand, takeOption } from '../registry.ts';
|
|
9
|
-
import { castBodies,
|
|
10
|
-
import {
|
|
9
|
+
import { castBodies, cutOut, findFigure, paintFigure, readBodies, type Who } from '../scene/cast.ts';
|
|
10
|
+
import { heldMask, heldThings } from '../scene/held.ts';
|
|
11
11
|
import { photoStanding } from '../scene/photo.ts';
|
|
12
12
|
import { rigFigure } from '../scene/rig.ts';
|
|
13
13
|
import { checkSheet } from '../scene/rig-check.ts';
|
|
@@ -64,14 +64,15 @@ defineCommand({
|
|
|
64
64
|
return null;
|
|
65
65
|
});
|
|
66
66
|
if (!made) return;
|
|
67
|
-
const { painting, figure } = made;
|
|
67
|
+
const { painting, figure, share, holds } = made;
|
|
68
68
|
bindExistingAnimation(files, who.key);
|
|
69
69
|
files.write(files.path('cast', `${who.key}-painting.png`), painting);
|
|
70
70
|
files.write(files.path('cast', `${who.key}.png`), figure);
|
|
71
|
+
writeCut(files, who.key, share, holds);
|
|
71
72
|
markStyled(files, 'cast', who.key, style);
|
|
72
73
|
// The same painting cut into parts, so the runtime can walk the figure.
|
|
73
74
|
if (bodies[who.key]?.upright === false) return console.log(`${who.key}: ${files.path('cast', `${who.key}.png`)} · not upright: drawn as painted`);
|
|
74
|
-
await rigCast(files, who.key, painting);
|
|
75
|
+
await rigCast(files, who.key, painting, holds);
|
|
75
76
|
}));
|
|
76
77
|
for (const m of missing) console.log(`set aside, shown as a stand-in: ${m}`);
|
|
77
78
|
console.log(`calls sent: muse ${files.memo.sent.muse}, sam ${files.memo.sent.sam}`);
|
|
@@ -83,9 +84,9 @@ defineCommand({
|
|
|
83
84
|
* rig.json with the joints and how the walk measured. A walk that tears is kept but marked, and the game slides
|
|
84
85
|
* the painted figure as one piece instead. Parts of an older cut-out rig are removed.
|
|
85
86
|
*/
|
|
86
|
-
export async function rigCast(files: WorldFiles, key: string, painting: Buffer) {
|
|
87
|
+
export async function rigCast(files: WorldFiles, key: string, painting: Buffer, holds: string[] = []) {
|
|
87
88
|
try {
|
|
88
|
-
let { rig, parts, issues } = await rigFigure(files.memo, key, painting);
|
|
89
|
+
let { rig, parts, issues } = await rigFigure(files.memo, key, painting, 'warp', holds);
|
|
89
90
|
rig.check = rig.sheet ? await checkSheet(rig, parts[rig.sheet.file]) : undefined;
|
|
90
91
|
if (rig.check && !rig.check.ok) {
|
|
91
92
|
// Try a separate neutral source for the warp; never overwrite the creator's painting.
|
|
@@ -96,7 +97,7 @@ export async function rigCast(files: WorldFiles, key: string, painting: Buffer)
|
|
|
96
97
|
const verdict = schema.parse(await askSparkJson(files.memo, { label: `rig-neutral-check:${key}`, schema, images: [painting,neutral], personal: true,
|
|
97
98
|
prompt: 'Compare the original and proposed neutral standing character. Check identity, clothing, colours and proportions match; legs are visible and not already walking; every object held in the original remains complete and held in the same hand. If none is held, heldPreserved is true. Reply JSON {"matches":true,"neutral":true,"heldPreserved":true,"reason":"short explanation"}.' }));
|
|
98
99
|
if (!verdict.matches || !verdict.neutral || !verdict.heldPreserved) throw new Error(verdict.reason || 'neutral pose did not preserve the original');
|
|
99
|
-
const candidate = await rigFigure(files.memo, key, neutral);
|
|
100
|
+
const candidate = await rigFigure(files.memo, key, neutral, 'warp', holds);
|
|
100
101
|
candidate.rig.check = candidate.rig.sheet ? await checkSheet(candidate.rig, candidate.parts[candidate.rig.sheet.file]) : undefined;
|
|
101
102
|
if (!candidate.rig.check?.ok) throw new Error(candidate.rig.check?.why ?? 'neutral walk did not pass');
|
|
102
103
|
rig = candidate.rig; parts = candidate.parts; issues = [...issues, 'walk prepared from a verified neutral pose'];
|
|
@@ -121,22 +122,40 @@ export async function rigCast(files: WorldFiles, key: string, painting: Buffer)
|
|
|
121
122
|
|
|
122
123
|
export const rigAttemptFile = (files: WorldFiles, key: string) => files.path('cast', key, 'rig-attempt.json');
|
|
123
124
|
|
|
125
|
+
/**
|
|
126
|
+
* cast/<key>/cut.json: what the cut-out keeps beyond the body (`holds`, SAM phrases) and the share of its height the
|
|
127
|
+
* body takes (`share`), so the game draws the body at its own height and a slice of toast held over it above.
|
|
128
|
+
*/
|
|
129
|
+
export const cutFile = (files: WorldFiles, key: string) => files.path('cast', key, 'cut.json');
|
|
130
|
+
export function writeCut(files: WorldFiles, key: string, share: number, holds: string[]) {
|
|
131
|
+
files.write(cutFile(files, key), { share, holds });
|
|
132
|
+
}
|
|
133
|
+
|
|
124
134
|
defineCommand({
|
|
125
135
|
name: 'rig',
|
|
126
|
-
usage: 'rig <slug> [key...] make the walk again from the cast paintings already painted (no Muse; SAM answers are kept), and cut the figure out again so what it holds stays on',
|
|
136
|
+
usage: 'rig <slug> [key...] make the walk again from the cast paintings already painted (no Muse; SAM answers are kept), and cut the figure out again so what it holds stays on (what the description says they hold, or cast/fixes.json holds)',
|
|
127
137
|
async run(args) {
|
|
128
138
|
const [slug, ...only] = args;
|
|
129
139
|
if (!slug) throw new Error('usage: diorama rig <slug> [key...]');
|
|
130
140
|
const files = new WorldFiles(slug);
|
|
141
|
+
const world = files.world();
|
|
131
142
|
const bodies = readBodies(files, fs.existsSync);
|
|
143
|
+
const looksOf = new Map<string, string>([[world.hero.key, world.hero.looks], ...world.places.flatMap((p) => p.things.filter((t) => t.role === 'npc').map((t) => [t.key, t.looks] as [string, string])), ...players(files).map((p) => [p.key, p.looks] as [string, string])]);
|
|
132
144
|
const keys = fs.readdirSync(files.path('cast')).filter((f) => f.endsWith('-painting.png')).map((f) => f.slice(0, -'-painting.png'.length)).filter((k) => !only.length || only.includes(k));
|
|
133
145
|
for (const key of keys) {
|
|
134
|
-
if (bodies[key]?.upright === false) continue;
|
|
135
146
|
const painting = fs.readFileSync(files.path('cast', `${key}-painting.png`));
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
await
|
|
147
|
+
const who = fixedLooks(files, { key, looks: looksOf.get(key) ?? '' });
|
|
148
|
+
const holds = await heldThings(files.memo, who);
|
|
149
|
+
const person = await findFigure(files.memo, who, painting);
|
|
150
|
+
if (person) {
|
|
151
|
+
const { figure, share } = await cutOut(painting, person.mask, await heldMask(files.memo, key, painting, person.mask, holds));
|
|
152
|
+
files.write(files.path('cast', `${key}.png`), figure);
|
|
153
|
+
writeCut(files, key, share, holds);
|
|
154
|
+
console.log(`${key}: cut out again${holds.length ? `, holding ${holds.join(', ')}` : ''}${share < 1 ? ` (the body is ${Math.round(share * 100)}% of its height)` : ''}`);
|
|
155
|
+
}
|
|
156
|
+
if (bodies[key]?.upright === false) continue;
|
|
157
|
+
await rigCast(files, key, painting, holds);
|
|
139
158
|
}
|
|
140
|
-
console.log(`calls sent: sam ${files.memo.sent.sam}`);
|
|
159
|
+
console.log(`calls sent: spark ${files.memo.sent.spark}, sam ${files.memo.sent.sam}`);
|
|
141
160
|
},
|
|
142
161
|
});
|
|
@@ -7,6 +7,19 @@ import { defineCommand } from '../registry.ts';
|
|
|
7
7
|
import { WorldFiles } from '../world/files.ts';
|
|
8
8
|
import { bundle, WEB, worldData } from './play.ts';
|
|
9
9
|
|
|
10
|
+
/** A language name ("Polish", "polski") or code ("pl", "pt-BR") as a BCP 47 code, when Intl knows it. */
|
|
11
|
+
function langCode(language?: string): string | undefined {
|
|
12
|
+
if (!language) return undefined;
|
|
13
|
+
const t = language.trim();
|
|
14
|
+
if (/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/i.test(t)) return t;
|
|
15
|
+
const names = new Intl.DisplayNames(['en'], { type: 'language' });
|
|
16
|
+
for (const code of ['en', 'pl', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'cs', 'sk', 'uk', 'ru', 'sv', 'no', 'da', 'fi', 'hu', 'ro', 'el', 'tr', 'ja', 'ko', 'zh', 'ar', 'he', 'hi']) {
|
|
17
|
+
const own = new Intl.DisplayNames([code], { type: 'language' }).of(code);
|
|
18
|
+
if ([names.of(code), own].some((n) => n?.toLowerCase() === t.toLowerCase())) return code;
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
const esc = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
11
24
|
|
|
12
25
|
/** The link preview (X, Facebook, chats): the world's title and pitch, and a 1200x630 picture: the world's own og.jpg when it has one, else its first painted place. */
|
|
@@ -115,6 +128,9 @@ export async function exportWorld(slug: string, out: string, home?: string, url?
|
|
|
115
128
|
page = page.replace('<body>', '<body data-shipped>');
|
|
116
129
|
if (home) page = page.replace('<div id="soundless"', `<a id="home" href="${esc(home)}">← Diorama</a>\n<div id="soundless"`);
|
|
117
130
|
page = page.replace('<title>Diorama</title>', await sharePreview(files, out, url));
|
|
131
|
+
// The page's language follows the world's, so browsers and screen readers read a Polish world as Polish.
|
|
132
|
+
const lang = langCode(files.world().language);
|
|
133
|
+
if (lang) page = page.replace('<html lang="en">', `<html lang="${lang}">`);
|
|
118
134
|
fs.writeFileSync(path.join(out, 'index.html'), page);
|
|
119
135
|
}
|
|
120
136
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// diorama language <slug> <Language>: the world told in another language. Title, pitch, names and every line of
|
|
2
|
+
// the story are translated in one Spark pass; the paintings, masks, sounds and puzzles stay as they are. Then the
|
|
3
|
+
// voices are cast again for the new language (no accent of the old one) and every spoken line is read again,
|
|
4
|
+
// and the talking faces follow the new takes.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { defineCommand, registry } from '../registry.ts';
|
|
7
|
+
import { castFixesFile, readCastFixes } from '../scene/cast-fixes.ts';
|
|
8
|
+
import { SoundPlan } from '../sound/plan.ts';
|
|
9
|
+
import { readStoryFixes, StoryFixes, storyFixesFile, textAt } from '../story/fixes.ts';
|
|
10
|
+
import { Story } from '../story/schema.ts';
|
|
11
|
+
import { WorldFiles } from '../world/files.ts';
|
|
12
|
+
import { applyWorldTexts, languagePlan, planLine, relanguage, translateTexts, worldTexts } from '../world/language.ts';
|
|
13
|
+
import { placeSources, soundPlanFrom } from './sound.ts';
|
|
14
|
+
|
|
15
|
+
const run = (name: string, args: string[]) => registry.get(name)!.run(args);
|
|
16
|
+
|
|
17
|
+
defineCommand({
|
|
18
|
+
name: 'language',
|
|
19
|
+
usage: 'language <slug> <Language> tell the world in another language ("English", "Polish"): title, pitch, names and the story translated in one Spark pass, then the voices cast again for that language and every spoken line read again [--text-only: leave the voices for later] [--estimate: only say what it redoes and costs]',
|
|
20
|
+
async run(args) {
|
|
21
|
+
const estimate = args.includes('--estimate');
|
|
22
|
+
const textOnly = args.includes('--text-only');
|
|
23
|
+
const [slug, language] = args.filter((a) => !a.startsWith('--'));
|
|
24
|
+
if (!slug || !language) throw new Error('usage: diorama language <slug> <Language> [--text-only] [--estimate]');
|
|
25
|
+
const files = new WorldFiles(slug);
|
|
26
|
+
const world = files.world();
|
|
27
|
+
const storyFile = files.path('story.json');
|
|
28
|
+
const story = fs.existsSync(storyFile) ? Story.parse(files.readJson(storyFile)) : null;
|
|
29
|
+
const plan = languagePlan(files, world, story, language);
|
|
30
|
+
console.log(`language ${slug}: ${planLine(plan)}`);
|
|
31
|
+
if (estimate) return;
|
|
32
|
+
|
|
33
|
+
if (!plan.same) {
|
|
34
|
+
const texts = worldTexts(world, story);
|
|
35
|
+
const kids = world.audience === 'kids' || fs.existsSync(files.path('drawings'));
|
|
36
|
+
console.log(`translating ${Object.keys(texts).length} texts from ${plan.from} to ${plan.to}`);
|
|
37
|
+
const translated = await translateTexts(files.memo, texts, plan.from, plan.to, kids);
|
|
38
|
+
const now = applyWorldTexts(world, story, translated, plan.to);
|
|
39
|
+
// sound.json is planned from the world and story: the words changed, not what makes a sound, so the plan stays.
|
|
40
|
+
const soundFile = files.path('sound.json');
|
|
41
|
+
const sound = fs.existsSync(soundFile) ? SoundPlan.safeParse(files.readJson(soundFile)) : undefined;
|
|
42
|
+
const sources = sound?.success ? placeSources(files, world) : undefined;
|
|
43
|
+
const soundWasCurrent = sound?.success && sources && sound.data.from === soundPlanFrom(world, story, sources);
|
|
44
|
+
|
|
45
|
+
files.write(files.path('world.json'), { ...JSON.parse(fs.readFileSync(files.path('world.json'), 'utf8')), ...now.world, ...(now.world.language ? {} : { language: undefined }) });
|
|
46
|
+
if (now.story) {
|
|
47
|
+
files.write(storyFile, now.story);
|
|
48
|
+
// The person's reworded lines are kept across rebuilds: they are reworded in the new language too.
|
|
49
|
+
const fixes = readStoryFixes(files);
|
|
50
|
+
if (Object.keys(fixes.texts).length) {
|
|
51
|
+
for (const path of Object.keys(fixes.texts)) {
|
|
52
|
+
const text = textAt(now.story, path);
|
|
53
|
+
if (text) fixes.texts[path] = { ...fixes.texts[path], text };
|
|
54
|
+
}
|
|
55
|
+
files.write(storyFixesFile(files), StoryFixes.parse(fixes));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// A voice the person described for the old language ("Native Polish speaker") is asked for in the new one.
|
|
59
|
+
const castFixes = readCastFixes(files);
|
|
60
|
+
let castChanged = false;
|
|
61
|
+
for (const fix of Object.values(castFixes)) {
|
|
62
|
+
if (fix.voice?.design && fix.voice.design !== relanguage(fix.voice.design, plan.from, plan.to)) {
|
|
63
|
+
fix.voice.design = relanguage(fix.voice.design, plan.from, plan.to);
|
|
64
|
+
castChanged = true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (castChanged) files.write(castFixesFile(files), castFixes);
|
|
68
|
+
if (soundWasCurrent && sound?.success && sources) files.write(soundFile, { ...sound.data, from: soundPlanFrom(files.world(), now.story, sources) });
|
|
69
|
+
for (const id of ['world.title', 'world.pitch']) console.log(` ${id}: ${translated[id]}`);
|
|
70
|
+
for (const [id, text] of Object.entries(translated).filter(([id]) => id.startsWith('names.'))) console.log(` ${id.slice(6)}: ${texts[id]} -> ${text}`);
|
|
71
|
+
console.log(`world.json and story.json are in ${plan.to} now`);
|
|
72
|
+
}
|
|
73
|
+
if (textOnly) return void console.log('voices left for later: run_step voices, then faces');
|
|
74
|
+
if (!story) return;
|
|
75
|
+
console.log('\n# voices');
|
|
76
|
+
await run('voices', [slug]);
|
|
77
|
+
if (fs.existsSync(files.path('cast'))) {
|
|
78
|
+
console.log('\n# faces');
|
|
79
|
+
try {
|
|
80
|
+
await run('build', [slug, '--from', 'faces', '--to', 'faces']);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
// Talking mouths are polish: the voices are in, the game plays without them.
|
|
83
|
+
console.log(`faces not made again (${error instanceof Error ? error.message : error}): run_step faces later`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
});
|
package/app/src/commands/play.ts
CHANGED
|
@@ -118,7 +118,10 @@ export function worldData(files: WorldFiles) {
|
|
|
118
118
|
const walks = body?.upright !== false;
|
|
119
119
|
// A creature has no legs for a 2D rig, but its own 3D model (no skeleton, model.json creature) hops and squashes.
|
|
120
120
|
const creatureModel = model?.info.creature ? model : undefined;
|
|
121
|
-
|
|
121
|
+
// cast/<key>/cut.json (diorama cast, rig): the share of the cut-out's height the body takes when it holds something above it.
|
|
122
|
+
const cutAt = files.path('cast', key, 'cut.json');
|
|
123
|
+
const share = fs.existsSync(cutAt) ? Number((files.readJson(cutAt) as { share?: unknown }).share) : 1;
|
|
124
|
+
return [key, { image: `cast/${f}`, ...(share > 0 && share < 1 && { share }), ...(face && { face }), ...(body && { height: body.height }), ...(!walks && { upright: false }), ...(walks && rig && rig.check?.ok !== false ? { rig, parts: `cast/${key}` } : {}), ...(walks ? model && { model } : creatureModel && { model: creatureModel }), ...(walks && sheets && { sprites: sheets }) }];
|
|
122
125
|
}));
|
|
123
126
|
const story = fs.existsSync(files.path('story.json')) ? Story.parse(files.readJson(files.path('story.json'))) : null;
|
|
124
127
|
// changes/<i>.png per place: how story.changes[i] looks once it holds.
|
|
@@ -12,6 +12,20 @@ import { readSoundFixes } from '../sound/fixes.ts';
|
|
|
12
12
|
import { elevenKey, musicgenPython } from '../sound/sources.ts';
|
|
13
13
|
import { Story } from '../story/schema.ts';
|
|
14
14
|
import { WorldFiles } from '../world/files.ts';
|
|
15
|
+
import type { World } from '../world/schema.ts';
|
|
16
|
+
|
|
17
|
+
/** What each place's sounds are planned from: its painting brief and floors. */
|
|
18
|
+
export function placeSources(files: WorldFiles, world: World): Record<string, PlaceSources> {
|
|
19
|
+
const sources: Record<string, PlaceSources> = {};
|
|
20
|
+
for (const place of world.places) {
|
|
21
|
+
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);
|
|
22
|
+
sources[place.key] = { brief: read('brief.json', Brief), layout: read('layout.json', Layout) };
|
|
23
|
+
}
|
|
24
|
+
return sources;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** sound.json's `from`: the plan is made again when this changes. */
|
|
28
|
+
export const soundPlanFrom = (world: World, story: Story | null, sources: Record<string, PlaceSources>) => inputHash(JSON.stringify(world), JSON.stringify(story), JSON.stringify(sources));
|
|
15
29
|
|
|
16
30
|
defineCommand({
|
|
17
31
|
name: 'sound',
|
|
@@ -31,16 +45,12 @@ defineCommand({
|
|
|
31
45
|
const files = new WorldFiles(slug);
|
|
32
46
|
const world = files.world();
|
|
33
47
|
const story = fs.existsSync(files.path('story.json')) ? Story.parse(files.readJson(files.path('story.json'))) : null;
|
|
34
|
-
const sources
|
|
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
|
-
}
|
|
48
|
+
const sources = placeSources(files, world);
|
|
39
49
|
|
|
40
50
|
// sound.json is the plan people may edit. It is made again with --replan, or when what it was made from
|
|
41
51
|
// (world, story, briefs, floors) has changed since, so its shots and loops keep matching the story's things.
|
|
42
52
|
const planFile = files.path('sound.json');
|
|
43
|
-
const from =
|
|
53
|
+
const from = soundPlanFrom(world, story, sources);
|
|
44
54
|
const old = fs.existsSync(planFile) ? SoundPlan.parse(files.readJson(planFile)) : undefined;
|
|
45
55
|
let plan: SoundPlan;
|
|
46
56
|
// A plan made before silent things lost their loops drops them here too, before anything is paid for.
|
package/app/src/meta/memo.ts
CHANGED
|
@@ -23,6 +23,20 @@ export function inputHash(...parts: (string | Buffer)[]): string {
|
|
|
23
23
|
/** What a call cost, when the asker knows: dollars and which backend answered (e.g. "claude" for a dev-mode Spark). */
|
|
24
24
|
export type Paid = { data: Buffer; usd?: number; via?: string };
|
|
25
25
|
|
|
26
|
+
/** The `via` of a Muse Voice Transcribe call: it shares the voice memo with ElevenLabs speech, but Meta answers it. */
|
|
27
|
+
export const MUSE_VOICE = 'muse-voice';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Who answered a logged call. Transcripts logged before they carried `via` are told apart from speech by the answer
|
|
31
|
+
* kept in the world's memo (a transcript is .txt or .json, speech .mp3), so old worlds stop counting them as ElevenLabs.
|
|
32
|
+
*/
|
|
33
|
+
export function callVia(dir: string, call: { model: string; hash?: string; via?: unknown }): string | undefined {
|
|
34
|
+
if (typeof call.via === 'string') return call.via;
|
|
35
|
+
if (call.model !== 'voice' || !call.hash) return undefined;
|
|
36
|
+
const at = (ext: string) => fs.existsSync(path.join(dir, 'memo', 'voice', `${call.hash}.${ext}`));
|
|
37
|
+
return at('txt') || at('json') ? MUSE_VOICE : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
export class Memo {
|
|
27
41
|
/** Calls actually sent to each model in this process (cache hits are not counted). */
|
|
28
42
|
readonly sent: Record<ModelName, number> = { spark: 0, muse: 0, sam: 0, sound: 0, music: 0, voice: 0, space: 0 };
|
package/app/src/meta/voice.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Muse Voice Transcribe realtime transport. File uploads use voice-file.ts through speech/transcribe.ts.
|
|
2
2
|
import { apiKey } from './http.ts';
|
|
3
|
-
import { inputHash, type Memo } from './memo.ts';
|
|
3
|
+
import { inputHash, MUSE_VOICE, type Memo } from './memo.ts';
|
|
4
4
|
|
|
5
5
|
export const VOICE_TRANSCRIBE = 'muse-voice-transcribe-1.0';
|
|
6
6
|
const URL_ASR = 'wss://api.meta.ai/v1/asr/realtime';
|
|
@@ -29,7 +29,7 @@ export function transcribe(memo: Memo, label: string, pcm24k: Buffer, pace = 1):
|
|
|
29
29
|
}
|
|
30
30
|
});
|
|
31
31
|
queue = run.catch(() => {});
|
|
32
|
-
return run;
|
|
32
|
+
return run.then((data) => ({ data, via: MUSE_VOICE }));
|
|
33
33
|
}).then((b) => b.toString('utf8'));
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -6,6 +6,8 @@ import type { WorldFiles } from '../world/files.ts';
|
|
|
6
6
|
|
|
7
7
|
export const CastFixes = z.record(z.string(), z.object({
|
|
8
8
|
looks: z.string().min(10).optional(),
|
|
9
|
+
/** What they hold, as short SAM phrases ("slice of toast"), kept on the cut-out whatever its size (cast_holds); [] keeps nothing. */
|
|
10
|
+
holds: z.array(z.string().min(2)).max(4).optional(),
|
|
9
11
|
take: z.number().int().min(0).optional(),
|
|
10
12
|
/** The person asked for a 3D model (cast_animate) though the story would not build one: the model step builds it every time. */
|
|
11
13
|
animate: z.boolean().optional(),
|
|
@@ -25,7 +27,7 @@ export function readCastFixes(files: WorldFiles): CastFixes {
|
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/** A character with the person's description in place of the world's. */
|
|
28
|
-
export function fixedLooks<T extends { key: string; looks: string }>(files: WorldFiles, who: T): T {
|
|
29
|
-
const looks = readCastFixes(files)[who.key]
|
|
30
|
-
return
|
|
30
|
+
export function fixedLooks<T extends { key: string; looks: string }>(files: WorldFiles, who: T): T & { holds?: string[] } {
|
|
31
|
+
const { looks, holds } = readCastFixes(files)[who.key] ?? {};
|
|
32
|
+
return { ...who, ...(looks && { looks }), ...(holds && { holds }) };
|
|
31
33
|
}
|
package/app/src/scene/cast.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { STYLE_REFERENCE_PROMPT } from '../world/style-reference.ts';
|
|
|
3
3
|
import sharp from 'sharp';
|
|
4
4
|
import type { Mask } from '../image/mask.ts';
|
|
5
5
|
import { maskBox, union } from '../image/raster.ts';
|
|
6
|
-
import { bodyShare, heldMask, heldThings } from './held.ts';
|
|
6
|
+
import { bodyShare, groundGaps, heldMask, heldThings } from './held.ts';
|
|
7
7
|
import { companions, silhouette } from './warp.ts';
|
|
8
8
|
import type { Memo } from '../meta/memo.ts';
|
|
9
9
|
import { museEdit, musePaint } from '../meta/muse.ts';
|
|
@@ -40,7 +40,10 @@ export async function cutOut(painting: Buffer, mask: Mask, held?: Mask): Promise
|
|
|
40
40
|
const { data, info } = await sharp(painting).resize(mask.width, mask.height, { fit: 'fill' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
41
41
|
// SAM's person leaves out what they hold (a lantern, a net of fish) and leaves holes where it hangs over them.
|
|
42
42
|
// ... and what floats beside them as part of who they are (a heart, the bubble they are in).
|
|
43
|
-
const
|
|
43
|
+
const body = held ? union(mask, held) : mask;
|
|
44
|
+
const whole = companions(data, silhouette(data, body));
|
|
45
|
+
// Held up in both hands, the thing closes a window of plain ground with the arms and head: it stays see-through.
|
|
46
|
+
if (held) for (const gap of groundGaps(data, whole, body)) gap.bits.forEach((b, i) => b && (whole.bits[i] = 0));
|
|
44
47
|
for (let i = 0; i < whole.bits.length; i++) if (!whole.bits[i]) data[i * info.channels + 3] = 0;
|
|
45
48
|
const [x0, y0, x1, y1] = maskBox(whole)!;
|
|
46
49
|
const figure = await sharp(data, { raw: { width: info.width, height: info.height, channels: 4 } })
|
|
@@ -112,13 +115,8 @@ export async function paintFigure(memo: Memo, world: World, who: Who, take = 0,
|
|
|
112
115
|
: (from?.styleReference ? await edit({ label: `figure:${who.key}`, prompt: figurePrompt(world, who), images: [], take: t }) : await musePaint(memo, { label: `figure:${who.key}`, prompt: figurePrompt(world, who), take: t }));
|
|
113
116
|
const png = await sharp(painting).png().toBuffer();
|
|
114
117
|
// Not every character is a person (a flock of pigeons, a cat): SAM is asked by their own description next.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
found = await samFind(memo, { label: `figure:${who.key}`, image: png, prompt });
|
|
118
|
-
if (found.length) break;
|
|
119
|
-
}
|
|
120
|
-
if (!found.length) continue;
|
|
121
|
-
const biggest = found.reduce((a, b) => (area(b.mask) > area(a.mask) ? b : a));
|
|
118
|
+
const biggest = await findFigure(memo, who, png);
|
|
119
|
+
if (!biggest) continue;
|
|
122
120
|
const made = { painting: png, ...(await cutOut(png, biggest.mask, await heldMask(memo, who.key, png, biggest.mask, holds))), holds };
|
|
123
121
|
const why = (await boxed(made.figure)) ?? (upright ? await notStanding(memo, who.key, made.figure, t) : undefined);
|
|
124
122
|
if (!why) return made;
|
|
@@ -132,6 +130,15 @@ export async function paintFigure(memo: Memo, world: World, who: Who, take = 0,
|
|
|
132
130
|
throw new Error(`${who.key}: SAM found no one in the painting, two takes; try another take (cast_redo anotherTake)`);
|
|
133
131
|
}
|
|
134
132
|
|
|
133
|
+
/** The biggest one SAM finds of the character: as a person, else by its own description (a flock of pigeons, a cat), else as an animal. */
|
|
134
|
+
export async function findFigure(memo: Memo, who: Who, png: Buffer) {
|
|
135
|
+
for (const prompt of ['person', who.looks.split(/[,.;]/)[0].trim().split(/\s+/).slice(0, 6).join(' '), 'animal']) {
|
|
136
|
+
const found = await samFind(memo, { label: `figure:${who.key}`, image: png, prompt });
|
|
137
|
+
if (found.length) return found.reduce((a, b) => (area(b.mask) > area(a.mask) ? b : a));
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
135
142
|
/**
|
|
136
143
|
* Why a cut-out of a person on two legs is not one standing whole, or undefined when it is. A standing figure is
|
|
137
144
|
* about twice as tall as wide (robots and big-headed drawn children down to about 1.4), so only a squatter one is
|