@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,284 @@
|
|
|
1
|
+
// diorama model <slug> [key]: a character as a rigged 3D model, painted by Muse from four sides. Without a
|
|
2
|
+
// key, everyone the sprites plan needs (the hero, NPCs who speak): the model is where their poses come from.
|
|
3
|
+
import { castLanes, mapLimit } from '../world/pool.ts';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { bindExistingAnimation, bindAnimation } from '../scene/animation-source.ts';
|
|
6
|
+
import { refusedByFilter } from '../scene/words.ts';
|
|
7
|
+
import { defineCommand, registry, takeOption } from '../registry.ts';
|
|
8
|
+
import { autoRig, reconstruct } from '../model/build.ts';
|
|
9
|
+
import { fitViews, refitIfOld } from '../model/fit.ts';
|
|
10
|
+
import { badModel, lean, readShape, rigProblem, standUp } from '../model/glb.ts';
|
|
11
|
+
import { findBlender, relaxPose, type RelaxReport } from '../model/blender.ts';
|
|
12
|
+
import { paintViews } from '../model/views.ts';
|
|
13
|
+
import { planSprites } from '../model/plan.ts';
|
|
14
|
+
import { animationSettings, animationOptions, saveAnimationSettings } from '../model/animation.ts';
|
|
15
|
+
import { Story } from '../story/schema.ts';
|
|
16
|
+
import { fixedLooks, readCastFixes } from '../scene/cast-fixes.ts';
|
|
17
|
+
import { readBodies } from '../scene/cast.ts';
|
|
18
|
+
import { WorldFiles } from '../world/files.ts';
|
|
19
|
+
|
|
20
|
+
defineCommand({
|
|
21
|
+
name: 'model',
|
|
22
|
+
usage: 'model <slug> [key] with [key], also finishes its required sprites; Muse paints each character who needs sprites (or [key]) from 4 sides, TRELLIS + auto-rig make a 3D model (cast/<key>/model/) [--texture model|views: saved per character] [--glb rigged.glb: skip TRELLIS and the rig] [--coat-correction on|off: off for trousers] [--sprites: finish the character animation, used by Studio] [--take N] [--refit: only fit the views onto the model again, no calls] [--relax: only put existing models into a calm rest pose with a local Blender, no calls]',
|
|
23
|
+
async run(args) {
|
|
24
|
+
const options = animationOptions(takeOption(args, 'texture', ''), '', takeOption(args, 'coat-correction', ''));
|
|
25
|
+
const take = Number(takeOption(args, 'take', '0'));
|
|
26
|
+
const given = takeOption(args, 'glb', '');
|
|
27
|
+
const refit = args.includes('--refit');
|
|
28
|
+
const relaxOnly = args.includes('--relax');
|
|
29
|
+
const [slug, keyArg] = args.filter((a) => a !== '--refit' && a !== '--sprites' && a !== '--relax');
|
|
30
|
+
// An individual Animate request includes its sprites, including older Studio clients.
|
|
31
|
+
const withSprites = !!keyArg || args.includes('--sprites');
|
|
32
|
+
if (!slug) throw new Error('usage: diorama model <slug> [key] [--glb rigged.glb] [--refit]');
|
|
33
|
+
if (refit) {
|
|
34
|
+
if (Object.keys(options).length) throw new Error('--texture cannot be combined with --refit');
|
|
35
|
+
const files = new WorldFiles(slug);
|
|
36
|
+
const castDir = files.path('cast');
|
|
37
|
+
for (const key of keyArg ? [keyArg] : fs.readdirSync(castDir).filter((k) => fs.existsSync(files.path('cast', k, 'model', 'model.json')))) {
|
|
38
|
+
await refitIfOld(files.path('cast', key, 'model'), true);
|
|
39
|
+
console.log(`${key}: views fitted again, slice by slice`);
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (relaxOnly) {
|
|
44
|
+
const blender = await findBlender();
|
|
45
|
+
if (!blender) throw new Error('--relax needs Blender 3.6+ (install it, or set DIORAMA_BLENDER to its path)');
|
|
46
|
+
const files = new WorldFiles(slug);
|
|
47
|
+
const castDir = files.path('cast');
|
|
48
|
+
const keys = keyArg ? [keyArg] : fs.readdirSync(castDir).filter((k) => fs.existsSync(files.path('cast', k, 'model', 'model.json')));
|
|
49
|
+
for (const key of keys) console.log(`${key}: ${await relaxModel(files, key, true)}`);
|
|
50
|
+
// Renders of the model itself follow it; painted sprites are redrawn by diorama sprites (Muse).
|
|
51
|
+
for (const key of keys) if (animationSettings(files, key).texture === 'model') await registry.get('sprites')!.run([slug, key]);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (given && !keyArg) throw new Error('--glb needs the key of the character it is');
|
|
55
|
+
const files = new WorldFiles(slug);
|
|
56
|
+
const story = fs.existsSync(files.path('story.json')) ? Story.parse(files.readJson(files.path('story.json'))) : undefined;
|
|
57
|
+
// Everyone the sprites plan needs, and whoever the person asked to animate (cast_animate, cast/fixes.json).
|
|
58
|
+
const asked = Object.entries(readCastFixes(files)).filter(([, f]) => f.animate).map(([k]) => k);
|
|
59
|
+
const keys = keyArg ? [keyArg] : [...new Set([...(await planSprites(files, story)).map((p) => p.key), ...asked])];
|
|
60
|
+
for (const key of keys) if (Object.keys(options).length) saveAnimationSettings(files, key, animationSettings(files, key, options));
|
|
61
|
+
await buildModels(files, keys, take, given, !!keyArg);
|
|
62
|
+
console.log(`calls sent: muse ${files.memo.sent.muse}, space ${files.memo.sent.space}`);
|
|
63
|
+
// UV sprites are renders of the model itself (no Muse): a new model without them leaves the game walking
|
|
64
|
+
// the old body's sheets (a pole the new sides left out, an old face), so they are always rendered again.
|
|
65
|
+
const uvSprites = (key: string) => {
|
|
66
|
+
try {
|
|
67
|
+
return animationSettings(files, key).texture === 'model' && fs.existsSync(files.path('cast', key, 'model', 'model.json'));
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
if (withSprites || keys.some(uvSprites)) {
|
|
73
|
+
const plans = await planSprites(files, story);
|
|
74
|
+
for (const key of keys) if ((withSprites || uvSprites(key)) && plans.some((p) => p.key === key && !p.creature)) await registry.get('sprites')!.run([slug, key]);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** Said when a character is left in 2D because the 3D service did not answer: cast_animate tries it again. */
|
|
80
|
+
export const SERVICE_DOWN = 'the 3D service did not answer';
|
|
81
|
+
/** Pauses before asking TRELLIS again after an error; tests shorten them. */
|
|
82
|
+
export const SERVICE_PAUSES = { ms: [10_000, 30_000, 60_000] };
|
|
83
|
+
/** Errors that asking again will not mend: no quota left, no access, a refused picture. */
|
|
84
|
+
const lasting = (message: string) => /quota|\b40[13]\b|unauthori[sz]ed|forbidden|NSFW|not allowed/i.test(message);
|
|
85
|
+
|
|
86
|
+
/** An unavailable service for one character must not prevent the rest of the cast being attempted. */
|
|
87
|
+
export async function buildModels(files: WorldFiles, keys: string[], take: number, given: string, requireModel = false) {
|
|
88
|
+
const failures: string[] = [];
|
|
89
|
+
// Characters side by side (a few at once): each waits on Muse, Claude and the Spaces, never on another character.
|
|
90
|
+
// The Spaces keep their own small lanes (model/build.ts), so a busy GPU queue is not asked twice as hard.
|
|
91
|
+
await mapLimit(keys, castLanes(), async (key) => {
|
|
92
|
+
try {
|
|
93
|
+
const made = await buildModel(files, key, take, given);
|
|
94
|
+
if (!made && requireModel) failures.push(`${key}: no usable 3D model (${attemptReason(files, key)}); see cast status`);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const reason = `${key}: ${error instanceof Error ? error.message : String(error)}`;
|
|
97
|
+
console.error(reason);
|
|
98
|
+
failures.push(reason);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
if (failures.length) throw new Error(`3D models failed: ${failures.join('; ')}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function attemptReason(files: WorldFiles, key: string): string {
|
|
105
|
+
try {
|
|
106
|
+
return String((files.readJson(files.path('cast', key, 'model', 'attempt.json')) as { reason?: unknown }).reason ?? 'unknown');
|
|
107
|
+
} catch {
|
|
108
|
+
return 'unknown';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function buildModel(files: WorldFiles, key: string, take: number, given: string): Promise<boolean> {
|
|
113
|
+
const attempt = files.path('cast', key, 'model', 'attempt.json');
|
|
114
|
+
bindExistingAnimation(files, key);
|
|
115
|
+
try {
|
|
116
|
+
const reason = await makeModel(files, key, take, given);
|
|
117
|
+
if (!reason) bindAnimation(files, key, 'model');
|
|
118
|
+
files.write(attempt, { state: reason ? 'fallback' : 'ready', reason, at: new Date().toISOString() });
|
|
119
|
+
return !reason;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
files.write(attempt, { state: 'failed', reason: error instanceof Error ? error.message : String(error), at: new Date().toISOString() });
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function makeModel(files: WorldFiles, key: string, take: number, given: string): Promise<string | null> {
|
|
127
|
+
const finishSettings = (mesh: Buffer) => {
|
|
128
|
+
const settings = animationSettings(files, key, {}, mesh);
|
|
129
|
+
saveAnimationSettings(files, key, settings);
|
|
130
|
+
return { ...(settings.texture === 'model' && { texture: 'model' as const }), ...(settings.coatCorrection !== undefined && { coatCorrection: settings.coatCorrection }) };
|
|
131
|
+
};
|
|
132
|
+
const world = files.world();
|
|
133
|
+
const found = [world.hero, ...world.places.flatMap((p) => p.things)].find((w) => w.key === key);
|
|
134
|
+
if (!found) throw new Error(`no hero or NPC called ${key}`);
|
|
135
|
+
const supplied = given ? fs.readFileSync(given) : undefined;
|
|
136
|
+
if (supplied) {
|
|
137
|
+
const bad = badModel(readShape(supplied));
|
|
138
|
+
if (bad) throw new Error(`${key}: supplied 3D model rejected: ${bad}`);
|
|
139
|
+
}
|
|
140
|
+
const who = fixedLooks(files, found);
|
|
141
|
+
const painting = fs.readFileSync(files.path('cast', `${key}-painting.png`));
|
|
142
|
+
const out = (name: string) => files.path('cast', key, 'model', name);
|
|
143
|
+
|
|
144
|
+
// Not a person on two legs (cast/figures.json): painted as it is, no skeleton; it hops and squashes as one body.
|
|
145
|
+
const creature = readBodies(files, fs.existsSync)[key]?.upright === false;
|
|
146
|
+
// Each stage says its name ("<key>: ..."), and each side is written as it lands: Studio follows both live.
|
|
147
|
+
console.log(`${key}: painting 4 sides`);
|
|
148
|
+
let views: Awaited<ReturnType<typeof paintViews>>;
|
|
149
|
+
try {
|
|
150
|
+
views = await paintViews(files.memo, key, who.looks ?? '', painting, take, creature, (name, png) => files.write(out(`${name}.png`), png));
|
|
151
|
+
} catch (error) {
|
|
152
|
+
// Refused even in calm words: the model made before stays (a changed prompt reran it), else the 2D rig walks.
|
|
153
|
+
if (!refusedByFilter(error)) throw error;
|
|
154
|
+
if (fs.existsSync(out('model.json')) && fs.existsSync(out('model.glb'))) {
|
|
155
|
+
console.log(`${key}: the image safety filter refused its 4 sides; the 3D model made before stays`);
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
console.log(`${key}: no 3D model: the image safety filter refused its 4 sides; ${creature ? 'the painted figure hops as it is' : 'the game walks the 2D rig'}`);
|
|
159
|
+
return 'the image safety filter refused its 4 sides';
|
|
160
|
+
}
|
|
161
|
+
// TRELLIS sometimes makes a flat card of a flat drawing: other seeds, then the front alone (depth guessed),
|
|
162
|
+
// and with none of them a body, no model: the character walks with its 2D rig.
|
|
163
|
+
const all = [views.front, views.back, views.left, views.right];
|
|
164
|
+
const tries = [[1, all], [2, all], [3, all], [1, [views.front]]] as const;
|
|
165
|
+
let shell: Buffer | undefined;
|
|
166
|
+
// A Space that answers with an error (busy, restarting, a lost file) is asked again after a pause, on the next try;
|
|
167
|
+
// one that keeps failing leaves the character in 2D, so the world is still built and playable.
|
|
168
|
+
let unavailable: string | undefined;
|
|
169
|
+
let failures = 0;
|
|
170
|
+
let built: Buffer | undefined;
|
|
171
|
+
let rigDown: string | undefined;
|
|
172
|
+
let misplaced: string | undefined;
|
|
173
|
+
for (const [seed, from] of tries) {
|
|
174
|
+
if (given) break;
|
|
175
|
+
console.log(`${key}: TRELLIS builds the body${seed > 1 || from.length === 1 ? ` (try ${seed + (from.length === 1 ? 3 : 0)})` : ''}`);
|
|
176
|
+
let made: Buffer;
|
|
177
|
+
try {
|
|
178
|
+
made = await reconstruct(files.memo, key, [...from], seed);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
unavailable = error instanceof Error ? error.message : String(error);
|
|
181
|
+
console.log(`${key}: TRELLIS failed: ${unavailable}`);
|
|
182
|
+
if (lasting(unavailable) || ++failures > SERVICE_PAUSES.ms.length) break;
|
|
183
|
+
const pause = SERVICE_PAUSES.ms[failures - 1];
|
|
184
|
+
console.log(`${key}: asking TRELLIS again in ${Math.round(pause / 1000)} s`);
|
|
185
|
+
await new Promise((resolve) => setTimeout(resolve, pause));
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
unavailable = undefined;
|
|
189
|
+
const bad = badModel(readShape(made));
|
|
190
|
+
if (bad) {
|
|
191
|
+
console.log(`${key}: TRELLIS (seed ${seed}${from.length === 1 ? ', front only' : ''}) made a model that is ${bad}`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (creature) { shell = made; break; }
|
|
195
|
+
// The auto-rig guesses joints from the shape alone and is deterministic: a skeleton it misplaces (a long
|
|
196
|
+
// robe hides the legs) comes back the same for the same body, so the next TRELLIS body gets the next try.
|
|
197
|
+
console.log(`${key}: rigging the skeleton`);
|
|
198
|
+
let skinned: Buffer;
|
|
199
|
+
try {
|
|
200
|
+
skinned = await autoRig(files.memo, key, made);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
rigDown = error instanceof Error ? error.message : String(error);
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
misplaced = rigProblem(skinned) ?? undefined;
|
|
206
|
+
if (!misplaced) { shell = made; built = skinned; break; }
|
|
207
|
+
console.log(`${key}: auto-rig on TRELLIS seed ${seed}${from.length === 1 ? ', front only' : ''}: ${misplaced}`);
|
|
208
|
+
}
|
|
209
|
+
const rejected = out('model.json');
|
|
210
|
+
const reject = (why: string) => {
|
|
211
|
+
if (fs.existsSync(rejected)) fs.renameSync(rejected, out('model-rejected.json'));
|
|
212
|
+
console.log(`${key}: no 3D model: ${why}; ${creature ? 'the painted figure hops as it is' : 'the game walks the 2D rig'}`);
|
|
213
|
+
return why;
|
|
214
|
+
};
|
|
215
|
+
if (rigDown) return reject(`${SERVICE_DOWN}: ${rigDown}`);
|
|
216
|
+
if (!given && !shell) {
|
|
217
|
+
// An older model of this character (a flat one, kept before this gate) must not stay in the game.
|
|
218
|
+
return reject(unavailable ? `${SERVICE_DOWN}: ${unavailable}` : misplaced ? `every skeleton the auto-rig made was misplaced (${misplaced})` : 'every reconstruction was flat or invalid');
|
|
219
|
+
}
|
|
220
|
+
if (creature) {
|
|
221
|
+
// Make-It-Animatable rigs a person's skeleton only; a creature keeps TRELLIS's mesh as it came (no standing up:
|
|
222
|
+
// a cat or a frog is not meant to be straight up and down).
|
|
223
|
+
const mesh = supplied ?? shell!;
|
|
224
|
+
files.write(out('model.glb'), mesh);
|
|
225
|
+
const fits = await fitViews(mesh, views);
|
|
226
|
+
files.write(out('model.json'), { glb: 'model.glb', creature: true, views: fits, ...finishSettings(mesh) });
|
|
227
|
+
console.log(`${key}: ${out('model.glb')} · a creature, no skeleton: hops, squashes and blinks as one body · views ${Object.entries(fits).map(([v, f]) => `${v} ${f.iou}`).join(', ')}`);
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
built ??= supplied!;
|
|
231
|
+
const misrigged = rigProblem(built);
|
|
232
|
+
if (misrigged) return reject(`rigged body rejected: ${misrigged}`);
|
|
233
|
+
const bad = badModel(readShape(built));
|
|
234
|
+
if (bad) return reject(`rigged body rejected: ${bad}`);
|
|
235
|
+
const tilt = lean(readShape(built));
|
|
236
|
+
const rigged = standUp(built);
|
|
237
|
+
const standingProblem = badModel(readShape(rigged));
|
|
238
|
+
if (standingProblem) return reject(`standing body rejected: ${standingProblem}`);
|
|
239
|
+
files.write(out('model.glb'), rigged);
|
|
240
|
+
|
|
241
|
+
const fits = await fitViews(rigged, views);
|
|
242
|
+
files.write(out('model.json'), { glb: 'model.glb', views: fits, ...finishSettings(rigged) });
|
|
243
|
+
console.log(`${key}: ${await relaxModel(files, key)}`);
|
|
244
|
+
const degrees = (slope: number) => Math.round(Math.atan(slope) * 180 / Math.PI * 10) / 10;
|
|
245
|
+
console.log(`${key}: ${out('model.glb')} · stood up ${degrees(tilt.z)}° forward, ${degrees(tilt.x)}° sideways · views ${Object.entries(fits).map(([v, f]) => `${v} ${f.iou}`).join(', ')}`);
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* What the Blender round trip broke, or null. The face is painted onto the surface from the views, so a head
|
|
251
|
+
* that moved, or a mesh whose corners came apart (flat shading splits every triangle on export: faceted
|
|
252
|
+
* normals, a blotchy torn face on the talk sprites), must not replace the rig that worked.
|
|
253
|
+
*/
|
|
254
|
+
export function relaxDamage(source: Buffer, relaxed: Buffer, report: RelaxReport): string | null {
|
|
255
|
+
if ((report.headMove ?? 0) > 0.002) return `the head moved ${(report.headMove! * 100).toFixed(2)}% of the height`;
|
|
256
|
+
const before = readShape(source).positions.length / 3, after = readShape(relaxed).positions.length / 3;
|
|
257
|
+
if (after > before * 1.3) return `the mesh came apart on export (${before} -> ${after} corners)`;
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* With a local Blender, the model's rest pose becomes calm (arms down, feet under the hips): the game's walk
|
|
263
|
+
* then turns bones a little from rest instead of 100 degrees from TRELLIS's raised arms, which pinched the
|
|
264
|
+
* shoulders, glued sleeves to the chest and splayed the hands. model.source.glb keeps the rig as it came, so
|
|
265
|
+
* this can run again; model.json says relaxed. Says what happened, in a line.
|
|
266
|
+
*/
|
|
267
|
+
export async function relaxModel(files: WorldFiles, key: string, again = false): Promise<string> {
|
|
268
|
+
const out = (name: string) => files.path('cast', key, 'model', name);
|
|
269
|
+
const info = files.readJson(out('model.json')) as { glb: string; relaxed?: boolean; creature?: boolean };
|
|
270
|
+
if (info.creature) return 'a creature has no skeleton to relax';
|
|
271
|
+
if (info.relaxed && !again) return 'rest pose already relaxed';
|
|
272
|
+
const blender = await findBlender();
|
|
273
|
+
if (!blender) return 'rest pose as TRELLIS built it (no local Blender; install Blender 3.6+ for calmer walks)';
|
|
274
|
+
const source = fs.existsSync(out('model.source.glb')) ? fs.readFileSync(out('model.source.glb')) : fs.readFileSync(out(info.glb));
|
|
275
|
+
const done = await relaxPose(source, (line) => console.log(`${key}: ${line}`));
|
|
276
|
+
if (!done) return 'rest pose as TRELLIS built it (Blender could not relax it)';
|
|
277
|
+
const problem = rigProblem(done.glb) ?? badModel(readShape(done.glb)) ?? relaxDamage(source, done.glb, done.report);
|
|
278
|
+
if (problem) return `rest pose as TRELLIS built it (relaxed body rejected: ${problem})`;
|
|
279
|
+
if (!fs.existsSync(out('model.source.glb'))) files.write(out('model.source.glb'), source);
|
|
280
|
+
files.write(out('model.glb'), done.glb);
|
|
281
|
+
files.write(out('model.json'), { ...info, glb: 'model.glb', relaxed: true });
|
|
282
|
+
const arms = Object.values(done.report.armTurn ?? {}).map(Math.round).join('/');
|
|
283
|
+
return `Blender ${blender.version} relaxed the rest pose (arms turned ${arms}° down, feet under the hips)`;
|
|
284
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { worldStyleReference } from '../world/style-reference.ts';
|
|
2
|
+
// diorama paint <slug> [place...]: painting.png for each laid-out place.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { writeBrief } from '../scene/brief.ts';
|
|
6
|
+
import { seedRan } from '../scene/carry.ts';
|
|
7
|
+
import { addsPerson, critique, paintedPeople, repaint } from '../scene/critic.ts';
|
|
8
|
+
import { readCorrections, writeCorrections } from '../scene/corrections.ts';
|
|
9
|
+
import { Layout } from '../scene/layout.ts';
|
|
10
|
+
import { paintPlace } from '../scene/paint.ts';
|
|
11
|
+
import { alignPainting, PaintingLayoutError, paintingLayoutHash } from '../scene/paint-layout.ts';
|
|
12
|
+
import { paintPhoto } from '../scene/photo.ts';
|
|
13
|
+
import { restylePlace } from '../scene/restyle.ts';
|
|
14
|
+
import { refusedByFilter, reword, withWords, worded, type Words } from '../scene/words.ts';
|
|
15
|
+
import { defineCommand, takeOption } from '../registry.ts';
|
|
16
|
+
import { drawingsIn } from '../world/drawings.ts';
|
|
17
|
+
import { WorldFiles } from '../world/files.ts';
|
|
18
|
+
import { approvedConcept } from '../world/sketch.ts';
|
|
19
|
+
import { markStyled, placeStyle, readStyled } from '../world/styled.ts';
|
|
20
|
+
|
|
21
|
+
/** Rewords Spark may try after the image safety filter refuses a painting, before the place is set aside. */
|
|
22
|
+
const REWORDS = 2;
|
|
23
|
+
|
|
24
|
+
defineCommand({
|
|
25
|
+
name: 'paint',
|
|
26
|
+
usage: 'paint <slug> [place...] brief, paint, critique and repaint laid-out places [--take N]',
|
|
27
|
+
async run(args) {
|
|
28
|
+
const takeArg = takeOption(args, 'take', '');
|
|
29
|
+
const [slug, ...only] = args;
|
|
30
|
+
if (!slug) throw new Error('usage: diorama paint <slug> [place...] [--take N]');
|
|
31
|
+
const files = new WorldFiles(slug);
|
|
32
|
+
const base = files.world();
|
|
33
|
+
const places = base.places.filter((p) => only.length === 0 || only.includes(p.key));
|
|
34
|
+
await Promise.all(places.map(async (place) => {
|
|
35
|
+
// The place's own style, when the person gave it one, is the world's style for everything painted here.
|
|
36
|
+
const style = placeStyle(files, base, place.key);
|
|
37
|
+
// --take wins; else the take the place's corrections keep (a new take the build asked for stays across rebuilds).
|
|
38
|
+
const take = takeArg ? Number(takeArg) : readCorrections(files.place(place.key)).take ?? 0;
|
|
39
|
+
const world = { ...base, style };
|
|
40
|
+
const layout = Layout.parse(files.readJson(files.place(place.key, 'layout.json')));
|
|
41
|
+
const blockin = fs.readFileSync(files.place(place.key, 'blockin.png'));
|
|
42
|
+
const fromPhoto = fs.existsSync(files.place(place.key, 'photo.png'));
|
|
43
|
+
const concept = approvedConcept(files, 'place', place.key);
|
|
44
|
+
const styleReference = worldStyleReference(files, style);
|
|
45
|
+
// The first painting is kept while its block-in, layout and take stay the same, even without the model
|
|
46
|
+
// cache (a world copied without memo/): a person's region repaint must not repaint the whole place.
|
|
47
|
+
const hash = (words?: Words) => {
|
|
48
|
+
const h = createHash('sha256').update(blockin).update(JSON.stringify(layout)).update(String(take));
|
|
49
|
+
// A new wording paints the place anew; a place never reworded keeps the inputs it had.
|
|
50
|
+
if (words) h.update(JSON.stringify(words));
|
|
51
|
+
if (concept) h.update(concept);
|
|
52
|
+
if (styleReference) h.update(styleReference);
|
|
53
|
+
return h.digest('hex');
|
|
54
|
+
};
|
|
55
|
+
let words = readCorrections(files.place(place.key)).words;
|
|
56
|
+
let inputs = hash(words);
|
|
57
|
+
const kept = files.place(place.key, 'painting-first.json');
|
|
58
|
+
const mtime = (f: string) => fs.statSync(files.place(place.key, f)).mtimeMs;
|
|
59
|
+
const have = ['painting-first.png', 'brief.json', 'critique.json'].every((f) => fs.existsSync(files.place(place.key, f)));
|
|
60
|
+
// Worlds painted before this record: the first painting counts when it is newer than what it was painted from.
|
|
61
|
+
const same = fs.existsSync(kept)
|
|
62
|
+
? (files.readJson(kept) as { inputs?: string }).inputs === inputs
|
|
63
|
+
: have && take === 0 && mtime('painting-first.png') >= Math.max(mtime('blockin.png'), mtime('layout.json'));
|
|
64
|
+
const reuse = have && same;
|
|
65
|
+
const record = (fs.existsSync(kept) ? files.readJson(kept) : {}) as { inputs?: string; applied?: string[] };
|
|
66
|
+
let first: Buffer;
|
|
67
|
+
let found: Awaited<ReturnType<typeof critique>>;
|
|
68
|
+
// The style changed since this place was painted (style_set): Muse repaints the painting as it is, every
|
|
69
|
+
// fix in it, in the new style, keeping the composition, so masks, stand spots and the person's fixes still fit.
|
|
70
|
+
const was = readStyled(files).places[place.key];
|
|
71
|
+
const restyled = reuse && !!was && was !== style && fs.existsSync(files.place(place.key, 'painting.png'));
|
|
72
|
+
if (restyled) {
|
|
73
|
+
const brief = files.readJson(files.place(place.key, 'brief.json')) as Awaited<ReturnType<typeof writeBrief>>;
|
|
74
|
+
const told = worded(place, words);
|
|
75
|
+
first = await restylePlace(files.memo, told, fs.readFileSync(files.place(place.key, 'painting.png')), was, style, brief, take, drawingsIn(files, place));
|
|
76
|
+
console.log(` ${place.key}: repainted in the new style, composition kept`);
|
|
77
|
+
files.write(files.place(place.key, 'painting-first.png'), first);
|
|
78
|
+
found = await critique(files.memo, told, brief, first);
|
|
79
|
+
files.write(files.place(place.key, 'critique.json'), found);
|
|
80
|
+
} else if (reuse) {
|
|
81
|
+
first = fs.readFileSync(files.place(place.key, 'painting-first.png'));
|
|
82
|
+
found = files.readJson(files.place(place.key, 'critique.json')) as typeof found;
|
|
83
|
+
// The critic is asked again (a memo hit while its prompt stays the same): what it now finds unwanted (a person
|
|
84
|
+
// painted in, a double of the place's NPC) is painted out over the painting as it is; the rest stays.
|
|
85
|
+
const brief = files.readJson(files.place(place.key, 'brief.json')) as Awaited<ReturnType<typeof writeBrief>>;
|
|
86
|
+
// A person the critic itself once asked for ("a child kneeling at the dock edge") is taken out of its fixes,
|
|
87
|
+
// so the painting is made again from the first one without them.
|
|
88
|
+
const before = found.issues.length;
|
|
89
|
+
found = { issues: found.issues.filter((i) => !addsPerson(i)) };
|
|
90
|
+
const again = await critique(files.memo, worded(place, words), brief, first);
|
|
91
|
+
// People are looked for on the painting as the game shows it, with every fix in, not only the first one.
|
|
92
|
+
const shown = fs.existsSync(files.place(place.key, 'painting.png')) ? fs.readFileSync(files.place(place.key, 'painting.png')) : first;
|
|
93
|
+
for (const p of shown === first ? [] : await paintedPeople(files.memo, place.key, shown)) {
|
|
94
|
+
again.issues.push({ key: 'person', problem: 'unwanted', region: p.box, fix: `Paint out the ${p.what}: continue what is behind them (planks, water, wall), add nothing new` });
|
|
95
|
+
}
|
|
96
|
+
const had = new Set(found.issues.map((i) => JSON.stringify([i.region, i.fix])));
|
|
97
|
+
const unwanted = again.issues.filter((i) => i.problem === 'unwanted' && !had.has(JSON.stringify([i.region, i.fix])));
|
|
98
|
+
if (unwanted.length || found.issues.length !== before) {
|
|
99
|
+
found = { issues: [...found.issues, ...unwanted].slice(0, 8) };
|
|
100
|
+
files.write(files.place(place.key, 'critique.json'), found);
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
let brief: Awaited<ReturnType<typeof writeBrief>>;
|
|
104
|
+
// When the image safety filter refuses the painting, Spark rewords the place and it is painted again;
|
|
105
|
+
// the wording is kept in corrections.json, so every later paint asks with it and no rebuild repeats the refusal.
|
|
106
|
+
// A wording Spark made in an earlier run counts its rewords, so the next one is a new ask.
|
|
107
|
+
const before = words?.tries ?? 0;
|
|
108
|
+
for (let refusals = 0; ; refusals++) {
|
|
109
|
+
const told = worded(place, words);
|
|
110
|
+
brief = withWords(await writeBrief(files.memo, world, told, layout), words);
|
|
111
|
+
files.write(files.place(place.key, 'brief.json'), brief);
|
|
112
|
+
try {
|
|
113
|
+
first = fromPhoto
|
|
114
|
+
? await paintPhoto(files.memo, world, told, blockin, brief, take, concept, styleReference)
|
|
115
|
+
: await paintPlace(files.memo, world, told, layout, blockin, brief, take, drawingsIn(files, place), concept, styleReference);
|
|
116
|
+
break;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (!refusedByFilter(error)) throw error;
|
|
119
|
+
if (refusals >= REWORDS) {
|
|
120
|
+
throw new Error(`the image safety filter refused to paint ${place.key} ${refusals + 1} times, also after ${refusals} reword(s) by Spark: `
|
|
121
|
+
+ `reword its scene and looks with paint_words (calm, plain words), which paints it again (${error instanceof Error ? error.message.slice(0, 160) : error})`);
|
|
122
|
+
}
|
|
123
|
+
words = await reword(files.memo, told, layout, brief, before + refusals);
|
|
124
|
+
const fixes = readCorrections(files.place(place.key));
|
|
125
|
+
fixes.words = words;
|
|
126
|
+
writeCorrections(files.place(place.key), fixes);
|
|
127
|
+
console.log(` ${place.key}: the image safety filter refused the painting; Spark reworded it (kept in corrections.json), painting again: ${words.shows ?? place.shows}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
inputs = hash(words);
|
|
131
|
+
files.write(files.place(place.key, 'painting-first.png'), first);
|
|
132
|
+
found = await critique(files.memo, worded(place, words), brief, first);
|
|
133
|
+
files.write(files.place(place.key, 'critique.json'), found);
|
|
134
|
+
}
|
|
135
|
+
// The person's repaints join the critic's. When the painting already has every earlier fix, only the new
|
|
136
|
+
// ones are painted over it, so the rest of the place stays exactly as the person saw it.
|
|
137
|
+
const asked = readCorrections(files.place(place.key)).repaint.map((r) => ({ key: 'person', problem: 'wrong' as const, region: r.region, fix: r.fix }));
|
|
138
|
+
const fixes = { issues: [...found.issues, ...asked] };
|
|
139
|
+
const id = (i: (typeof fixes.issues)[number]) => JSON.stringify([i.region, i.fix]);
|
|
140
|
+
// After a restyle the person's repaints are in the picture already; only the critic's new findings are painted.
|
|
141
|
+
const done = restyled ? asked.map(id) : reuse && fs.existsSync(files.place(place.key, 'painting.png')) ? record.applied ?? (have && !fs.existsSync(kept) ? found.issues.map(id) : null) : null;
|
|
142
|
+
const onTop = done && done.every((d) => fixes.issues.some((i) => id(i) === d));
|
|
143
|
+
const fresh = onTop ? fixes.issues.filter((i) => !done.includes(id(i))) : fixes.issues;
|
|
144
|
+
const from = onTop && !restyled ? fs.readFileSync(files.place(place.key, 'painting.png')) : first;
|
|
145
|
+
// A place built before the steps kept their painting (.ran/) gets it now, so this first repaint carries too.
|
|
146
|
+
const seeded = seedRan(files.place(place.key));
|
|
147
|
+
if (seeded.length) console.log(` ${place.key}: ${seeded.join(', ')} take the painting before this repaint as the one they ran on`);
|
|
148
|
+
let painting: Buffer;
|
|
149
|
+
try {
|
|
150
|
+
painting = await repaint(files.memo, place, from, { issues: fresh });
|
|
151
|
+
} catch (error) {
|
|
152
|
+
// The critic's own fixes are nice to have: a refused repaint leaves the painting as it is. The person's are not.
|
|
153
|
+
if (!refusedByFilter(error) || asked.length) throw error;
|
|
154
|
+
console.log(` ${place.key}: the image safety filter refused the critic's repaint; the painting is kept without it`);
|
|
155
|
+
painting = from;
|
|
156
|
+
}
|
|
157
|
+
// Inspect the FINAL candidate, including region fixes and restyles. Only a passing composition may
|
|
158
|
+
// become the input to masks/derive. Old paid art stays on disk until a replacement passes.
|
|
159
|
+
if (!fromPhoto) {
|
|
160
|
+
const report = files.place(place.key, 'painting-layout.json');
|
|
161
|
+
const previous = fs.existsSync(report) ? files.readJson(report) as { ok?: boolean; inputs?: string } : undefined;
|
|
162
|
+
if (!previous?.ok || previous.inputs !== paintingLayoutHash(place, layout, blockin, painting)) {
|
|
163
|
+
files.write(report, { ok: false, pending: true });
|
|
164
|
+
try {
|
|
165
|
+
const brief = files.readJson(files.place(place.key, 'brief.json')) as Awaited<ReturnType<typeof writeBrief>>;
|
|
166
|
+
const aligned = await alignPainting(files.memo, worded(place, words), layout, blockin, painting, brief, take, fixes.issues.map((i) => i.fix));
|
|
167
|
+
painting = aligned.painting;
|
|
168
|
+
files.write(report, { ok: true, inputs: paintingLayoutHash(place, layout, blockin, painting), attempts: aligned.attempts });
|
|
169
|
+
} catch (error) {
|
|
170
|
+
files.write(report, { ok: false, attempts: error instanceof PaintingLayoutError ? error.attempts : [], error: String(error) });
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
files.write(files.place(place.key, 'painting.png'), painting);
|
|
176
|
+
files.write(kept, { inputs, applied: fixes.issues.map(id) });
|
|
177
|
+
markStyled(files, 'places', place.key, style);
|
|
178
|
+
console.log(`${place.key}: painted, ${fresh.length} fix(es)${onTop ? ' over the last painting' : ''}${fresh.map((i) => ` · ${i.key} ${i.problem}`).join('')}`);
|
|
179
|
+
}));
|
|
180
|
+
console.log(`calls sent: spark ${files.memo.sent.spark}, muse ${files.memo.sent.muse}`);
|
|
181
|
+
},
|
|
182
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// diorama plates <slug> [place...]: plates/<thing>.png for every thing the story lets the hero carry off.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { maskFromPng, maskArea, maskToPng, type Mask } from '../image/mask.ts';
|
|
4
|
+
import { dilate, intersect } from '../image/raster.ts';
|
|
5
|
+
import { defineCommand } from '../registry.ts';
|
|
6
|
+
import { Brief } from '../scene/brief.ts';
|
|
7
|
+
import { Layout } from '../scene/layout.ts';
|
|
8
|
+
import { carryFrom, paintingFor, ran } from '../scene/carry.ts';
|
|
9
|
+
import { changePlate, changeShape, plateFor } from '../scene/plates.ts';
|
|
10
|
+
import { removableThings } from '../story/engine.ts';
|
|
11
|
+
import { Story } from '../story/schema.ts';
|
|
12
|
+
import { WorldFiles } from '../world/files.ts';
|
|
13
|
+
|
|
14
|
+
/** How far from its thing a plate looks at the painting: its grown area, the ring it is matched to, a close view's crop. */
|
|
15
|
+
const PLATE_REACH = 96;
|
|
16
|
+
|
|
17
|
+
defineCommand({
|
|
18
|
+
name: 'plates',
|
|
19
|
+
usage: 'plates <slug> [place...] paint each place without the things the hero can take, and with the things the story changes (needs story.json)',
|
|
20
|
+
async run([slug, ...only]) {
|
|
21
|
+
if (!slug) throw new Error('usage: diorama plates <slug> [place...]');
|
|
22
|
+
const files = new WorldFiles(slug);
|
|
23
|
+
const world = files.world();
|
|
24
|
+
if (!fs.existsSync(files.path('story.json'))) {
|
|
25
|
+
console.log('no story.json: nothing is carried off, no plates needed');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const story = Story.parse(files.readJson(files.path('story.json')));
|
|
29
|
+
const removable = removableThings(story);
|
|
30
|
+
const places = world.places.filter((p) => only.length === 0 || only.includes(p.key));
|
|
31
|
+
await Promise.all(places.map(async (place) => {
|
|
32
|
+
const at = (...parts: string[]) => files.place(place.key, ...parts);
|
|
33
|
+
const painted = fs.readFileSync(at('painting.png'));
|
|
34
|
+
// After a small repaint a plate the change does not reach is the one made last time (the memo keeps it).
|
|
35
|
+
const carry = await carryFrom(at(), 'plates', painted);
|
|
36
|
+
fs.rmSync(at('plates'), { recursive: true, force: true });
|
|
37
|
+
fs.rmSync(at('changes'), { recursive: true, force: true });
|
|
38
|
+
const keys = removable.filter((r) => r.place === place.key).map((r) => r.thing);
|
|
39
|
+
const changes = story.changes.map((c, i) => ({ ...c, i })).filter((c) => c.place === place.key);
|
|
40
|
+
if (!keys.length && !changes.length) return ran(at(), 'plates', carry?.old ?? painted);
|
|
41
|
+
const notes = (files.readJson(at('masks.json')) as { things: Record<string, { phrase: string }> }).things;
|
|
42
|
+
// A plate is painted around its thing and matched to a ring around it.
|
|
43
|
+
const around = (mask: Mask) => paintingFor(carry, painted, mask, PLATE_REACH);
|
|
44
|
+
// changes/<i>.png: the place once story.changes[i] holds, drawn by the runtime over the painting.
|
|
45
|
+
// Every plate is its own Muse edit: they are painted side by side.
|
|
46
|
+
const changed = Promise.all(changes.map(async (c) => {
|
|
47
|
+
if (!notes[c.thing]) {
|
|
48
|
+
console.log(` ${place.key}: ${c.thing} has no mask, no change plate`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const mask = await maskFromPng(fs.readFileSync(at('masks', `${c.thing}.png`)));
|
|
52
|
+
// What the hero can take keeps its own pixels: Muse redraws all it sees, and a ticket it moved would lie
|
|
53
|
+
// beside the one the player clicks (its outline off), and stay when the hero takes it. Props may be
|
|
54
|
+
// overgrown: a basket that grew a rope may reach over the door beside it.
|
|
55
|
+
const others = await Promise.all(keys.filter((k) => k !== c.thing && fs.existsSync(at('masks', `${k}.png`)))
|
|
56
|
+
.map(async (k) => maskFromPng(fs.readFileSync(at('masks', `${k}.png`)))));
|
|
57
|
+
const plate = await changePlate(files.memo, place, { key: c.thing, phrase: notes[c.thing].phrase, mask }, around(mask), c.looks, others);
|
|
58
|
+
files.write(at('changes', `${c.i}.png`), plate);
|
|
59
|
+
// changes/<i>-shape.png: the thing as the plate draws it, for its hover outline while the change holds.
|
|
60
|
+
const shape = await changeShape(files.memo, place, { key: c.thing, mask }, plate, c.looks).catch((error) => {
|
|
61
|
+
console.log(` ${place.key}: ${c.thing}: no shape for its change (${error instanceof Error ? error.message : error})`);
|
|
62
|
+
return undefined;
|
|
63
|
+
});
|
|
64
|
+
if (shape) files.write(at('changes', `${c.i}-shape.png`), await maskToPng(shape));
|
|
65
|
+
else console.log(` ${place.key}: ${c.thing}: SAM found no clear shape in its change plate; the outline keeps the painted one`);
|
|
66
|
+
console.log(`${place.key}: ${c.thing} once ${c.when.join(', ')}: ${c.looks}`);
|
|
67
|
+
}));
|
|
68
|
+
if (!keys.length) {
|
|
69
|
+
await changed;
|
|
70
|
+
return ran(at(), 'plates', carry?.old ?? painted);
|
|
71
|
+
}
|
|
72
|
+
const layout = Layout.parse(files.readJson(at('layout.json')));
|
|
73
|
+
const brief = Brief.parse(files.readJson(at('brief.json')));
|
|
74
|
+
const floors = await Promise.all(layout.floors.map(async (f) => ({ f, mask: await maskFromPng(fs.readFileSync(at('masks', 'floors', `${f.key}.png`))) })));
|
|
75
|
+
const made = await Promise.all(keys.map(async (key) => {
|
|
76
|
+
if (!notes[key]) {
|
|
77
|
+
console.log(` ${place.key}: ${key} has no mask, no plate`);
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
const mask = await maskFromPng(fs.readFileSync(at('masks', `${key}.png`)));
|
|
81
|
+
// The floor around it says what to paint where it stood.
|
|
82
|
+
const ring = dilate(mask, 16);
|
|
83
|
+
const under = floors.map(({ f, mask: m }) => ({ f, px: maskArea(intersect(m, ring)) })).sort((a, b) => b.px - a.px)[0];
|
|
84
|
+
const ground = under?.px ? (brief.looks[under.f.key] ?? under.f.is) : 'the ground around it';
|
|
85
|
+
const plate = await plateFor(files.memo, place, { key, phrase: notes[key].phrase, mask }, around(mask), ground);
|
|
86
|
+
files.write(at('plates', `${key}.png`), plate.png);
|
|
87
|
+
console.log(`${place.key}: plate for ${key} (seam ${plate.ringError.toFixed(1)}, stain ${Math.round(plate.stain * 100)}%)${plate.stillThere ? ' (SAM still finds it: check the plate)' : ''}`);
|
|
88
|
+
return [[key, { stillThere: plate.stillThere, iou: Math.round(plate.iou * 100) / 100, ringError: Math.round(plate.ringError * 10) / 10, stain: Math.round(plate.stain * 100) / 100 }] as const];
|
|
89
|
+
}));
|
|
90
|
+
await changed;
|
|
91
|
+
const report = Object.fromEntries(made.flat());
|
|
92
|
+
files.write(at('plates.json'), report);
|
|
93
|
+
ran(at(), 'plates', carry?.old ?? painted);
|
|
94
|
+
}));
|
|
95
|
+
console.log(`calls sent: muse ${files.memo.sent.muse}, sam ${files.memo.sent.sam}`);
|
|
96
|
+
},
|
|
97
|
+
});
|