@gtanczyk/diorama 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +157 -2
- package/app/bin/diorama.mjs +13 -0
- package/app/src/actions/budget.ts +152 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +248 -0
- package/app/src/actions/depth.ts +95 -0
- package/app/src/actions/extend.ts +36 -0
- package/app/src/actions/faces.ts +160 -0
- package/app/src/actions/feedback.ts +87 -0
- package/app/src/actions/fix.ts +382 -0
- package/app/src/actions/history.ts +93 -0
- package/app/src/actions/idea-preview.ts +65 -0
- package/app/src/actions/idea-review.ts +102 -0
- package/app/src/actions/idea-sketch.ts +172 -0
- package/app/src/actions/index.ts +31 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/models.ts +64 -0
- package/app/src/actions/motion.ts +119 -0
- package/app/src/actions/pictures.ts +49 -0
- package/app/src/actions/reactions.ts +36 -0
- package/app/src/actions/registry.ts +101 -0
- package/app/src/actions/share.ts +32 -0
- package/app/src/actions/sound.ts +73 -0
- package/app/src/actions/start.ts +154 -0
- package/app/src/actions/step-work.ts +150 -0
- package/app/src/actions/story.ts +101 -0
- package/app/src/actions/studio.ts +133 -0
- package/app/src/actions/style.ts +72 -0
- package/app/src/actions/video.ts +46 -0
- package/app/src/actions/view.ts +109 -0
- package/app/src/actions/weather.ts +38 -0
- package/app/src/actions/world.ts +289 -0
- package/app/src/commands/action.ts +39 -0
- package/app/src/commands/backdrop.ts +188 -0
- package/app/src/commands/browsers.ts +21 -0
- package/app/src/commands/build.ts +503 -0
- package/app/src/commands/cast.ts +142 -0
- package/app/src/commands/clip.ts +431 -0
- package/app/src/commands/derive.ts +167 -0
- package/app/src/commands/doctor.ts +74 -0
- package/app/src/commands/explore.ts +123 -0
- package/app/src/commands/export.ts +142 -0
- package/app/src/commands/extend.ts +75 -0
- package/app/src/commands/faces.ts +80 -0
- package/app/src/commands/icons.ts +69 -0
- package/app/src/commands/index.ts +36 -0
- package/app/src/commands/layout.ts +31 -0
- package/app/src/commands/masks.ts +117 -0
- package/app/src/commands/mcp.ts +52 -0
- package/app/src/commands/model.ts +284 -0
- package/app/src/commands/paint.ts +182 -0
- package/app/src/commands/plates.ts +97 -0
- package/app/src/commands/play.ts +192 -0
- package/app/src/commands/playthrough.ts +111 -0
- package/app/src/commands/publish.ts +78 -0
- package/app/src/commands/reactions.ts +40 -0
- package/app/src/commands/reel.ts +46 -0
- package/app/src/commands/setup.ts +85 -0
- package/app/src/commands/sound.ts +90 -0
- package/app/src/commands/sprites.ts +232 -0
- package/app/src/commands/story.ts +37 -0
- package/app/src/commands/studio.ts +65 -0
- package/app/src/commands/style.ts +87 -0
- package/app/src/commands/verify.ts +45 -0
- package/app/src/commands/voice-retry.ts +14 -0
- package/app/src/commands/voices.ts +145 -0
- package/app/src/commands/weather.ts +52 -0
- package/app/src/commands/world.ts +104 -0
- package/app/src/film/clip.ts +386 -0
- package/app/src/film/reel.ts +126 -0
- package/app/src/film/screencast.ts +123 -0
- package/app/src/image/mask.ts +29 -0
- package/app/src/image/overlay.ts +38 -0
- package/app/src/image/raster.ts +224 -0
- package/app/src/main.ts +28 -0
- package/app/src/meta/choice.ts +27 -0
- package/app/src/meta/http.ts +99 -0
- package/app/src/meta/memo.ts +91 -0
- package/app/src/meta/muse.ts +70 -0
- package/app/src/meta/openrouter.ts +93 -0
- package/app/src/meta/prices.ts +25 -0
- package/app/src/meta/sam.ts +83 -0
- package/app/src/meta/schema.ts +63 -0
- package/app/src/meta/spark.ts +220 -0
- package/app/src/meta/step.ts +14 -0
- package/app/src/meta/voice-file.ts +78 -0
- package/app/src/meta/voice.ts +103 -0
- package/app/src/model/animation.ts +38 -0
- package/app/src/model/blender/relax.py +284 -0
- package/app/src/model/blender.ts +115 -0
- package/app/src/model/build.ts +50 -0
- package/app/src/model/face.ts +21 -0
- package/app/src/model/fit.ts +181 -0
- package/app/src/model/glb.ts +241 -0
- package/app/src/model/plan.ts +151 -0
- package/app/src/model/quality.ts +48 -0
- package/app/src/model/space.ts +168 -0
- package/app/src/model/sprites.ts +293 -0
- package/app/src/model/views.ts +163 -0
- package/app/src/play/creature.ts +43 -0
- package/app/src/play/depth-occlusion.ts +150 -0
- package/app/src/play/exit-outline.ts +48 -0
- package/app/src/play/face3d.ts +34 -0
- package/app/src/play/focus.ts +78 -0
- package/app/src/play/geometry.ts +272 -0
- package/app/src/play/gestures.ts +23 -0
- package/app/src/play/glow.ts +187 -0
- package/app/src/play/lipsync.ts +69 -0
- package/app/src/play/living.ts +83 -0
- package/app/src/play/outline-path.ts +130 -0
- package/app/src/play/parallax.ts +200 -0
- package/app/src/play/path.ts +262 -0
- package/app/src/play/preview.ts +71 -0
- package/app/src/play/reactions.ts +92 -0
- package/app/src/play/rig.ts +172 -0
- package/app/src/play/shadow.ts +253 -0
- package/app/src/play/sound.ts +51 -0
- package/app/src/play/weather.ts +58 -0
- package/app/src/qa/explore.ts +136 -0
- package/app/src/qa/probe.ts +139 -0
- package/app/src/qa/route.ts +118 -0
- package/app/src/registry.ts +23 -0
- package/app/src/scene/animation-source.ts +36 -0
- package/app/src/scene/assets.ts +37 -0
- package/app/src/scene/autofix.ts +133 -0
- package/app/src/scene/backdrop.ts +326 -0
- package/app/src/scene/blockin.ts +189 -0
- package/app/src/scene/brief.ts +74 -0
- package/app/src/scene/carry.ts +177 -0
- package/app/src/scene/cast-fixes.ts +31 -0
- package/app/src/scene/cast.ts +192 -0
- package/app/src/scene/corrections.ts +164 -0
- package/app/src/scene/critic.ts +169 -0
- package/app/src/scene/depthmap.ts +149 -0
- package/app/src/scene/derive.ts +475 -0
- package/app/src/scene/diagnose.ts +132 -0
- package/app/src/scene/face-calibration.ts +65 -0
- package/app/src/scene/face-registration.ts +21 -0
- package/app/src/scene/face-work.ts +71 -0
- package/app/src/scene/faces-data.ts +48 -0
- package/app/src/scene/faces-settings.ts +20 -0
- package/app/src/scene/faces.ts +435 -0
- package/app/src/scene/glows.ts +153 -0
- package/app/src/scene/held.ts +55 -0
- package/app/src/scene/icons.ts +185 -0
- package/app/src/scene/layers.ts +822 -0
- package/app/src/scene/layout.ts +216 -0
- package/app/src/scene/locate.ts +118 -0
- package/app/src/scene/maskcheck.ts +43 -0
- package/app/src/scene/masks.ts +392 -0
- package/app/src/scene/occluders.ts +123 -0
- package/app/src/scene/occlusion-check.ts +138 -0
- package/app/src/scene/paint-layout.ts +135 -0
- package/app/src/scene/paint.ts +81 -0
- package/app/src/scene/photo.ts +123 -0
- package/app/src/scene/plates.ts +256 -0
- package/app/src/scene/playbook.ts +121 -0
- package/app/src/scene/preview.ts +23 -0
- package/app/src/scene/reactions.ts +118 -0
- package/app/src/scene/restyle.ts +53 -0
- package/app/src/scene/rig-check.ts +68 -0
- package/app/src/scene/rig.ts +375 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +207 -0
- package/app/src/scene/stairs.ts +183 -0
- package/app/src/scene/status.ts +32 -0
- package/app/src/scene/verify.ts +156 -0
- package/app/src/scene/walk.ts +326 -0
- package/app/src/scene/warp.ts +417 -0
- package/app/src/scene/weather.ts +118 -0
- package/app/src/scene/words.ts +65 -0
- package/app/src/secrets.ts +219 -0
- package/app/src/setup/checks.ts +110 -0
- package/app/src/sound/cost.ts +74 -0
- package/app/src/sound/data.ts +89 -0
- package/app/src/sound/fixes.ts +23 -0
- package/app/src/sound/issues.ts +21 -0
- package/app/src/sound/language.ts +11 -0
- package/app/src/sound/lipsync-make.ts +98 -0
- package/app/src/sound/lipsync.ts +24 -0
- package/app/src/sound/make.ts +229 -0
- package/app/src/sound/musicgen.py +20 -0
- package/app/src/sound/pcm.ts +280 -0
- package/app/src/sound/plan.ts +154 -0
- package/app/src/sound/retry.ts +43 -0
- package/app/src/sound/settings.ts +28 -0
- package/app/src/sound/sources.ts +81 -0
- package/app/src/sound/spoken.ts +70 -0
- package/app/src/sound/voices.ts +579 -0
- package/app/src/speech/transcribe.ts +38 -0
- package/app/src/speech/types.ts +18 -0
- package/app/src/story/check.ts +230 -0
- package/app/src/story/design.ts +108 -0
- package/app/src/story/engine.ts +233 -0
- package/app/src/story/fixes.ts +54 -0
- package/app/src/story/npc-guidance.ts +7 -0
- package/app/src/story/phrases.ts +4 -0
- package/app/src/story/schema.ts +92 -0
- package/app/src/studio/agent.ts +554 -0
- package/app/src/studio/discovery.ts +28 -0
- package/app/src/studio/events.ts +48 -0
- package/app/src/studio/history.ts +124 -0
- package/app/src/studio/jobs.ts +291 -0
- package/app/src/studio/locks.ts +33 -0
- package/app/src/studio/mcp.ts +86 -0
- package/app/src/studio/operation.ts +18 -0
- package/app/src/studio/repair.ts +31 -0
- package/app/src/studio/server.ts +421 -0
- package/app/src/studio/trouble.ts +48 -0
- package/app/src/world/design.ts +72 -0
- package/app/src/world/drawings.ts +198 -0
- package/app/src/world/extend-stage.ts +109 -0
- package/app/src/world/extend.ts +123 -0
- package/app/src/world/extension-progress.ts +10 -0
- package/app/src/world/files.ts +62 -0
- package/app/src/world/idea-review.ts +32 -0
- package/app/src/world/photo-limits.ts +2 -0
- package/app/src/world/pool.ts +41 -0
- package/app/src/world/schedule.ts +64 -0
- package/app/src/world/schema.ts +74 -0
- package/app/src/world/sketch.ts +81 -0
- package/app/src/world/style-plan.ts +90 -0
- package/app/src/world/style-reference.ts +16 -0
- package/app/src/world/styled.ts +51 -0
- package/app/src/world/told.ts +105 -0
- package/app/src/world/video-limits.ts +3 -0
- package/app/src/world/video.ts +98 -0
- package/app/studio/activity.tsx +124 -0
- package/app/studio/agent.tsx +384 -0
- package/app/studio/api.ts +111 -0
- package/app/studio/atelier.tsx +234 -0
- package/app/studio/audio-missing.tsx +25 -0
- package/app/studio/audio-setup.tsx +33 -0
- package/app/studio/blender-setup.tsx +30 -0
- package/app/studio/build.tsx +677 -0
- package/app/studio/camera.tsx +59 -0
- package/app/studio/cast-work.tsx +207 -0
- package/app/studio/cast.tsx +178 -0
- package/app/studio/concept-edit.tsx +47 -0
- package/app/studio/creator-idea.tsx +46 -0
- package/app/studio/custom-look.tsx +36 -0
- package/app/studio/faces-setup.tsx +28 -0
- package/app/studio/favicon.svg +11 -0
- package/app/studio/figure.tsx +314 -0
- package/app/studio/fonts/OFL.txt +93 -0
- package/app/studio/fonts/fonts.css +7 -0
- package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
- package/app/studio/icons.tsx +42 -0
- package/app/studio/idea-card.tsx +42 -0
- package/app/studio/idea-review.tsx +181 -0
- package/app/studio/idea-storyboard.tsx +134 -0
- package/app/studio/index.html +15 -0
- package/app/studio/job-words.ts +33 -0
- package/app/studio/lightbox.tsx +49 -0
- package/app/studio/main.tsx +1328 -0
- package/app/studio/nav.ts +14 -0
- package/app/studio/place.tsx +264 -0
- package/app/studio/review-sketch.tsx +47 -0
- package/app/studio/say-it.tsx +49 -0
- package/app/studio/step-work.tsx +134 -0
- package/app/studio/story.tsx +394 -0
- package/app/studio/style-menu.tsx +81 -0
- package/app/studio/style.css +1390 -0
- package/app/studio/sure.tsx +54 -0
- package/app/studio/tip.tsx +74 -0
- package/app/studio/tsconfig.json +5 -0
- package/app/studio/video.tsx +73 -0
- package/app/studio/world.tsx +188 -0
- package/app/tsconfig.json +13 -0
- package/app/web/audio.ts +315 -0
- package/app/web/face.ts +131 -0
- package/app/web/face3d.ts +35 -0
- package/app/web/glows.ts +387 -0
- package/app/web/hero3d.ts +728 -0
- package/app/web/index.html +118 -0
- package/app/web/living.ts +404 -0
- package/app/web/main.ts +3751 -0
- package/app/web/pad.ts +174 -0
- package/app/web/reactions.ts +79 -0
- package/app/web/sheet.ts +36 -0
- package/app/web/synth.ts +140 -0
- package/app/web/tsconfig.json +5 -0
- package/app/web/ui.ts +263 -0
- package/app/web/weather.ts +306 -0
- package/app/web/words.ts +12 -0
- package/docs/characters.md +58 -0
- package/docs/npc-motion.md +69 -0
- package/docs/scenes.md +75 -0
- package/docs/setup.md +60 -0
- package/docs/sound.md +23 -0
- package/docs/studio.md +62 -0
- package/package.json +70 -4
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { CalibrationTarget, inspectFaceCalibration } from '../scene/face-calibration.ts';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { modelFaceStatus } from '../model/face.ts';
|
|
6
|
+
import { faceCharacters } from '../commands/faces.ts';
|
|
7
|
+
import { registeredFace } from '../scene/faces-data.ts';
|
|
8
|
+
import { WorldFiles } from '../world/files.ts';
|
|
9
|
+
import { Slug } from '../world/schema.ts';
|
|
10
|
+
import { lipSyncData } from '../sound/lipsync.ts';
|
|
11
|
+
import { agentBlocked } from '../studio/locks.ts';
|
|
12
|
+
import { ActionError, defineAction } from './registry.ts';
|
|
13
|
+
import { placeTrouble } from '../studio/trouble.ts';
|
|
14
|
+
import { facesState } from '../scene/faces-settings.ts';
|
|
15
|
+
import { FaceWorkId, FaceRevision, MouthName, LipBox, readFaceWork, faceWorkList, faceWorkAtlas, faceWorkReference, workRevision } from '../scene/face-work.ts';
|
|
16
|
+
import { MOUTHS } from '../play/lipsync.ts';
|
|
17
|
+
|
|
18
|
+
export function faceStatus(files: WorldFiles, key: string) {
|
|
19
|
+
const face = registeredFace(files, key);
|
|
20
|
+
const attempt = files.path('cast', key, 'face-attempt.json');
|
|
21
|
+
const spritesFile = files.path('cast',key,'sprites','sprites.json');
|
|
22
|
+
const spritePlan = fs.existsSync(spritesFile) ? files.readJson(spritesFile) as {cols:number;rows:number;talk?:Record<string,{sheet:string;gestures?:string[]}>} : undefined;
|
|
23
|
+
const spritePoses = {registered:0,skipped:0,pending:0};
|
|
24
|
+
for (const view of Object.values(spritePlan?.talk ?? {})) {
|
|
25
|
+
const count=view.gestures?.length ?? spritePlan!.cols*spritePlan!.rows, poses=face?.info.sheets[view.sheet];
|
|
26
|
+
for(let i=0;i<count;i++) {
|
|
27
|
+
const p=poses?.[i];
|
|
28
|
+
if(p===null) spritePoses.skipped++;
|
|
29
|
+
else if(p?.lips && face?.info.frames?.[p.atlas ?? 'default'])spritePoses.registered++;
|
|
30
|
+
else spritePoses.pending++;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const modelViews = Object.entries(face?.info.sheets ?? {}).filter(([name,poses])=>name.startsWith('model:') && poses.some(p=>p?.lips && face?.info.frames?.[p.atlas ?? 'default'])).map(([name])=>name.slice(6));
|
|
34
|
+
return { model3d: modelFaceStatus(files.path('cast', key, 'model'),modelViews), ready: !!face, spritePoses, image: !!face?.info.imagePose, rig: !!face?.info.rigPose || Object.keys(face?.info.sheets ?? {}).some(s => s.startsWith('rig:')),
|
|
35
|
+
spriteViews: Object.keys(face?.info.sheets ?? {}).filter(s => !s.startsWith('rig:') && !s.startsWith('model:')).length,
|
|
36
|
+
repairWork: faceWorkList(files,key),
|
|
37
|
+
attempt: fs.existsSync(attempt) ? files.readJson(attempt) : null };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
defineAction({
|
|
41
|
+
name: 'faces_status', reads: true,
|
|
42
|
+
description: 'Speaking-face coverage per character: image, 2D rig, registered talk sprite views, audio timelines and preparation failures. Includes native morphs or calibrated painted model surfaces. No model calls.',
|
|
43
|
+
input: z.object({ slug: Slug, key: Slug.optional() }),
|
|
44
|
+
async run({ slug, key }, ctx) {
|
|
45
|
+
const files = new WorldFiles(slug, ctx.root), sync = lipSyncData(files);
|
|
46
|
+
const voicesFile = files.path('voices.json');
|
|
47
|
+
const lines = fs.existsSync(voicesFile) ? (files.readJson(voicesFile) as { lines: Array<{ speaker: string; file: string }> }).lines : [];
|
|
48
|
+
return faceCharacters(files).filter(k => !key || k === key).map(key => ({ key, ...faceStatus(files, key), voicedLines: lines.filter(l => l.speaker === key).length,
|
|
49
|
+
syncedLines: lines.filter(l => l.speaker === key && sync[l.file]).length }));
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
defineAction({
|
|
54
|
+
name:'faces_inspect', reads:true,
|
|
55
|
+
description:'Inspect saved face work before a targeted repair. With id and mouth, returns the exact full-head cell, identity reference and normalized lip box. X is neutral, A MBP, B teeth, C EH, D AH, E OH, F OO, G FV, H L. Use the returned revision for face_frame_repair or face_lips_set. No model calls; stale source work is rejected.',
|
|
56
|
+
input:z.object({slug:Slug,key:Slug,id:FaceWorkId.optional(),mouth:MouthName.optional()}),
|
|
57
|
+
async run({slug,key,id,mouth},ctx) {
|
|
58
|
+
const files=new WorldFiles(slug,ctx.root);
|
|
59
|
+
if(!faceCharacters(files).includes(key))throw new ActionError(404,`No character ${key}`);
|
|
60
|
+
if(!id)return {work:faceWorkList(files,key)};
|
|
61
|
+
const work=readFaceWork(files,key,id), atlas=faceWorkAtlas(files,key,work);
|
|
62
|
+
let image: Buffer=atlas;
|
|
63
|
+
if(mouth) {
|
|
64
|
+
const index=MOUTHS.indexOf(mouth),meta=await sharp(atlas).metadata(),w=meta.width!,h=meta.height!;
|
|
65
|
+
const left=Math.floor(index%3*w/3),top=Math.floor(Math.floor(index/3)*h/3);
|
|
66
|
+
image=await sharp(atlas).extract({left,top,width:Math.floor((index%3+1)*w/3)-left,height:Math.floor((Math.floor(index/3)+1)*h/3)-top}).png().toBuffer();
|
|
67
|
+
}
|
|
68
|
+
const meta=await sharp(image).metadata();
|
|
69
|
+
return {...work,revision:workRevision(work),mouth,width:meta.width,height:meta.height,box:mouth?work.lips[mouth]:undefined,
|
|
70
|
+
coordinates:mouth?'normalized [x,y,width,height] relative to the FIRST image, one full-head cell':'Select a mouth before calibrating; this image is the complete atlas',
|
|
71
|
+
images:[image,faceWorkReference(files,key,id)].map(data=>({mimeType:'image/png',data:data.toString('base64')}))};
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Speaking faces are the last polish, never ahead of a playable game: while a place is unpainted, being built or
|
|
78
|
+
* left unplayable, the agent's face jobs wait (a face job queued first would hold the place repairs behind it,
|
|
79
|
+
* and a live demo would hang on a cat's lips). The person can still ask for one from its card.
|
|
80
|
+
*/
|
|
81
|
+
export function placesFirst(files: WorldFiles, slug: string, ctx: { by: string; jobs: { list(world?: string): Array<{ state: string; args: string[] }> } }): string | null {
|
|
82
|
+
if (ctx.by !== 'agent') return null;
|
|
83
|
+
if (!facesState().enabled) return 'Talking mouths are off in Setup: characters speak with voice and gestures. Do not prepare or repair faces; if the person wants moving lips, they turn it on in Setup.';
|
|
84
|
+
const world = fs.existsSync(files.path('world.json')) ? files.readJson(files.path('world.json')) as { places?: Array<{ key: string; title?: string }> } : {};
|
|
85
|
+
const waiting = (world.places ?? []).filter((p) => !fs.existsSync(files.path('places', p.key, 'painting.png')) || placeTrouble(files.dir, p.key)).map((p) => p.title ?? p.key);
|
|
86
|
+
const building = ctx.jobs.list(slug).some((j) => (j.state === 'queued' || j.state === 'running') && j.args[0] !== 'faces');
|
|
87
|
+
if (!waiting.length && !building) return null;
|
|
88
|
+
return `Places first: ${waiting.length ? `${waiting.join(', ')} ${waiting.length > 1 ? 'are' : 'is'} not playable yet` : 'a build or repair is still running'}. Speaking faces wait until every place plays (characters talk with gestures meanwhile). Repair the places (place_status, place_fix) and come back to faces after.`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const RepairTarget={slug:Slug,key:Slug,id:FaceWorkId,revision:FaceRevision,mouth:MouthName};
|
|
92
|
+
for(const kind of ['repair','calibrate'] as const)defineAction({
|
|
93
|
+
name:kind==='repair'?'face_frame_repair':'face_lips_set', job:true,
|
|
94
|
+
description:kind==='repair'
|
|
95
|
+
? 'Repair ONE mouth frame from faces_inspect, with concrete visual guidance. One image edit then full atlas validation; other cells remain unchanged. Resumes face preparation using saved results. Use the exact inspected id/revision; do not repeatedly retry unchanged failures.'
|
|
96
|
+
: 'Calibrate lips in an already accepted atlas using the full-head image from faces_inspect. Box is normalized [x,y,width,height] of the actual lip corners/opening, excluding beard/moustache. Inspect first; never guess coordinates or use this to bypass rejected articulation. Resumes preparation without repainting accepted mouths.',
|
|
97
|
+
input:kind==='repair'
|
|
98
|
+
? z.object({...RepairTarget,hint:z.string().trim().min(1).max(1000),box:LipBox.optional()})
|
|
99
|
+
: z.object({...RepairTarget,hint:z.string().optional(),box:LipBox}),
|
|
100
|
+
async run({slug,key,id,revision,mouth,hint,box},ctx) {
|
|
101
|
+
const files=new WorldFiles(slug,ctx.root),blocked=agentBlocked(files.dir,ctx.by);
|
|
102
|
+
if(blocked)throw new ActionError(423,blocked);
|
|
103
|
+
const later=placesFirst(files,slug,ctx);if(later)throw new ActionError(409,later);
|
|
104
|
+
if(!faceCharacters(files).includes(key))throw new ActionError(404,`No character ${key}`);
|
|
105
|
+
const work=readFaceWork(files,key,id,revision);
|
|
106
|
+
if(kind==='calibrate'&&!work.accepted)throw new ActionError(409,'Repair articulation before calibrating the lips');
|
|
107
|
+
return ctx.jobs.start(slug,['faces',slug,key,'--image-only','--take',String(work.take),kind==='repair'?'--repair-frame':'--calibrate-frame',id,'--revision',revision,'--mouth',mouth,...(kind==='repair'?['--hint',hint!]:['--box',JSON.stringify(box)])],`${kind==='repair'?'repair mouth':'calibrate lips'} ${key} ${mouth}`,ctx.by);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
defineAction({
|
|
112
|
+
name: 'faces_prepare', job: true,
|
|
113
|
+
description: 'Prepare speaking faces automatically for a character or the cast: locate facial features, paint and validate reusable mouth shapes for each visible view, register image/rig/talk poses, analyse final recordings. A rejected atlas gets one repair round: up to three individual mouth edits, or one full retry with validation feedback. Costs up to four image calls per new/changed view plus vision calls. Unchanged assets and audio reuse their cache. Existing faces remain active on failure. Wait for the job, check faces_status, then play_reload.',
|
|
114
|
+
input: z.object({ slug: Slug, key: Slug.optional(), audioOnly: z.boolean().optional(), anotherTake: z.boolean().optional() }),
|
|
115
|
+
async run({ slug, key, audioOnly, anotherTake }, ctx) {
|
|
116
|
+
const files = new WorldFiles(slug, ctx.root);
|
|
117
|
+
const blocked = agentBlocked(files.dir, ctx.by);
|
|
118
|
+
if (blocked) throw new ActionError(423, blocked);
|
|
119
|
+
const later=placesFirst(files,slug,ctx);if(later)throw new ActionError(409,later);
|
|
120
|
+
const keys = faceCharacters(files);
|
|
121
|
+
if (key && !keys.includes(key)) throw new ActionError(404, `No character ${key}`);
|
|
122
|
+
if (anotherTake && !key) throw new ActionError(400, 'Choose one character for another face take');
|
|
123
|
+
let take = 0;
|
|
124
|
+
if (key) {
|
|
125
|
+
const previous = files.path('cast', key, 'face-attempt.json');
|
|
126
|
+
if (fs.existsSync(previous)) take = Number((files.readJson(previous) as { take?: number }).take ?? 0);
|
|
127
|
+
if (anotherTake) take++;
|
|
128
|
+
}
|
|
129
|
+
return ctx.jobs.start(slug, ['faces', slug, '--image-only', ...(key ? [key] : []), ...(audioOnly ? ['--audio-only'] : []), '--take', String(take), ...(anotherTake ? ['--force'] : [])], `prepare speaking faces${key ? ` ${key}` : ''}`, ctx.by);
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
defineAction({
|
|
135
|
+
name:'face_calibration_inspect',reads:true,
|
|
136
|
+
description:'Inspect pending source-pose or final-patch lip calibration after an atlas passed. Without target lists pending targets. With target returns the exact close-up and source-bound revision/imageHash for face_calibration_set. No model calls.',
|
|
137
|
+
input:z.object({slug:Slug,key:Slug,target:CalibrationTarget.optional()}),
|
|
138
|
+
async run({slug,key,target},ctx) {
|
|
139
|
+
const files=new WorldFiles(slug,ctx.root);
|
|
140
|
+
if(!faceCharacters(files).includes(key))throw new ActionError(404,`No character ${key}`);
|
|
141
|
+
const {image,...result}=await inspectFaceCalibration(files,key,target);
|
|
142
|
+
return {...result,coordinates:'normalized [x,y,width,height] relative to this one close-up; tight lip corners/opening, excluding facial hair',...(image&&{images:[{mimeType:'image/png',data:image.toString('base64')}]})};
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
defineAction({
|
|
146
|
+
name:'face_calibration_set',job:true,
|
|
147
|
+
description:'Save inspected actual lip corners for a source pose or final cropped mouth patch and resume preparation. Requires exact target/revision/imageHash from face_calibration_inspect. Box normalized to its close-up; open means a visible mouth cavity. Never guess or use this to bypass rejected artwork. No new image generation for completed atlas views.',
|
|
148
|
+
input:z.object({slug:Slug,key:Slug,target:CalibrationTarget,revision:FaceRevision,imageHash:FaceRevision,box:LipBox,open:z.boolean()}),
|
|
149
|
+
async run({slug,key,target,revision,imageHash,box,open},ctx) {
|
|
150
|
+
const files=new WorldFiles(slug,ctx.root),blocked=agentBlocked(files.dir,ctx.by);
|
|
151
|
+
if(blocked)throw new ActionError(423,blocked);
|
|
152
|
+
const later=placesFirst(files,slug,ctx);if(later)throw new ActionError(409,later);
|
|
153
|
+
if(!faceCharacters(files).includes(key))throw new ActionError(404,`No character ${key}`);
|
|
154
|
+
const current=await inspectFaceCalibration(files,key,target);
|
|
155
|
+
if(current.revision!==revision||current.imageHash!==imageHash)throw new ActionError(409,'Face changed; inspect it again');
|
|
156
|
+
const attempt=files.path('cast',key,'face-attempt.json');
|
|
157
|
+
const take=fs.existsSync(attempt)?Number((files.readJson(attempt) as {take?:number}).take??0):0;
|
|
158
|
+
return ctx.jobs.start(slug,['faces',slug,key,'--image-only','--take',String(take),'--calibration',JSON.stringify(target),'--revision',revision,'--image-hash',imageHash,'--box',JSON.stringify(box),'--open',String(open)],`calibrate speaking face ${key}`,ctx.by);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Notes: the person points at a place and says what is wrong; the note waits until the agent (or the
|
|
2
|
+
// person) works it off and says what was done. Kept in worlds/<slug>/studio/feedback.json.
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { Slug } from '../world/schema.ts';
|
|
8
|
+
import { ActionError, defineAction, type ActionContext } from './registry.ts';
|
|
9
|
+
|
|
10
|
+
export type Note = { id: string; at: string; by: string; place?: string; point?: [number, number]; text: string; state: 'open' | 'done'; reply?: string; doneAt?: string };
|
|
11
|
+
|
|
12
|
+
const file = (root: string, slug: string) => path.join(root, slug, 'studio', 'feedback.json');
|
|
13
|
+
|
|
14
|
+
export function readNotes(root: string, slug: string): Note[] {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(fs.readFileSync(file(root, slug), 'utf8')) as Note[];
|
|
17
|
+
} catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeNotes(ctx: ActionContext, slug: string, notes: Note[]) {
|
|
23
|
+
fs.mkdirSync(path.dirname(file(ctx.root, slug)), { recursive: true });
|
|
24
|
+
fs.writeFileSync(file(ctx.root, slug), `${JSON.stringify(notes, null, 2)}\n`);
|
|
25
|
+
ctx.events.publish('feedback.changed', { world: slug, open: notes.filter((n) => n.state === 'open').length });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
defineAction({
|
|
29
|
+
name: 'feedback_add',
|
|
30
|
+
description: 'Leave a note on a world or on a spot of a place (painting pixels) for later: what looks or plays wrong. The person\'s own notes come from the Studio; '
|
|
31
|
+
+ 'as the agent, add one only when the person asks you to note something, never as a way to report what you could not fix (say that in your answer).',
|
|
32
|
+
input: z.object({ slug: Slug, text: z.string().min(2).max(2000), place: z.string().min(1).optional(), point: z.tuple([z.number(), z.number()]).optional() }),
|
|
33
|
+
async run({ slug, text, place, point }, ctx) {
|
|
34
|
+
if (!fs.existsSync(path.join(ctx.root, slug))) throw new ActionError(404, `no world ${slug} yet`);
|
|
35
|
+
const note: Note = { id: randomBytes(3).toString('hex'), at: new Date().toISOString(), by: ctx.by, ...(place && { place }), ...(point && { point }), text, state: 'open' };
|
|
36
|
+
writeNotes(ctx, slug, [...readNotes(ctx.root, slug), note]);
|
|
37
|
+
return note;
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
defineAction({
|
|
42
|
+
name: 'feedback_pending',
|
|
43
|
+
reads: true,
|
|
44
|
+
description: 'The person\'s open notes on a world: what they want fixed, where (place and painting point). Work each off with the fix tools, then feedback_done.',
|
|
45
|
+
input: z.object({ slug: Slug }),
|
|
46
|
+
async run({ slug }, ctx) {
|
|
47
|
+
return readNotes(ctx.root, slug).filter((n) => n.state === 'open');
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
defineAction({
|
|
52
|
+
name: 'feedback_list',
|
|
53
|
+
reads: true,
|
|
54
|
+
description: 'Every note on a world, open and done, with the replies.',
|
|
55
|
+
input: z.object({ slug: Slug }),
|
|
56
|
+
async run({ slug }, ctx) {
|
|
57
|
+
return readNotes(ctx.root, slug);
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
defineAction({
|
|
62
|
+
name: 'feedback_done',
|
|
63
|
+
description: 'Close a note with one sentence on what was done about it (or why nothing was).',
|
|
64
|
+
input: z.object({ slug: Slug, id: z.string().min(1), reply: z.string().min(2).max(500) }),
|
|
65
|
+
async run({ slug, id, reply }, ctx) {
|
|
66
|
+
const notes = readNotes(ctx.root, slug);
|
|
67
|
+
const note = notes.find((n) => n.id === id);
|
|
68
|
+
if (!note) throw new ActionError(404, `no note ${id} on ${slug}`);
|
|
69
|
+
Object.assign(note, { state: 'done', reply, doneAt: new Date().toISOString() });
|
|
70
|
+
writeNotes(ctx, slug, notes);
|
|
71
|
+
return note;
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
defineAction({
|
|
76
|
+
name: 'feedback_reopen',
|
|
77
|
+
description: 'Open a closed note again, when the fix did not do it.',
|
|
78
|
+
input: z.object({ slug: Slug, id: z.string().min(1) }),
|
|
79
|
+
async run({ slug, id }, ctx) {
|
|
80
|
+
const notes = readNotes(ctx.root, slug);
|
|
81
|
+
const note = notes.find((n) => n.id === id);
|
|
82
|
+
if (!note) throw new ActionError(404, `no note ${id} on ${slug}`);
|
|
83
|
+
Object.assign(note, { state: 'open' });
|
|
84
|
+
writeNotes(ctx, slug, notes);
|
|
85
|
+
return note;
|
|
86
|
+
},
|
|
87
|
+
});
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// Fixing a place: each fix is written to places/<place>/corrections.json, then the place is rebuilt
|
|
2
|
+
// from the step the fix feeds, so the next full build keeps it. The agent gets the same tools.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { Corrections, readCorrections, RectIn, Stroke, writeCorrections } from '../scene/corrections.ts';
|
|
7
|
+
import { Layout } from '../scene/layout.ts';
|
|
8
|
+
import { KINDS, parseCell } from '../scene/locate.ts';
|
|
9
|
+
import { wordKeys } from '../scene/words.ts';
|
|
10
|
+
import { agentBlocked } from '../studio/locks.ts';
|
|
11
|
+
import { placeTrouble } from '../studio/trouble.ts';
|
|
12
|
+
import { WorldFiles } from '../world/files.ts';
|
|
13
|
+
import { Slug, World } from '../world/schema.ts';
|
|
14
|
+
import { storyNeeds } from '../story/check.ts';
|
|
15
|
+
import { ActionError, defineAction, type ActionContext } from './registry.ts';
|
|
16
|
+
|
|
17
|
+
const XY = z.tuple([z.number(), z.number()]);
|
|
18
|
+
const Cell = z.string().refine((c) => !!parseCell(c), 'a grid cell like C12: a letter per 60 px column from A, a number per 60 px row from 1');
|
|
19
|
+
|
|
20
|
+
/** Applies a change to a place's corrections and rebuilds that place from `from` to verify, as a job. */
|
|
21
|
+
async function fixPlace(ctx: ActionContext, slug: string, place: string, from: 'layout' | 'paint' | 'masks' | 'derive', label: string, change: (c: Corrections) => void, addedThing?: string) {
|
|
22
|
+
const dir = path.join(ctx.root, slug);
|
|
23
|
+
const blocked = agentBlocked(dir, ctx.by, place);
|
|
24
|
+
if (blocked) throw new ActionError(423, blocked);
|
|
25
|
+
// The agent's repair limit: after three fixes in a row that left the place unplayable, it is the person's turn.
|
|
26
|
+
if (ctx.by === 'agent') {
|
|
27
|
+
const since = new Date(Date.now() - 30 * 60_000).toISOString();
|
|
28
|
+
const tries = ctx.jobs.list(slug).filter((j) => j.by === 'agent' && j.args.includes('--place') && j.args[j.args.indexOf('--place') + 1] === place && (j.queuedAt ?? '') >= since);
|
|
29
|
+
const lastOk = tries.findLastIndex((j) => j.state === 'done');
|
|
30
|
+
if (tries.slice(lastOk + 1).filter((j) => j.state === 'failed').length >= 3) {
|
|
31
|
+
throw new ActionError(429, `three fixes of ${place} in a row left it unplayable: stop here and tell the person what you tried and what you would do next`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const placeDir = path.join(dir, 'places', place);
|
|
35
|
+
const needs = { layout: null, paint: 'blockin.png', masks: 'painting.png', derive: 'masks.json' }[from];
|
|
36
|
+
if (needs && !fs.existsSync(path.join(placeDir, needs))) throw new ActionError(404, `${slug}/${place} has no ${needs} yet: build it first`);
|
|
37
|
+
return ctx.jobs.start(slug, ['build', slug, '--from', from, '--to', 'verify', '--place', place], `${label} · ${place}`, ctx.by, () => {
|
|
38
|
+
// The job snapshots first, then changes its source. Queued fixes must not change a running build.
|
|
39
|
+
fs.mkdirSync(placeDir, { recursive: true });
|
|
40
|
+
const fixes = readCorrections(placeDir);
|
|
41
|
+
change(fixes);
|
|
42
|
+
writeCorrections(placeDir, Corrections.parse(fixes));
|
|
43
|
+
fs.rmSync(path.join(placeDir, 'verify.json'), { force: true });
|
|
44
|
+
}, addedThing ? { place, key: addedThing } : undefined);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const thingOf = (c: Corrections, key: string) => (c.things[key] ??= { strokes: [] });
|
|
48
|
+
const floorOf = (c: Corrections, key: string) => (c.floors[key] ??= { add: [], remove: [], strokes: [] });
|
|
49
|
+
|
|
50
|
+
/** Only an observed composition rejection unlocks relayout of an already painted place. */
|
|
51
|
+
function compositionFailed(dir: string): boolean {
|
|
52
|
+
try {
|
|
53
|
+
const r = JSON.parse(fs.readFileSync(path.join(dir, 'painting-layout.json'), 'utf8')) as { ok: boolean; attempts?: Array<{ issues: string[] }> };
|
|
54
|
+
return r.ok === false && !!r.attempts?.at(-1)?.issues.length;
|
|
55
|
+
} catch { return false; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
defineAction({
|
|
59
|
+
name: 'corrections_get',
|
|
60
|
+
reads: true,
|
|
61
|
+
description: 'The fixes the person (or you) made to a place so far: SAM phrases, boxes, kinds, brush strokes, floor cells, stand spots, repaints, the painter\'s wording.',
|
|
62
|
+
input: z.object({ slug: Slug, place: z.string().min(1) }),
|
|
63
|
+
async run({ slug, place }, ctx) {
|
|
64
|
+
return readCorrections(path.join(ctx.root, slug, 'places', place));
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
defineAction({
|
|
69
|
+
name: 'mask_fix',
|
|
70
|
+
description: 'Fix how a thing is found in the painting: a better name for SAM to search ("wooden rowing boat"), the box where it really is, '
|
|
71
|
+
+ 'or its kind (standing: people walk around and behind it; wall: on the backdrop; on-top; walk-on; flat). Reruns masks to verify for that place.',
|
|
72
|
+
input: z.object({
|
|
73
|
+
slug: Slug,
|
|
74
|
+
place: z.string().min(1),
|
|
75
|
+
thing: z.string().min(1),
|
|
76
|
+
phrase: z.string().min(2).optional(),
|
|
77
|
+
box: RectIn.optional(),
|
|
78
|
+
kind: z.enum(KINDS).optional(),
|
|
79
|
+
}),
|
|
80
|
+
job: true,
|
|
81
|
+
async run({ slug, place, thing, phrase, box, kind }, ctx) {
|
|
82
|
+
if (!phrase && !box && !kind) throw new ActionError(400, 'give a phrase, a box or a kind');
|
|
83
|
+
return fixPlace(ctx, slug, place, 'masks', `fix ${thing}`, (c) => {
|
|
84
|
+
const t = thingOf(c, thing);
|
|
85
|
+
if (phrase) t.phrase = phrase;
|
|
86
|
+
if (box) t.box = box;
|
|
87
|
+
if (kind) t.kind = kind;
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
defineAction({
|
|
93
|
+
name: 'mask_brush',
|
|
94
|
+
description: 'Paint onto or erase from the mask of a thing or a floor with round brush strokes (painting pixels, 1200x896). Kept across rebuilds; reruns masks to verify for that place.',
|
|
95
|
+
input: z.object({
|
|
96
|
+
slug: Slug,
|
|
97
|
+
place: z.string().min(1),
|
|
98
|
+
layer: z.enum(['thing', 'floor']),
|
|
99
|
+
key: z.string().min(1),
|
|
100
|
+
strokes: z.array(Stroke).min(1).max(50),
|
|
101
|
+
}),
|
|
102
|
+
job: true,
|
|
103
|
+
async run({ slug, place, layer, key, strokes }, ctx) {
|
|
104
|
+
return fixPlace(ctx, slug, place, 'masks', `brush ${key}`, (c) => {
|
|
105
|
+
(layer === 'thing' ? thingOf(c, key) : floorOf(c, key)).strokes.push(...strokes);
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
defineAction({
|
|
111
|
+
name: 'floor_cells',
|
|
112
|
+
description: 'Say which 60 px grid cells are (add) or are not (remove) a floor people walk on, when Spark marked them wrong; cells are named like C12 '
|
|
113
|
+
+ '(column letter from A at the left, row number from 1 at the top). The cells guide SAM; brush strokes (mask_brush) are painted over its mask after, '
|
|
114
|
+
+ 'so removing cells does not take back a stroke: corrections_clear or mask_brush erase does. Reruns masks to verify for that place.',
|
|
115
|
+
input: z.object({ slug: Slug, place: z.string().min(1), floor: z.string().min(1), add: z.array(Cell).optional(), remove: z.array(Cell).optional() }),
|
|
116
|
+
job: true,
|
|
117
|
+
async run({ slug, place, floor, add, remove }, ctx) {
|
|
118
|
+
if (!add?.length && !remove?.length) throw new ActionError(400, 'give cells to add or remove');
|
|
119
|
+
return fixPlace(ctx, slug, place, 'masks', `floor ${floor}`, (c) => {
|
|
120
|
+
const f = floorOf(c, floor);
|
|
121
|
+
const up = (cells?: string[]) => (cells ?? []).map((s) => s.toUpperCase());
|
|
122
|
+
f.add = [...new Set([...f.add.filter((s) => !up(remove).includes(s)), ...up(add)])];
|
|
123
|
+
f.remove = [...new Set([...f.remove.filter((s) => !up(add).includes(s)), ...up(remove)])];
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
defineAction({
|
|
129
|
+
name: 'stand_set',
|
|
130
|
+
description: 'Set where the hero stands to use a thing (painting pixels, on walkable floor). Reruns derive to verify for that place.',
|
|
131
|
+
input: z.object({ slug: Slug, place: z.string().min(1), thing: z.string().min(1), at: XY }),
|
|
132
|
+
job: true,
|
|
133
|
+
async run({ slug, place, thing, at }, ctx) {
|
|
134
|
+
return fixPlace(ctx, slug, place, 'derive', `stand ${thing}`, (c) => {
|
|
135
|
+
thingOf(c, thing).stand = at;
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
defineAction({
|
|
141
|
+
name: 'npc_move',
|
|
142
|
+
description: 'Move an NPC to stand elsewhere in a place: at is where its feet go (painting pixels, on walkable floor); it is sized for the depth there. '
|
|
143
|
+
+ 'Use it when an NPC covers a thing the player must click, or stands somewhere odd. No repaint: reruns derive to verify for that place.',
|
|
144
|
+
input: z.object({ slug: Slug, place: z.string().min(1), npc: z.string().min(1), at: XY }),
|
|
145
|
+
job: true,
|
|
146
|
+
async run({ slug, place, npc, at }, ctx) {
|
|
147
|
+
const world = new WorldFiles(slug, ctx.root).world();
|
|
148
|
+
const thing = world.places.find((p) => p.key === place)?.things.find((t) => t.key === npc);
|
|
149
|
+
if (thing?.role !== 'npc') throw new ActionError(404, `${place} has no NPC called ${npc}`);
|
|
150
|
+
return fixPlace(ctx, slug, place, 'derive', `move ${npc}`, (c) => {
|
|
151
|
+
thingOf(c, npc).at = at;
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
defineAction({
|
|
157
|
+
name: 'hero_start_set',
|
|
158
|
+
description: 'Set where the hero begins in a place (painting pixels, on walkable floor; the nearest roomy cell of the main floor is used): '
|
|
159
|
+
+ 'when the hero starts in front of an NPC or a thing and hides it, or somewhere odd. Kept across rebuilds. No repaint: reruns derive to verify for that place.',
|
|
160
|
+
input: z.object({ slug: Slug, place: z.string().min(1), at: XY }),
|
|
161
|
+
job: true,
|
|
162
|
+
async run({ slug, place, at }, ctx) {
|
|
163
|
+
return fixPlace(ctx, slug, place, 'derive', 'hero start', (c) => {
|
|
164
|
+
c.start = at;
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
defineAction({
|
|
170
|
+
name: 'repaint_region',
|
|
171
|
+
description: 'Repaint one region of a place with Muse ("make the lantern brass, not glass"): only that region changes. Costs one Muse edit; '
|
|
172
|
+
+ 'reruns paint to verify for that place (masks and walking are measured again).',
|
|
173
|
+
input: z.object({ slug: Slug, place: z.string().min(1), region: RectIn, fix: z.string().min(3) }),
|
|
174
|
+
job: true,
|
|
175
|
+
async run({ slug, place, region, fix }, ctx) {
|
|
176
|
+
return fixPlace(ctx, slug, place, 'paint', 'repaint', (c) => {
|
|
177
|
+
c.repaint.push({ region, fix });
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
/** A prop the person took off a place's list, or one they added: the place's thing list as world.json and the edits make it. */
|
|
183
|
+
function thingsOf(dir: string, place: string) {
|
|
184
|
+
const world = World.safeParse(JSON.parse(fs.readFileSync(path.join(dir, 'world.json'), 'utf8')));
|
|
185
|
+
const p = world.success ? world.data.places.find((x) => x.key === place) : undefined;
|
|
186
|
+
if (!p) throw new ActionError(404, `no place ${place}`);
|
|
187
|
+
return p;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
defineAction({
|
|
191
|
+
name: 'thing_remove',
|
|
192
|
+
description: 'Take a thing off a place\'s list when the person says it should not be a thing of its own (a prop only: an exit, an item or a person '
|
|
193
|
+
+ 'belongs to the story, change that with story_redo). paintOut false (default): it stays painted, just no longer a separate card one can '
|
|
194
|
+
+ 'walk behind or click; reruns masks to verify. paintOut true: Muse also paints it out of the picture (one Muse edit); reruns paint to verify. '
|
|
195
|
+
+ 'Kept in corrections.json across rebuilds; history_undo takes it back.',
|
|
196
|
+
input: z.object({ slug: Slug, place: z.string().min(1), thing: z.string().min(1), paintOut: z.boolean().optional() }),
|
|
197
|
+
job: true,
|
|
198
|
+
async run({ slug, place, thing, paintOut }, ctx) {
|
|
199
|
+
const dir = path.join(ctx.root, slug);
|
|
200
|
+
const placeDir = path.join(dir, 'places', place);
|
|
201
|
+
const listed = thingsOf(dir, place).things.find((t) => t.key === thing);
|
|
202
|
+
const added = readCorrections(placeDir).added[thing];
|
|
203
|
+
if (!listed && !added) throw new ActionError(404, `${place} has no thing ${thing}`);
|
|
204
|
+
if (listed && listed.role !== 'prop') throw new ActionError(409, `${thing} is ${listed.role === 'npc' ? 'a person' : `an ${listed.role}`} in the story: change the story with story_redo to take it out`);
|
|
205
|
+
const story = path.join(dir, 'story.json');
|
|
206
|
+
if (listed && fs.existsSync(story) && storyNeeds(JSON.parse(fs.readFileSync(story, 'utf8')), place, thing)) throw new ActionError(409, `the story uses ${thing}: change the story with story_redo to take it out`);
|
|
207
|
+
let region: [number, number, number, number] | undefined;
|
|
208
|
+
if (paintOut) {
|
|
209
|
+
const masks = path.join(placeDir, 'masks.json');
|
|
210
|
+
const box = fs.existsSync(masks) ? (JSON.parse(fs.readFileSync(masks, 'utf8')) as { things?: Record<string, { box?: number[] }> }).things?.[thing]?.box : undefined;
|
|
211
|
+
if (!box) throw new ActionError(409, `${thing} has no mask yet, so there is nothing to paint out: take it off with paintOut false`);
|
|
212
|
+
const pad = 16;
|
|
213
|
+
region = [Math.max(0, box[0] - pad), Math.max(0, box[1] - pad), Math.min(1200, box[2] + pad), Math.min(896, box[3] + pad)];
|
|
214
|
+
}
|
|
215
|
+
const looks = (listed?.looks ?? added?.looks ?? thing.replace(/-/g, ' ')).replace(/^(a|an|the)\s+/i, '');
|
|
216
|
+
return fixPlace(ctx, slug, place, paintOut ? 'paint' : 'masks', `remove ${thing}`, (c) => {
|
|
217
|
+
if (added) delete c.added[thing];
|
|
218
|
+
else thingOf(c, thing).removed = true;
|
|
219
|
+
if (region) c.repaint.push({ region, fix: `Paint out the ${looks}: continue what is behind it, add nothing new` });
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
defineAction({
|
|
225
|
+
name: 'thing_add',
|
|
226
|
+
description: 'Make something painted in a place a thing of its own (a prop: a card one walks behind and can look at), when the person says it '
|
|
227
|
+
+ 'is missing from the list: what it is in their words ("the red bench by the fountain"), and its kind if they say how it meets the floor. '
|
|
228
|
+
+ 'It must already be in the painting. Reruns masks to verify; kept in corrections.json across rebuilds.',
|
|
229
|
+
input: z.object({
|
|
230
|
+
slug: Slug,
|
|
231
|
+
place: z.string().min(1),
|
|
232
|
+
looks: z.string().min(3).max(200).describe('What it is, as painted, in a few words.'),
|
|
233
|
+
kind: z.enum(KINDS).optional(),
|
|
234
|
+
}),
|
|
235
|
+
job: true,
|
|
236
|
+
async run({ slug, place, looks, kind }, ctx) {
|
|
237
|
+
const dir = path.join(ctx.root, slug);
|
|
238
|
+
const p = thingsOf(dir, place);
|
|
239
|
+
const fixes = readCorrections(path.join(dir, 'places', place));
|
|
240
|
+
const pending = ctx.jobs.list(slug).flatMap((j) => (j.state === 'queued' || j.state === 'running') && j.addedThing?.place === place ? [j.addedThing.key] : []);
|
|
241
|
+
const taken = new Set([...p.things.map((t) => t.key), ...Object.keys(fixes.added), ...pending]);
|
|
242
|
+
if (taken.size >= 10) throw new ActionError(409, `${place} has ten things already: take one off first`);
|
|
243
|
+
const base = looks.toLowerCase().replace(/^(the|a|an)\s+/, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').split('-').slice(0, 4).join('-') || 'thing';
|
|
244
|
+
const key = /^[a-z]/.test(base) ? base : `thing-${base}`;
|
|
245
|
+
let unique = key;
|
|
246
|
+
for (let n = 2; taken.has(unique); n++) unique = `${key}-${n}`;
|
|
247
|
+
const job = await fixPlace(ctx, slug, place, 'masks', `add ${unique}`, (c) => {
|
|
248
|
+
if (c.added[unique]) throw new ActionError(409, `${unique} was added while this job waited: add it again to choose a new key`);
|
|
249
|
+
c.added[unique] = { looks };
|
|
250
|
+
if (kind) thingOf(c, unique).kind = kind;
|
|
251
|
+
if (c.things[unique]) delete c.things[unique].removed;
|
|
252
|
+
}, unique);
|
|
253
|
+
return { thing: unique, job };
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
defineAction({
|
|
258
|
+
name: 'paint_retry',
|
|
259
|
+
description: 'Try a new painting take for one place using its existing plan, then rebuild it through verify. Use after PAINT_LAYOUT_MISMATCH: advances the take instead of replaying the same rejected cached image. Keeps the previous painting until the replacement passes composition checks.',
|
|
260
|
+
input: z.object({ slug: Slug, place: Slug }),
|
|
261
|
+
job: true,
|
|
262
|
+
async run({ slug, place }, ctx) {
|
|
263
|
+
if (!new WorldFiles(slug, ctx.root).world().places.some((p) => p.key === place)) throw new ActionError(404, `${slug} has no place ${place}`);
|
|
264
|
+
return fixPlace(ctx, slug, place, 'paint', 'new painting take', (c) => {
|
|
265
|
+
c.take = (c.take ?? 0) + 1;
|
|
266
|
+
if (c.layout) c.layout.replans = 0;
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* layout_fix rounds a composition rejection allows before a new painting take. Each round is a full relayout and
|
|
273
|
+
* repaint (2-5 minutes); magic-toast's cloud-harbor went through 7 plans in a row, each failing the same way.
|
|
274
|
+
*/
|
|
275
|
+
export const REPLANS = 2;
|
|
276
|
+
|
|
277
|
+
defineAction({
|
|
278
|
+
name: 'layout_fix',
|
|
279
|
+
description: 'Repair an unfinished layout, or simplify a plan whose painting failed PAINT_LAYOUT_MISMATCH. Save concrete layout guidance (camera, object sizes, floor access) and rebuild only that place through verify. Read place_status first. Other existing painted scenes use the other fix tools.',
|
|
280
|
+
input: z.object({ slug: Slug, place: Slug, guidance: z.string().min(10).max(4000) }),
|
|
281
|
+
job: true,
|
|
282
|
+
async run({ slug, place, guidance }, ctx) {
|
|
283
|
+
const files = new WorldFiles(slug, ctx.root);
|
|
284
|
+
if (!files.world().places.some((p) => p.key === place)) throw new ActionError(404, `${slug} has no place ${place}`);
|
|
285
|
+
const painted = () => fs.existsSync(files.place(place, 'painting.png')) && !compositionFailed(files.place(place));
|
|
286
|
+
if (painted()) throw new ActionError(409, `${place} already has a painting: use the existing place fix tools`);
|
|
287
|
+
const replanning = compositionFailed(files.place(place));
|
|
288
|
+
const replans = readCorrections(files.place(place)).layout?.replans ?? 0;
|
|
289
|
+
if (replanning && replans >= REPLANS) throw new ActionError(409, `${place}: ${replans} simpler plans in a row were still rejected by the composition check; call paint_retry for a new painting take of the current plan first (or ask the person), then layout_fix may simplify again`);
|
|
290
|
+
return fixPlace(ctx, slug, place, 'layout', 'repair layout', (c) => {
|
|
291
|
+
if (painted()) throw new ActionError(409, `${place} acquired a painting while this repair waited: use the existing place fix tools`);
|
|
292
|
+
c.layout = { guidance, ...(replanning && { replans: replans + 1 }) };
|
|
293
|
+
});
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
defineAction({
|
|
298
|
+
name: 'place_fix',
|
|
299
|
+
description: 'The Fix it button of a place that failed or was never painted: rebuilds the place from the step it broke at (paint rewords by itself '
|
|
300
|
+
+ 'when the image safety filter refused it), or, when it was built through and verify still finds errors, hands it to the agent.',
|
|
301
|
+
input: z.object({ slug: Slug, place: z.string().min(1) }),
|
|
302
|
+
async run({ slug, place }, ctx) {
|
|
303
|
+
const dir = path.join(ctx.root, slug);
|
|
304
|
+
if (!new WorldFiles(slug, ctx.root).world().places.some((p) => p.key === place)) throw new ActionError(404, `${slug} has no place ${place}`);
|
|
305
|
+
const trouble = placeTrouble(dir, place);
|
|
306
|
+
if (!trouble) throw new ActionError(409, `${place} has nothing to fix: it is playable or not built yet`);
|
|
307
|
+
if (trouble.from) {
|
|
308
|
+
const blocked = agentBlocked(dir, ctx.by, place);
|
|
309
|
+
if (blocked) throw new ActionError(423, blocked);
|
|
310
|
+
const job = trouble.from === 'layout'
|
|
311
|
+
? await fixPlace(ctx, slug, place, 'layout', 'retry layout', () => {})
|
|
312
|
+
: trouble.from === 'paint' && compositionFailed(path.join(dir, 'places', place))
|
|
313
|
+
? await fixPlace(ctx, slug, place, 'paint', 'new painting take', (c) => { c.take = (c.take ?? 0) + 1; })
|
|
314
|
+
: ctx.jobs.start(slug, ['build', slug, '--from', trouble.from, '--to', 'verify', '--place', place], `fix ${place} from ${trouble.from}`, ctx.by);
|
|
315
|
+
return { did: 'rebuild', from: trouble.from, why: trouble.why, job };
|
|
316
|
+
}
|
|
317
|
+
// Built through and still not playable: the fix tools need judgement, which is the agent's (or the person's).
|
|
318
|
+
if (ctx.by === 'agent') throw new ActionError(409, `${place} needs a fix tool: ${trouble.why}; follow place_status's playbook`);
|
|
319
|
+
const prompt = `[Studio] The person pressed Fix it on ${place}, which is not playable:\n${trouble.errors.map((e) => `- ${e}`).join('\n')}\n`
|
|
320
|
+
+ 'Repair it as your brief says: place_status and place_view first, then the playbook\'s fix tools in order, at most three fixes, '
|
|
321
|
+
+ 'and ask me before the last resort. Then tell me in two sentences what you fixed and what is left for me.';
|
|
322
|
+
ctx.events.publish('repair.ask', { world: slug, place, prompt });
|
|
323
|
+
return { did: 'agent', why: trouble.why };
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
defineAction({
|
|
328
|
+
name: 'paint_words',
|
|
329
|
+
description: 'Reword how a place is described to the painter: what the place shows, its mood, and the look of things and floors (one sentence each, '
|
|
330
|
+
+ 'used as it is). Use it when paint failed with content_policy_violation (the image safety filter refused a word such as "bare" or "hanging"), '
|
|
331
|
+
+ 'or when the painting keeps getting a thing wrong in kind. world.json and the story stay as they are. Repaints the place from paint to verify.',
|
|
332
|
+
input: z.object({
|
|
333
|
+
slug: Slug,
|
|
334
|
+
place: z.string().min(1),
|
|
335
|
+
shows: z.string().min(10).optional().describe('What the place shows, in place of world.json\'s.'),
|
|
336
|
+
mood: z.string().min(2).optional(),
|
|
337
|
+
looks: z.record(z.string(), z.string().min(10)).optional().describe('Thing or floor key to one sentence on how it looks.'),
|
|
338
|
+
}),
|
|
339
|
+
job: true,
|
|
340
|
+
async run({ slug, place, shows, mood, looks }, ctx) {
|
|
341
|
+
const world = new WorldFiles(slug, ctx.root).world();
|
|
342
|
+
const p = world.places.find((x) => x.key === place);
|
|
343
|
+
if (!p) throw new ActionError(404, `${slug} has no place ${place}`);
|
|
344
|
+
if (!shows && !mood && !looks) throw new ActionError(400, 'give shows, mood or looks');
|
|
345
|
+
const layoutFile = path.join(ctx.root, slug, 'places', place, 'layout.json');
|
|
346
|
+
const floors = fs.existsSync(layoutFile) ? Layout.parse(JSON.parse(fs.readFileSync(layoutFile, 'utf8'))).floors : [];
|
|
347
|
+
const keys = wordKeys(p, { floors });
|
|
348
|
+
const unknown = Object.keys(looks ?? {}).filter((k) => !keys.includes(k));
|
|
349
|
+
if (unknown.length) throw new ActionError(400, `${place} has no painted thing or floor ${unknown.join(', ')}: it has ${keys.join(', ')}`);
|
|
350
|
+
return fixPlace(ctx, slug, place, 'paint', 'reword', (c) => {
|
|
351
|
+
const had = c.words ?? { looks: {}, by: 'person' as const };
|
|
352
|
+
c.words = { ...had, ...(shows && { shows }), ...(mood && { mood }), looks: { ...had.looks, ...looks }, by: 'person' };
|
|
353
|
+
});
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
defineAction({
|
|
358
|
+
name: 'corrections_clear',
|
|
359
|
+
description: 'Take back fixes of a place: all of them, or those of one thing or floor. Reruns that place from paint (repaints) or masks.',
|
|
360
|
+
input: z.object({ slug: Slug, place: z.string().min(1), key: z.string().min(1).optional().describe('One thing or floor; leave out to clear every fix of the place.') }),
|
|
361
|
+
job: true,
|
|
362
|
+
async run({ slug, place, key }, ctx) {
|
|
363
|
+
const had = readCorrections(path.join(ctx.root, slug, 'places', place));
|
|
364
|
+
const repaints = !key ? had.repaint.length > 0 || !!had.words : !!had.words?.looks[key];
|
|
365
|
+
const unfinishedLayout = !key && had.layout && !fs.existsSync(path.join(ctx.root, slug, 'places', place, 'painting.png'));
|
|
366
|
+
return fixPlace(ctx, slug, place, unfinishedLayout ? 'layout' : repaints ? 'paint' : 'masks', `clear ${key ?? 'fixes'}`, (c) => {
|
|
367
|
+
if (key) {
|
|
368
|
+
delete c.things[key];
|
|
369
|
+
delete c.floors[key];
|
|
370
|
+
delete c.added[key];
|
|
371
|
+
if (c.words) delete c.words.looks[key];
|
|
372
|
+
} else {
|
|
373
|
+
c.things = {};
|
|
374
|
+
c.floors = {};
|
|
375
|
+
c.added = {};
|
|
376
|
+
c.repaint = [];
|
|
377
|
+
delete c.words;
|
|
378
|
+
delete c.layout;
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
},
|
|
382
|
+
});
|