@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,54 @@
|
|
|
1
|
+
// Asking before something big or hard to take back, in the Studio's own dialog instead of the browser's confirm.
|
|
2
|
+
// `type` makes the person type a word (the world's slug) before the button wakes up, as GitHub does for deletes.
|
|
3
|
+
import { signal } from '@preact/signals';
|
|
4
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
5
|
+
|
|
6
|
+
type Ask = { title: string; body: string; go: string; danger?: boolean; type?: string; answer: (yes: boolean) => void };
|
|
7
|
+
const asking = signal<Ask | null>(null);
|
|
8
|
+
|
|
9
|
+
/** Resolves true when the person presses `go`, false on Cancel, Esc or a click outside. */
|
|
10
|
+
export function askSure(o: Omit<Ask, 'answer'>): Promise<boolean> {
|
|
11
|
+
return new Promise((answer) => {
|
|
12
|
+
asking.value?.answer(false);
|
|
13
|
+
asking.value = { ...o, answer };
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Mounted once, like the lightbox. */
|
|
18
|
+
export function Sure() {
|
|
19
|
+
const a = asking.value;
|
|
20
|
+
const [typed, setTyped] = useState('');
|
|
21
|
+
const box = useRef<HTMLInputElement>(null);
|
|
22
|
+
const done = (yes: boolean) => {
|
|
23
|
+
a?.answer(yes);
|
|
24
|
+
asking.value = null;
|
|
25
|
+
setTyped('');
|
|
26
|
+
};
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!a) return;
|
|
29
|
+
box.current?.focus();
|
|
30
|
+
const esc = (e: KeyboardEvent) => e.key === 'Escape' && (e.stopImmediatePropagation(), done(false));
|
|
31
|
+
addEventListener('keydown', esc, true);
|
|
32
|
+
return () => removeEventListener('keydown', esc, true);
|
|
33
|
+
}, [a]);
|
|
34
|
+
if (!a) return null;
|
|
35
|
+
const ok = !a.type || typed.trim() === a.type;
|
|
36
|
+
return (
|
|
37
|
+
<div class="sure-back" onClick={(e) => e.target === e.currentTarget && done(false)}>
|
|
38
|
+
<form class="sure" role="alertdialog" aria-label={a.title} onSubmit={(e) => (e.preventDefault(), ok && done(true))}>
|
|
39
|
+
<h2>{a.title}</h2>
|
|
40
|
+
<p>{a.body}</p>
|
|
41
|
+
{a.type && (
|
|
42
|
+
<label><span>Type <code>{a.type}</code> to confirm</span>
|
|
43
|
+
<input ref={box} value={typed} autoComplete="off" spellcheck={false} onInput={(e) => setTyped(e.currentTarget.value)} />
|
|
44
|
+
</label>
|
|
45
|
+
)}
|
|
46
|
+
<div class="row">
|
|
47
|
+
<span class="grow" />
|
|
48
|
+
<button type="button" class="ghost" onClick={() => done(false)}>Cancel</button>
|
|
49
|
+
<button type="submit" class={a.danger ? 'danger-fill' : 'primary'} disabled={!ok}>{a.go}</button>
|
|
50
|
+
</div>
|
|
51
|
+
</form>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Tooltips for the whole Studio: any element with a title gets a styled tip instead of the browser's slow grey
|
|
2
|
+
// one. Mounted once; it watches the pointer and keyboard focus, so a screen just writes title="what it does".
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
4
|
+
|
|
5
|
+
type Shown = { text: string; rect: DOMRect };
|
|
6
|
+
|
|
7
|
+
/** The game's frame and a select's options keep their own title. */
|
|
8
|
+
const SKIP = new Set(['IFRAME', 'OPTION']);
|
|
9
|
+
const WAIT_MS = 350;
|
|
10
|
+
|
|
11
|
+
/** Moves an element's title into data-tip (so the browser's own tip stays away) and returns the text. */
|
|
12
|
+
function tipOf(el: Element): string {
|
|
13
|
+
const title = el.getAttribute('title');
|
|
14
|
+
if (title) {
|
|
15
|
+
el.setAttribute('data-tip', title);
|
|
16
|
+
el.removeAttribute('title');
|
|
17
|
+
if (!el.hasAttribute('aria-label') && !el.textContent?.trim()) el.setAttribute('aria-label', title);
|
|
18
|
+
}
|
|
19
|
+
return el.getAttribute('data-tip') ?? '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function target(from: EventTarget | null): Element | null {
|
|
23
|
+
const el = from instanceof Element ? from.closest('[title], [data-tip]') : null;
|
|
24
|
+
return el && !SKIP.has(el.tagName) ? el : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Tips() {
|
|
28
|
+
const [shown, setShown] = useState<Shown | null>(null);
|
|
29
|
+
const tip = useRef<HTMLDivElement>(null);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
let over: Element | null = null;
|
|
32
|
+
let timer = 0;
|
|
33
|
+
// Right after one tip, the next shows at once: running along a row of buttons reads them all.
|
|
34
|
+
let warmUntil = 0;
|
|
35
|
+
const enter = (el: Element | null) => {
|
|
36
|
+
if (el === over) return;
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
if (over && !el) warmUntil = Date.now() + 500;
|
|
39
|
+
over = el;
|
|
40
|
+
if (!el) return setShown(null);
|
|
41
|
+
tipOf(el);
|
|
42
|
+
timer = window.setTimeout(() => {
|
|
43
|
+
const text = over === el && el.isConnected ? tipOf(el) : '';
|
|
44
|
+
setShown(text ? { text, rect: el.getBoundingClientRect() } : null);
|
|
45
|
+
}, Date.now() < warmUntil ? 0 : WAIT_MS);
|
|
46
|
+
};
|
|
47
|
+
const onOver = (e: PointerEvent) => e.pointerType !== 'touch' && enter(target(e.target));
|
|
48
|
+
const onFocus = (e: FocusEvent) => (e.target as Element)?.matches?.(':focus-visible') && enter(target(e.target));
|
|
49
|
+
const hide = () => enter(null);
|
|
50
|
+
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && hide();
|
|
51
|
+
const on: Array<[EventTarget, string, EventListener, boolean?]> = [
|
|
52
|
+
[document, 'pointerover', onOver as EventListener], [document, 'focusin', onFocus as EventListener], [document, 'focusout', hide],
|
|
53
|
+
[document, 'pointerdown', hide, true], [document, 'keydown', onKey as EventListener], [window, 'scroll', hide, true], [window, 'blur', hide],
|
|
54
|
+
];
|
|
55
|
+
for (const [t, name, fn, capture] of on) t.addEventListener(name, fn, capture);
|
|
56
|
+
return () => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
for (const [t, name, fn, capture] of on) t.removeEventListener(name, fn, capture);
|
|
59
|
+
};
|
|
60
|
+
}, []);
|
|
61
|
+
// Above the element, or below it when there is no room; the edges stay 8 px inside the window.
|
|
62
|
+
useLayoutEffect(() => {
|
|
63
|
+
const el = tip.current;
|
|
64
|
+
if (!el || !shown) return;
|
|
65
|
+
const { rect } = shown;
|
|
66
|
+
const w = el.offsetWidth;
|
|
67
|
+
const h = el.offsetHeight;
|
|
68
|
+
const below = rect.top - h - 8 < 0;
|
|
69
|
+
el.style.left = `${Math.max(8, Math.min(innerWidth - w - 8, rect.left + rect.width / 2 - w / 2))}px`;
|
|
70
|
+
el.style.top = `${below ? rect.bottom + 8 : rect.top - h - 8}px`;
|
|
71
|
+
el.classList.add('on');
|
|
72
|
+
}, [shown]);
|
|
73
|
+
return shown ? <div ref={tip} class="tip" role="tooltip" key={shown.text}>{shown.text}</div> : null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
2
|
+
import { call } from './api.ts';
|
|
3
|
+
import { base64 } from './camera.tsx';
|
|
4
|
+
import { Icon } from './icons.tsx';
|
|
5
|
+
import { VIDEO_CHUNK, VIDEO_MAX } from '../src/world/video-limits.ts';
|
|
6
|
+
export type VideoResult = { speech?: Array<{ at: number; text: string; provider: string; model: string; timing: string; speakerScope?: string; segments: Array<{ start: number; end: number; text: string; speaker?: string }> }>; idea: string; photos: Array<{ name: string; data: string }>; totalFrames: number; observations: Array<{ at: number; text: string }> };
|
|
7
|
+
/** Upload and interpretation are resumable independently; no world is built until the person reviews it. */
|
|
8
|
+
export function VideoInput({ note, disabled, onResult, onBusy }: { note: string; disabled: boolean; onResult: (id: string, result: VideoResult) => void; onBusy: (busy: boolean) => void }) {
|
|
9
|
+
const [preview, setPreview] = useState('');
|
|
10
|
+
const picker = useRef<HTMLInputElement>(null);
|
|
11
|
+
const [filename, setFilename] = useState('');
|
|
12
|
+
useEffect(() => () => { if (preview) URL.revokeObjectURL(preview); }, [preview]);
|
|
13
|
+
const [status, setStatus] = useState('');
|
|
14
|
+
const [error, setError] = useState('');
|
|
15
|
+
const [busy, setBusy] = useState(false);
|
|
16
|
+
const [pending, setPending] = useState(false);
|
|
17
|
+
const work = useRef<{ id?: string; file: File; note: string }>();
|
|
18
|
+
const stop = useRef(false);
|
|
19
|
+
useEffect(() => () => { stop.current = true; }, []);
|
|
20
|
+
const run = async () => {
|
|
21
|
+
if (!work.current || busy) return;
|
|
22
|
+
stop.current = false; setBusy(true); onBusy(true); setError(''); setPending(true);
|
|
23
|
+
const task = work.current;
|
|
24
|
+
try {
|
|
25
|
+
if (!task.id) task.id = (await call<{ id: string }>('video_upload', { name: task.file.name, size: task.file.size })).id;
|
|
26
|
+
// Store the token so a refresh can resume: select the same source file again to continue.
|
|
27
|
+
try { localStorage.setItem('diorama-video-upload', JSON.stringify({ id: task.id, name: task.file.name, size: task.file.size, modified: task.file.lastModified, note: task.note })); } catch {}
|
|
28
|
+
let { received } = await call<{ received: number }>('video_upload', { id: task.id });
|
|
29
|
+
while (received < task.file.size) {
|
|
30
|
+
if (stop.current) return;
|
|
31
|
+
setStatus(`Uploading video · ${Math.floor(received / task.file.size * 100)}%`);
|
|
32
|
+
const data = (await base64(task.file.slice(received, received + VIDEO_CHUNK))).split(',')[1];
|
|
33
|
+
({ received } = await call<{ received: number }>('video_upload', { id: task.id, offset: received, data }));
|
|
34
|
+
}
|
|
35
|
+
setStatus('Reading video duration and audio…');
|
|
36
|
+
const info = await call<{ duration: number; audio: boolean; parts: number }>('video_inspect', { id: task.id });
|
|
37
|
+
for (let index = 0; index < info.parts; index++) {
|
|
38
|
+
if (stop.current) return;
|
|
39
|
+
setStatus(`Understanding video · ${index + 1}/${info.parts} · ${info.audio ? 'pictures and speech' : 'pictures, no audio track'}. You can pause and resume.`);
|
|
40
|
+
await call('video_read', { id: task.id, index, note: task.note });
|
|
41
|
+
}
|
|
42
|
+
if (stop.current) return;
|
|
43
|
+
const result = await call<VideoResult>('video_result', { id: task.id });
|
|
44
|
+
if (stop.current) return;
|
|
45
|
+
onResult(task.id, result); setStatus('Video understood. Review the idea and selected pictures before building.');
|
|
46
|
+
setPending(false); try { localStorage.removeItem('diorama-video-upload'); } catch {} work.current = undefined;
|
|
47
|
+
} catch (e) { setError((e as Error).message); }
|
|
48
|
+
finally { setBusy(false); onBusy(false); }
|
|
49
|
+
};
|
|
50
|
+
const choose = (file?: File) => {
|
|
51
|
+
if (!file) return;
|
|
52
|
+
if (file.size > VIDEO_MAX) { setError('Choose a video up to 2 GB.'); return; }
|
|
53
|
+
let saved;
|
|
54
|
+
try { saved = JSON.parse(localStorage.getItem('diorama-video-upload') ?? 'null'); } catch {}
|
|
55
|
+
setPreview(URL.createObjectURL(file));
|
|
56
|
+
setFilename(file.name);
|
|
57
|
+
work.current = { file, note, ...(saved?.name === file.name && saved?.size === file.size && saved?.modified === file.lastModified ? { id: saved.id, note: saved.note } : {}) };
|
|
58
|
+
void run();
|
|
59
|
+
};
|
|
60
|
+
return <>
|
|
61
|
+
<button type="button" class="ghost bare" disabled={disabled || busy} title="Show your drawings and tell their story in a video. With or without sound, up to 2 GB / 4 hours." onClick={() => picker.current?.click()}><Icon name="film" /> Video</button>
|
|
62
|
+
<input ref={picker} class="video-picker" hidden type="file" aria-label="Choose a video" accept="video/*,.mkv,.m4v,.avi" disabled={disabled || busy} onChange={e => { choose(e.currentTarget.files?.[0]); e.currentTarget.value = ''; }} />
|
|
63
|
+
{(preview || error || status) && <div class="video-status">
|
|
64
|
+
{filename && <p class="video-filename"><Icon name="film" /> {filename}</p>}
|
|
65
|
+
{preview && <video src={preview} controls preload="metadata" style={{ maxWidth: '100%', maxHeight: '300px' }} />}
|
|
66
|
+
{status && <p role="status">{status}</p>}
|
|
67
|
+
{error && <p class="error" role="alert">{error}</p>}
|
|
68
|
+
{busy && <button type="button" class="ghost small" onClick={() => { stop.current = true; setStatus('Pausing after the current interval…'); }}>Pause</button>}
|
|
69
|
+
{!busy && pending && <button type="button" class="ghost small" onClick={() => void run()}>Resume video</button>}
|
|
70
|
+
<details class="detail"><summary>Video tips</summary>Hold each drawing still for at least ten seconds. You can pause and resume processing. Interpreting the video uses model calls.</details>
|
|
71
|
+
</div>}
|
|
72
|
+
</>;
|
|
73
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// A world in the atelier: the conversation with the agent that looks after it, told in cards (the story, the
|
|
2
|
+
// places, the cast, what waits for you, what a turn changed, the game), beside a stage that shows what the
|
|
3
|
+
// card is about. Click a thing on the stage and it opens; the agent is told what is open with each message.
|
|
4
|
+
import type { ComponentChildren } from 'preact';
|
|
5
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
6
|
+
import { call, fileUrl } from './api.ts';
|
|
7
|
+
import { named } from './build.tsx';
|
|
8
|
+
import { Icon } from './icons.tsx';
|
|
9
|
+
|
|
10
|
+
export type StageView = { kind: 'build' | 'place' | 'cast' | 'story' | 'play' | 'work'; place?: string; step?: string; build?: { place?: string } };
|
|
11
|
+
|
|
12
|
+
type Pic = { file: string; v: number | null } | null;
|
|
13
|
+
export type CardPlace = { key: string; shows: string; image: Pic; stages: Record<string, number | null>; verdict: { ok: boolean } | null; trouble?: { why: string } | null };
|
|
14
|
+
type Character = { key: string; role: string; looksNow: string; figure: Pic };
|
|
15
|
+
|
|
16
|
+
/** A card in the conversation: what it shows decides the stage when clicked. */
|
|
17
|
+
export function Card({ kind, by, on, onClick, children, tone, opening }: { kind: string; by?: string; on?: boolean; onClick?: () => void; children: ComponentChildren; tone?: 'ask' | 'play'; opening?: boolean }) {
|
|
18
|
+
return (
|
|
19
|
+
<div class={`wcard${on ? ' on' : ''}${tone ? ` ${tone}` : ''}${onClick ? ' click' : ''}`} data-opening={opening ? kind.toLowerCase() : undefined} onClick={onClick} role={onClick ? 'button' : undefined} tabIndex={onClick ? 0 : undefined}
|
|
20
|
+
onKeyDown={(e) => onClick && (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onClick())}>
|
|
21
|
+
<div class="k">{kind}{by && <span class="by">{named(by)}</span>}</div>
|
|
22
|
+
{children}
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const nice = (key: string) => key.replace(/[-_]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
|
|
28
|
+
export { nice };
|
|
29
|
+
|
|
30
|
+
/** The places as small paintings; one not painted yet shows its name. */
|
|
31
|
+
export function PlacesCard({ slug, places, pending = [], stage, onStage }: { slug: string; places: CardPlace[]; pending?: string[]; stage: StageView; onStage: (s: StageView) => void }) {
|
|
32
|
+
return (
|
|
33
|
+
<Card kind="Places" by="Muse Image · SAM 3.1" on={stage.kind === 'place'} opening>
|
|
34
|
+
<div class="thumbs">
|
|
35
|
+
{places.map((p) => (
|
|
36
|
+
<button type="button" class={`thumb${stage.kind === 'place' && stage.place === p.key ? ' on' : ''}${p.trouble ? ' trouble' : p.verdict?.ok ? ' ready' : ''}`} title={`${nice(p.key)}: ${p.shows}${p.verdict?.ok ? ' (playable)' : ''}`} onClick={() => onStage({ kind: 'place', place: p.key })}
|
|
37
|
+
style={p.image ? { backgroundImage: `url("${fileUrl(slug, p.image.file, p.image.v)}")` } : undefined}>
|
|
38
|
+
{!p.image && <span>{nice(p.key)}</span>}
|
|
39
|
+
</button>
|
|
40
|
+
))}
|
|
41
|
+
{pending.map(key => <button type="button" class="thumb pending-place" title={`${nice(key)}: being built; not in the game yet. Show build progress`} onClick={() => onStage({ kind: 'build' })}><span>{nice(key)}<br />Building…</span></button>)}
|
|
42
|
+
</div>
|
|
43
|
+
</Card>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The characters standing in a row, as cut out. */
|
|
48
|
+
export function CastCard({ slug, version, stage, onStage }: { slug: string; version: number; stage: StageView; onStage: (s: StageView) => void }) {
|
|
49
|
+
const cast = useCast(slug, version);
|
|
50
|
+
const shown = cast?.filter((c) => c.figure) ?? [];
|
|
51
|
+
if (!shown.length) return null;
|
|
52
|
+
return (
|
|
53
|
+
<Card kind="Cast" by="Muse Image · 3D" on={stage.kind === 'cast'} onClick={() => onStage({ kind: 'cast' })} opening>
|
|
54
|
+
<div class="lineup-mini">{shown.map((c) => <img src={fileUrl(slug, c.figure!.file, c.figure!.v)} alt={nice(c.key)} title={`${nice(c.key)}: ${c.looksNow}`} />)}</div>
|
|
55
|
+
</Card>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The world at a glance, pinned over a long conversation once its first cards scrolled away: the story, the places
|
|
61
|
+
* and the cast. The compact previews lead back to their full cards in the conversation.
|
|
62
|
+
*/
|
|
63
|
+
export function WorldStrip({ slug, version, title, places, pending = [], castDone, stage, onStage }: { slug: string; version: number; title?: string; places: CardPlace[]; pending?: string[]; castDone: boolean; stage: StageView; onStage: (s: StageView) => void }) {
|
|
64
|
+
const cast = useCast(slug, version)?.filter((c) => c.figure) ?? [];
|
|
65
|
+
const goToCard = (button: HTMLButtonElement, kind: 'places' | 'cast') => {
|
|
66
|
+
const scroller = button.closest('.agent')?.querySelector<HTMLElement>('.gc-scroll');
|
|
67
|
+
const card = scroller?.querySelector<HTMLElement>(`[data-opening="${kind}"]`);
|
|
68
|
+
if (!scroller || !card) return;
|
|
69
|
+
const target = kind === 'places' ? card.querySelector<HTMLElement>('button') : card;
|
|
70
|
+
target?.focus({ preventScroll: true });
|
|
71
|
+
scroller.scrollTo({ top: scroller.scrollTop + card.getBoundingClientRect().top - scroller.getBoundingClientRect().top - 8, behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
|
|
72
|
+
};
|
|
73
|
+
const placesLabel = `${places.length} ${places.length === 1 ? 'place' : 'places'}${pending.length ? `, ${pending.length} building` : ''}. Show places in the conversation`;
|
|
74
|
+
return (
|
|
75
|
+
<div class="world-strip">
|
|
76
|
+
{title && <button type="button" class={`ws-story${stage.kind === 'story' ? ' on' : ''}`} title="The story: places, items, people and endings" onClick={() => onStage({ kind: 'story' })}>{title}</button>}
|
|
77
|
+
<button type="button" class="ws-places" title={placesLabel} aria-label={placesLabel} onClick={(e) => goToCard(e.currentTarget, 'places')}>
|
|
78
|
+
<span class="ws-stack" aria-hidden="true">
|
|
79
|
+
{places.slice(0, 3).map((p) => <span class="ws-sheet" key={p.key} style={p.image ? { backgroundImage: `url("${fileUrl(slug, p.image.file, p.image.v)}")` } : undefined} />)}
|
|
80
|
+
</span>
|
|
81
|
+
<span class="ws-count">{places.length}</span>
|
|
82
|
+
{pending.length > 0 && <span class="ws-building" aria-hidden="true">+{pending.length} ◌</span>}
|
|
83
|
+
</button>
|
|
84
|
+
{castDone && cast.length > 0 && (
|
|
85
|
+
<button type="button" class="ws-cast" title="Show the cast in the conversation" aria-label={`Show ${cast.length} ${cast.length === 1 ? 'character' : 'characters'} in the conversation`} onClick={(e) => goToCard(e.currentTarget, 'cast')}>
|
|
86
|
+
{cast.slice(0, 5).map((c) => <img src={fileUrl(slug, c.figure!.file, c.figure!.v)} alt="" />)}
|
|
87
|
+
{cast.length > 5 && <span class="ws-count">+{cast.length - 5}</span>}
|
|
88
|
+
</button>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function useCast(slug: string, version: number) {
|
|
95
|
+
const [cast, setCast] = useState<Character[] | null>(null);
|
|
96
|
+
useEffect(() => void call<Character[]>('cast_status', { slug }).then(setCast).catch(() => setCast([])), [slug, version]);
|
|
97
|
+
return cast;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type Box = [number, number, number, number];
|
|
101
|
+
type Masks = { things?: Record<string, { box?: Box; phrase?: string }> };
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The place as a framed painting. Every thing SAM cut out is a spot: hovering lights up its mask,
|
|
105
|
+
* a click opens it (its mask, kind, stand spot: by hand).
|
|
106
|
+
*/
|
|
107
|
+
export function PlaceStage({ slug, place, version, onOpen, children }: { slug: string; place: CardPlace; version: number; onOpen?: (thing: string) => void; children?: ComponentChildren }) {
|
|
108
|
+
const [masks, setMasks] = useState<Masks | null>(null);
|
|
109
|
+
const [hover, setHover] = useState<string | null>(null);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
const controller = new AbortController();
|
|
112
|
+
setMasks(null);
|
|
113
|
+
setHover(null);
|
|
114
|
+
fetch(fileUrl(slug, `places/${place.key}/masks.json`, Date.now()), { cache: 'no-store', signal: controller.signal }).then((r) => (r.ok ? r.json() : null)).then((m) => { if (!controller.signal.aborted) setMasks(m as Masks | null); }).catch(() => {});
|
|
115
|
+
return () => controller.abort();
|
|
116
|
+
}, [slug, place.key, version]);
|
|
117
|
+
// Big things first, so a small one on top of a big one gets the pointer.
|
|
118
|
+
const spots = Object.entries(masks?.things ?? {}).filter(([, t]) => t.box).sort(([, a], [, b]) => area(b.box!) - area(a.box!));
|
|
119
|
+
return (
|
|
120
|
+
<div class="mat">
|
|
121
|
+
<div class="frame">
|
|
122
|
+
<div class="pic">
|
|
123
|
+
{place.image ? <img src={fileUrl(slug, place.image.file, place.image.v)} alt={place.shows} /> : <div class="empty"><strong>{nice(place.key)}</strong><span>{named('Muse Image paints it next')}</span></div>}
|
|
124
|
+
{hover && (
|
|
125
|
+
<div class="glow" style={{ maskImage: `url("${fileUrl(slug, `places/${place.key}/masks/${hover}.png`)}")`, WebkitMaskImage: `url("${fileUrl(slug, `places/${place.key}/masks/${hover}.png`)}")` }} />
|
|
126
|
+
)}
|
|
127
|
+
{spots.map(([key, t]) => {
|
|
128
|
+
const [x0, y0, x1, y1] = t.box!;
|
|
129
|
+
return (
|
|
130
|
+
<button type="button" class="spot" aria-label={`Open the ${nice(key).toLowerCase()}`} title={`${nice(key)}: click to open it`}
|
|
131
|
+
style={{ left: `${(x0 / 1200) * 100}%`, top: `${(y0 / 896) * 100}%`, width: `${((x1 - x0) / 1200) * 100}%`, height: `${((y1 - y0) / 896) * 100}%` }}
|
|
132
|
+
onMouseEnter={() => setHover(key)} onMouseLeave={() => setHover(null)} onFocus={() => setHover(key)} onBlur={() => setHover(null)}
|
|
133
|
+
onClick={() => onOpen?.(key)}>
|
|
134
|
+
{hover === key && <span class="name">{nice(key).toLowerCase()}</span>}
|
|
135
|
+
</button>
|
|
136
|
+
);
|
|
137
|
+
})}
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
{children}
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const area = (b: Box) => (b[2] - b[0]) * (b[3] - b[1]);
|
|
145
|
+
|
|
146
|
+
/** The cast standing in a row and their items on a shelf; a click opens one. */
|
|
147
|
+
export function CastStage({ slug, version, onOpen }: { slug: string; version: number; onOpen?: (key: string) => void }) {
|
|
148
|
+
const cast = useCast(slug, version);
|
|
149
|
+
const [items, setItems] = useState<Item[]>([]);
|
|
150
|
+
useEffect(() => void call<Item[]>('icons_status', { slug }).then(setItems).catch(() => setItems([])), [slug, version]);
|
|
151
|
+
if (!cast) return <div class="mat"><p class="detail">Reading the cast…</p></div>;
|
|
152
|
+
const shown = cast.filter((c) => c.figure);
|
|
153
|
+
if (!shown.length) return <div class="mat"><p class="detail">Nobody is cast yet: the build paints them after the places.</p></div>;
|
|
154
|
+
return (
|
|
155
|
+
<div class="mat lineup">
|
|
156
|
+
{/* Any cast fits the stage: up to 6 a row, more rows when there are more, each figure smaller then. */}
|
|
157
|
+
<div class="figures" style={{ '--per-row': Math.min(shown.length, Math.ceil(shown.length / Math.ceil(shown.length / 6))), '--rows': Math.ceil(shown.length / 6) }}>
|
|
158
|
+
{shown.map((c) => (
|
|
159
|
+
<div class="who-at">
|
|
160
|
+
<button type="button" class="who" title={`${c.looksNow}. Click to open: looks, voice, 3D model and sprites`} onClick={() => onOpen?.(c.key)}>
|
|
161
|
+
<img src={fileUrl(slug, c.figure!.file, c.figure!.v)} alt="" />
|
|
162
|
+
<span>{nice(c.key)}{c.role === 'hero' ? ' · hero' : ''}</span>
|
|
163
|
+
</button>
|
|
164
|
+
</div>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
{/* The story's items on a shelf: what the hero picks up or is handed; a click opens one. */}
|
|
168
|
+
{items.length > 0 && (
|
|
169
|
+
<div class="shelf" aria-label="Items">
|
|
170
|
+
{items.map((it) => (
|
|
171
|
+
<button type="button" class="item" title={`${it.name}: ${it.from ? `picked up in ${nice(it.from.place)}` : 'handed over'}. Click to open its icon`} onClick={() => onOpen?.(`item-${it.key}`)}>
|
|
172
|
+
{it.icon ? <img src={fileUrl(slug, it.icon.file, it.icon.v)} alt="" /> : <span class="noicon">?</span>}
|
|
173
|
+
<span>{it.name}</span>
|
|
174
|
+
</button>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
type Item = { key: string; name: string; from: { place: string; thing: string } | null; icon: Pic };
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* What the person has open, sent ahead of each message so "this" means something to the agent; never shown.
|
|
185
|
+
* "[Looking at: …]" is the same from older conversations.
|
|
186
|
+
*/
|
|
187
|
+
export const screenPrefix = (on: string) => `[On screen: ${on}]\n`;
|
|
188
|
+
export const shownScreen = (text: string) => text.replace(/^\[(?:On screen|Looking at): [^\]]+\]\n?/, '');
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"allowImportingTsExtensions": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"types": ["node"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src", "test"]
|
|
13
|
+
}
|