@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,93 @@
|
|
|
1
|
+
// Other models through OpenRouter, for when a world should be made with something other than Meta's:
|
|
2
|
+
// Gemini image models in place of Muse Image, any chat model in place of Spark. OPENROUTER_API_KEY holds
|
|
3
|
+
// the key; in a cloud session the proxy supplies it. A Google key (GEMINI_API_KEY or GOOGLE_API_KEY) sends Gemini pictures straight to Google instead. Answers say what they cost.
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import type { Paid } from './memo.ts';
|
|
6
|
+
|
|
7
|
+
const URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
8
|
+
|
|
9
|
+
/** Short names for the image models worth trying, as OpenRouter lists them (2026-10-28). */
|
|
10
|
+
export const IMAGE_MODELS: Record<string, string> = {
|
|
11
|
+
'gemini-flash': 'google/gemini-3.1-flash-image',
|
|
12
|
+
'gemini-lite': 'google/gemini-3.1-flash-lite-image',
|
|
13
|
+
'nano-banana': 'google/gemini-nano-banana-2.1',
|
|
14
|
+
'gemini-pro': 'google/gemini-3-pro-image',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Part = { type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } };
|
|
18
|
+
type Answer = { choices?: { message?: { content?: string | null; images?: { image_url?: { url?: string } }[] } }[]; usage?: { cost?: number }; error?: { message?: string } };
|
|
19
|
+
|
|
20
|
+
async function post(label: string, body: Record<string, unknown>): Promise<Answer> {
|
|
21
|
+
for (let n = 1; ; n++) {
|
|
22
|
+
const res = await fetch(URL, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
signal: AbortSignal.timeout(240_000),
|
|
25
|
+
headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY ?? 'proxy'}`, 'Content-Type': 'application/json' },
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
});
|
|
28
|
+
if (res.ok) return (await res.json()) as Answer;
|
|
29
|
+
if (n === 4 || ![408, 429, 500, 502, 503, 504].includes(res.status)) throw new Error(`${label} (${body.model}): ${res.status} ${(await res.text()).slice(0, 300)}`);
|
|
30
|
+
await new Promise((r) => setTimeout(r, 1000 * 2 ** n));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const dataUrl = (image: Buffer) => `data:image/${image[0] === 0xff && image[1] === 0xd8 ? 'jpeg' : 'png'};base64,${image.toString('base64')}`;
|
|
35
|
+
const content = (prompt: string, images: Buffer[]): Part[] => [...images.map((i) => ({ type: 'image_url' as const, image_url: { url: dataUrl(i) } })), { type: 'text', text: prompt }];
|
|
36
|
+
|
|
37
|
+
/** A chat model's text answer to a prompt and images. */
|
|
38
|
+
export async function chat(label: string, model: string, prompt: string, images: Buffer[] = [], system?: string, schema?: Record<string, unknown>): Promise<Paid> {
|
|
39
|
+
const body = await post(label, {
|
|
40
|
+
model,
|
|
41
|
+
messages: [...(system ? [{ role: 'system', content: system }] : []), { role: 'user', content: content(prompt, images) }],
|
|
42
|
+
// Structured outputs: the model's answer is held to the shape, so it always parses.
|
|
43
|
+
...(schema ? { response_format: { type: 'json_schema', json_schema: { name: 'answer', strict: true, schema } } } : {}),
|
|
44
|
+
});
|
|
45
|
+
const text = body.choices?.[0]?.message?.content ?? '';
|
|
46
|
+
if (!text.trim()) throw new Error(`${label} (${model}): empty answer${body.error?.message ? `: ${body.error.message}` : ''}`);
|
|
47
|
+
return { data: Buffer.from(text, 'utf8'), usd: body.usage?.cost, via: model };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const RATIOS = ['1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9'];
|
|
51
|
+
|
|
52
|
+
const googleKey = () => process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
|
|
53
|
+
|
|
54
|
+
/** A Gemini picture straight from Google: the model id without OpenRouter's "google/". */
|
|
55
|
+
async function paintGoogle(label: string, model: string, prompt: string, ratio: string, images: Buffer[]): Promise<Buffer> {
|
|
56
|
+
const parts = [...images.map((i) => ({ inlineData: { mimeType: i[0] === 0xff && i[1] === 0xd8 ? 'image/jpeg' : 'image/png', data: i.toString('base64') } })), { text: prompt }];
|
|
57
|
+
for (let n = 1; ; n++) {
|
|
58
|
+
const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
signal: AbortSignal.timeout(240_000),
|
|
61
|
+
headers: { 'x-goog-api-key': googleKey()!, 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({ contents: [{ role: 'user', parts }], generationConfig: { responseModalities: ['IMAGE'], imageConfig: { aspectRatio: ratio } } }),
|
|
63
|
+
});
|
|
64
|
+
if (res.ok) {
|
|
65
|
+
const body = (await res.json()) as { candidates?: { content?: { parts?: { inlineData?: { data?: string } }[] } }[] };
|
|
66
|
+
const data = body.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data)?.inlineData?.data;
|
|
67
|
+
if (!data) throw new Error(`${label} (${model}): no image in the answer`);
|
|
68
|
+
return Buffer.from(data, 'base64');
|
|
69
|
+
}
|
|
70
|
+
if (n === 4 || ![408, 429, 500, 502, 503, 504].includes(res.status)) throw new Error(`${label} (${model}): ${res.status} ${(await res.text()).slice(0, 300)}`);
|
|
71
|
+
await new Promise((r) => setTimeout(r, 1000 * 2 ** n));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Paints (no images) or edits (with images) like Muse Image. Muse answers a size request about 1.43x larger
|
|
77
|
+
* (1712x1280 for 1200x896), so the picture is brought to that size: everything after reads it as a Muse answer.
|
|
78
|
+
*/
|
|
79
|
+
export async function paint(label: string, model: string, prompt: string, size: string, images: Buffer[] = []): Promise<Paid> {
|
|
80
|
+
const [w, h] = size.split('x').map(Number);
|
|
81
|
+
const ratio = RATIOS.reduce((best, r) => {
|
|
82
|
+
const [a, b] = r.split(':').map(Number);
|
|
83
|
+
const [c, d] = best.split(':').map(Number);
|
|
84
|
+
return Math.abs(Math.log(a / b / (w / h))) < Math.abs(Math.log(c / d / (w / h))) ? r : best;
|
|
85
|
+
});
|
|
86
|
+
const fit = (image: Buffer) => sharp(image).resize(Math.round((w * 1712) / 1200), Math.round((h * 1280) / 896), { fit: 'fill' }).png().toBuffer();
|
|
87
|
+
// Google's price is not in its answer; the budget counts the call, not its dollars.
|
|
88
|
+
if (model.startsWith('google/') && googleKey()) return { data: await fit(await paintGoogle(label, model.slice(7), prompt, ratio, images)), via: model };
|
|
89
|
+
const body = await post(label, { model, modalities: ['image', 'text'], image_config: { aspect_ratio: ratio }, messages: [{ role: 'user', content: content(prompt, images) }] });
|
|
90
|
+
const url = body.choices?.[0]?.message?.images?.[0]?.image_url?.url;
|
|
91
|
+
if (!url?.startsWith('data:')) throw new Error(`${label} (${model}): no image in the answer${body.error?.message ? `: ${body.error.message}` : ''}`);
|
|
92
|
+
return { data: await fit(Buffer.from(url.slice(url.indexOf(',') + 1), 'base64')), usd: body.usage?.cost, via: model };
|
|
93
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Meta Model API list prices (dev.meta.ai/docs/pricing-rate-limits, read 2026-10-28), so every call sent can say
|
|
2
|
+
// what it cost. Contributor tier: same Spark, Meta may train on the prompts and answers.
|
|
3
|
+
export const PRICES = {
|
|
4
|
+
/** Per image returned, generation or edit alike. */
|
|
5
|
+
museImage: 0.01,
|
|
6
|
+
/** Per image segmented. */
|
|
7
|
+
sam: 0.0025,
|
|
8
|
+
/** Per million tokens. */
|
|
9
|
+
spark: {
|
|
10
|
+
standard: { input: 1.25, cached: 0.15, output: 4.25 },
|
|
11
|
+
contributor: { input: 0.1, cached: 0.002, output: 0.2 },
|
|
12
|
+
},
|
|
13
|
+
/** Per hour of audio. */
|
|
14
|
+
voiceTranscribe: 0.18,
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export type SparkTier = keyof typeof PRICES.spark;
|
|
18
|
+
export type Usage = { input_tokens?: number; output_tokens?: number; input_tokens_details?: { cached_tokens?: number } };
|
|
19
|
+
|
|
20
|
+
export function sparkUsd(tier: SparkTier, usage: Usage | undefined): number | undefined {
|
|
21
|
+
if (!usage) return undefined;
|
|
22
|
+
const p = PRICES.spark[tier];
|
|
23
|
+
const cached = usage.input_tokens_details?.cached_tokens ?? 0;
|
|
24
|
+
return (((usage.input_tokens ?? 0) - cached) * p.input + cached * p.cached + (usage.output_tokens ?? 0) * p.output) / 1e6;
|
|
25
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// SAM 3.1: finds every instance of one concept in an image, or one object per box.
|
|
2
|
+
import { buildObjectPrompt, decodeMaskToRaster, parseImageStream, recordsOfKind, type ResponsesEvent } from '@meta-sam/parser';
|
|
3
|
+
import { emptyMask, imageSize, type Mask } from '../image/mask.ts';
|
|
4
|
+
import { metaPost } from './http.ts';
|
|
5
|
+
import { inputHash, type Memo } from './memo.ts';
|
|
6
|
+
import { PRICES } from './prices.ts';
|
|
7
|
+
|
|
8
|
+
export const SAM = 'sam-3.1';
|
|
9
|
+
|
|
10
|
+
/** Pixel box; right and bottom are exclusive, as in @meta-sam/parser. */
|
|
11
|
+
export type Box = { left: number; top: number; right: number; bottom: number };
|
|
12
|
+
export type Found = { id: string; box: Box; confidence?: number; mask: Mask };
|
|
13
|
+
/** At most this many boxes go in one request. */
|
|
14
|
+
export const SAM_MAX_BOXES = 16;
|
|
15
|
+
|
|
16
|
+
async function* serverEvents(res: Response): AsyncIterable<ResponsesEvent> {
|
|
17
|
+
const decoder = new TextDecoder();
|
|
18
|
+
let buffered = '';
|
|
19
|
+
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
|
|
20
|
+
buffered += decoder.decode(chunk, { stream: true });
|
|
21
|
+
let nl;
|
|
22
|
+
while ((nl = buffered.indexOf('\n')) >= 0) {
|
|
23
|
+
const line = buffered.slice(0, nl).trim();
|
|
24
|
+
buffered = buffered.slice(nl + 1);
|
|
25
|
+
const data = line.startsWith('data:') ? line.slice(5).trim() : '';
|
|
26
|
+
if (data && data !== '[DONE]') yield JSON.parse(data) as ResponsesEvent;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function* fromText(text: string): AsyncIterable<ResponsesEvent> {
|
|
32
|
+
const where = { item_id: 'memo', output_index: 0, content_index: 0 };
|
|
33
|
+
yield { type: 'response.output_text.delta', ...where, delta: text };
|
|
34
|
+
yield { type: 'response.output_text.done', ...where, text };
|
|
35
|
+
yield { type: 'response.completed' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function records(events: AsyncIterable<ResponsesEvent>) {
|
|
39
|
+
const stream = parseImageStream(events);
|
|
40
|
+
for await (const _snapshot of stream);
|
|
41
|
+
return stream.finalResult;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Turns SAM's output text into one full-size mask per instance found. */
|
|
45
|
+
export async function readSamOutput(text: string, width: number, height: number): Promise<Found[]> {
|
|
46
|
+
const { records: all } = await records(fromText(text));
|
|
47
|
+
return recordsOfKind(all, 'mask').map((r) => {
|
|
48
|
+
const raster = decodeMaskToRaster(r.mask);
|
|
49
|
+
const { left, top, right, bottom } = r.bounds;
|
|
50
|
+
const mask = emptyMask(width, height);
|
|
51
|
+
// The raster covers the bounds box at its own resolution.
|
|
52
|
+
for (let y = Math.max(top, 0); y < Math.min(bottom, height); y++) {
|
|
53
|
+
const ry = Math.floor(((y - top) / (bottom - top)) * r.mask.height);
|
|
54
|
+
for (let x = Math.max(left, 0); x < Math.min(right, width); x++) {
|
|
55
|
+
const rx = Math.floor(((x - left) / (right - left)) * r.mask.width);
|
|
56
|
+
mask.bits[y * width + x] = raster[ry * r.mask.width + rx] ? 1 : 0;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { id: r.objectId, box: { left, top, right, bottom }, confidence: r.confidence, mask };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Segments a PNG by a concept ("rowing boat") or by boxes. An empty list means nothing was found. */
|
|
64
|
+
export async function samFind(memo: Memo, { label, image, prompt }: { label: string; image: Buffer; prompt: string | Box[] }): Promise<Found[]> {
|
|
65
|
+
const { width, height } = await imageSize(image);
|
|
66
|
+
if (Array.isArray(prompt) && prompt.length > SAM_MAX_BOXES) throw new Error(`sam ${label}: at most ${SAM_MAX_BOXES} boxes per request`);
|
|
67
|
+
const text = typeof prompt === 'string'
|
|
68
|
+
? prompt
|
|
69
|
+
: buildObjectPrompt({ size: { width, height }, objects: prompt.map((box, id) => ({ id, prompts: [{ box }] })) });
|
|
70
|
+
const output = await memo.remember('sam', label, inputHash(SAM, text, image), 'txt', async () => {
|
|
71
|
+
const res = await metaPost('/responses', {
|
|
72
|
+
model: SAM,
|
|
73
|
+
input: [{ type: 'message', role: 'user', content: [
|
|
74
|
+
{ type: 'input_text', text },
|
|
75
|
+
{ type: 'input_image', image_url: `data:image/png;base64,${image.toString('base64')}` },
|
|
76
|
+
] }],
|
|
77
|
+
metadata: { include_confidence: 'true' },
|
|
78
|
+
stream: true,
|
|
79
|
+
}, 'text/event-stream');
|
|
80
|
+
return { data: Buffer.from((await records(serverEvents(res))).rawOutput, 'utf8'), usd: PRICES.sam };
|
|
81
|
+
});
|
|
82
|
+
return readSamOutput(output.toString('utf8'), width, height);
|
|
83
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// A zod shape as the JSON Schema that structured outputs (Anthropic output_config.format, OpenRouter
|
|
2
|
+
// response_format) hold a model's answer to: every object closed, no length or number limits (the API takes none;
|
|
3
|
+
// zod still checks them after), fields with a default asked for outright so few stay optional.
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
type Json = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
class Unsupported extends Error {}
|
|
9
|
+
|
|
10
|
+
function convert(t: z.ZodTypeAny): Json {
|
|
11
|
+
const def = t._def as { typeName: string } & Record<string, any>;
|
|
12
|
+
switch (def.typeName) {
|
|
13
|
+
case 'ZodString':
|
|
14
|
+
return { type: 'string' };
|
|
15
|
+
case 'ZodNumber':
|
|
16
|
+
return { type: (def.checks as { kind: string }[]).some((c) => c.kind === 'int') ? 'integer' : 'number' };
|
|
17
|
+
case 'ZodBoolean':
|
|
18
|
+
return { type: 'boolean' };
|
|
19
|
+
case 'ZodNull':
|
|
20
|
+
return { type: 'null' };
|
|
21
|
+
case 'ZodLiteral':
|
|
22
|
+
return { const: def.value };
|
|
23
|
+
case 'ZodEnum':
|
|
24
|
+
return { type: 'string', enum: def.values };
|
|
25
|
+
case 'ZodArray':
|
|
26
|
+
return { type: 'array', items: convert(def.type) };
|
|
27
|
+
case 'ZodObject': {
|
|
28
|
+
if (def.unknownKeys === 'passthrough' || def.catchall?._def.typeName !== 'ZodNever') throw new Unsupported('open object');
|
|
29
|
+
const shape = (t as z.AnyZodObject).shape as Record<string, z.ZodTypeAny>;
|
|
30
|
+
const required = Object.keys(shape).filter((k) => shape[k]._def.typeName !== 'ZodOptional');
|
|
31
|
+
return { type: 'object', properties: Object.fromEntries(Object.entries(shape).map(([k, v]) => [k, convert(v)])), required, additionalProperties: false };
|
|
32
|
+
}
|
|
33
|
+
case 'ZodUnion':
|
|
34
|
+
case 'ZodDiscriminatedUnion':
|
|
35
|
+
return { anyOf: [...(def.options as z.ZodTypeAny[])].map(convert) };
|
|
36
|
+
case 'ZodNullable':
|
|
37
|
+
return { anyOf: [convert(def.innerType), { type: 'null' }] };
|
|
38
|
+
case 'ZodOptional':
|
|
39
|
+
case 'ZodDefault':
|
|
40
|
+
case 'ZodCatch':
|
|
41
|
+
return convert(def.innerType);
|
|
42
|
+
case 'ZodEffects':
|
|
43
|
+
return convert(def.schema);
|
|
44
|
+
case 'ZodBranded':
|
|
45
|
+
return convert(def.type);
|
|
46
|
+
case 'ZodPipeline':
|
|
47
|
+
return convert(def.in);
|
|
48
|
+
default:
|
|
49
|
+
// Records, tuples, any: no closed shape to hold the model to.
|
|
50
|
+
throw new Unsupported(def.typeName);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The JSON Schema for a structured answer, or undefined when the shape cannot be held (records, tuples, any). */
|
|
55
|
+
export function answerSchema(t: z.ZodTypeAny): Json | undefined {
|
|
56
|
+
try {
|
|
57
|
+
const schema = convert(t);
|
|
58
|
+
return schema.type === 'object' ? schema : undefined;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error instanceof Unsupported) return undefined;
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Muse Spark: plans, briefs, locates things on images and judges them.
|
|
2
|
+
import { metaPost } from './http.ts';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { inputHash, type Memo, type Paid } from './memo.ts';
|
|
5
|
+
import { envValue } from '../secrets.ts';
|
|
6
|
+
import { sparkChoice } from './choice.ts';
|
|
7
|
+
import { chat } from './openrouter.ts';
|
|
8
|
+
import { sparkUsd, type SparkTier, type Usage } from './prices.ts';
|
|
9
|
+
import { answerSchema } from './schema.ts';
|
|
10
|
+
import type { ZodType } from 'zod';
|
|
11
|
+
|
|
12
|
+
export const SPARK = 'muse-spark-1.3';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Which Spark price tier a call goes to. DIORAMA_SPARK_TIER: "contributor" (Meta's cheaper tier, ~1/15 the price;
|
|
16
|
+
* Meta may train on the prompt and answer), "standard", or "auto" (the default): contributor, except calls that
|
|
17
|
+
* carry the person's own photos or drawings, which stay standard. Same model either way, so they share answers.
|
|
18
|
+
*/
|
|
19
|
+
export function sparkTier(personal = false): SparkTier {
|
|
20
|
+
const want = process.env.DIORAMA_SPARK_TIER ?? 'auto';
|
|
21
|
+
if (want === 'standard' || want === 'contributor') return want;
|
|
22
|
+
return personal ? 'standard' : 'contributor';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Which model answers Spark's asks (Studio Setup, or DIORAMA_SPARK_VIA): "meta" (the default), "claude" (Anthropic's API with
|
|
27
|
+
* DIORAMA_ANTHROPIC_API_KEY, model DIORAMA_CLAUDE_MODEL, default Sonnet 5.5) or "openrouter:<model id>".
|
|
28
|
+
* Another model's answers are kept under their own memo keys, so a Meta build never replays them.
|
|
29
|
+
*/
|
|
30
|
+
export function sparkVia(): string | null {
|
|
31
|
+
const via = sparkChoice();
|
|
32
|
+
if (!via || via === 'meta') return null;
|
|
33
|
+
return via === 'claude' ? `anthropic:${process.env.DIORAMA_CLAUDE_MODEL || 'claude-sonnet-5-5'}` : via;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function imageDataUrl(image: Buffer): string {
|
|
37
|
+
const jpeg = image[0] === 0xff && image[1] === 0xd8;
|
|
38
|
+
return `data:image/${jpeg ? 'jpeg' : 'png'};base64,${image.toString('base64')}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* personal: the images are the person's own photos or drawings (kept off the contributor tier by default).
|
|
43
|
+
* schema: the answer's shape. Claude (direct or via OpenRouter) is held to it by structured outputs, so its JSON
|
|
44
|
+
* always parses. The caller still checks the answer (zod limits, its own rules): structured outputs hold only the shape.
|
|
45
|
+
*/
|
|
46
|
+
export type SparkAsk<T = unknown> = { label: string; prompt: string; images?: Buffer[]; personal?: boolean; schema?: ZodType<T, any, any> };
|
|
47
|
+
|
|
48
|
+
const sparkHash = ({ prompt, images = [] }: SparkAsk) => inputHash(sparkVia()?.replace(/^anthropic:/, '') ?? SPARK, prompt, ...images);
|
|
49
|
+
|
|
50
|
+
async function askMeta({ label, prompt, images = [], personal }: SparkAsk): Promise<Paid> {
|
|
51
|
+
const tier = sparkTier(personal);
|
|
52
|
+
const input = images.length === 0
|
|
53
|
+
? prompt
|
|
54
|
+
: [{ role: 'user', content: [{ type: 'input_text', text: prompt }, ...images.map((i) => ({ type: 'input_image', image_url: imageDataUrl(i) }))] }];
|
|
55
|
+
const res = await metaPost('/responses', { model: tier === 'contributor' ? `${SPARK}-contributor` : SPARK, input });
|
|
56
|
+
const body = (await res.json()) as { output?: { content?: { type: string; text?: string }[] }[]; usage?: Usage };
|
|
57
|
+
const text = (body.output ?? []).flatMap((o) => o.content ?? []).filter((c) => c.type === 'output_text').map((c) => c.text ?? '').join('');
|
|
58
|
+
if (!text.trim()) throw new Error(`spark ${label}: empty answer`);
|
|
59
|
+
return { data: Buffer.from(text, 'utf8'), usd: sparkUsd(tier, body.usage), via: tier === 'contributor' ? 'contributor' : undefined };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Claude takes images up to 5 MB each; a big painting goes as a JPEG. */
|
|
63
|
+
async function claudeImage(image: Buffer) {
|
|
64
|
+
const jpeg = image[0] === 0xff && image[1] === 0xd8;
|
|
65
|
+
if (image.length < 3_500_000) return { type: 'base64', media_type: jpeg ? 'image/jpeg' : 'image/png', data: image.toString('base64') };
|
|
66
|
+
return { type: 'base64', media_type: 'image/jpeg', data: (await sharp(image).jpeg({ quality: 88 }).toBuffer()).toString('base64') };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Shapes the API would not compile into a grammar (the whole story is too large for it), asked plainly from then on:
|
|
71
|
+
* the JSON repair in jsonIn and askSparkJson's retries still hold those answers.
|
|
72
|
+
*/
|
|
73
|
+
const refused = new Set<string>();
|
|
74
|
+
const heldTo = (schema?: ZodType) => {
|
|
75
|
+
const format = schema && answerSchema(schema);
|
|
76
|
+
return format && !refused.has(JSON.stringify(format)) ? format : undefined;
|
|
77
|
+
};
|
|
78
|
+
function refuse(label: string, format: object, why: string) {
|
|
79
|
+
refused.add(JSON.stringify(format));
|
|
80
|
+
// Said in plain words on the progress card; the API's own message only says which limit.
|
|
81
|
+
const limit = /too large|too complex/i.test(why) ? 'the answer\'s shape is too big for it' : 'it did not accept the answer\'s shape';
|
|
82
|
+
console.log(` ${label}: Claude asked for plain JSON instead (${limit})`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function askOpenRouter(model: string, { label, prompt, images, schema }: SparkAsk): Promise<Paid> {
|
|
86
|
+
const format = heldTo(schema);
|
|
87
|
+
try {
|
|
88
|
+
return await chat(`spark ${label}`, model, prompt, images, CLAUDE_SYSTEM, format);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (!format || !/: 400 /.test(String(error))) throw error;
|
|
91
|
+
refuse(label, format, String(error));
|
|
92
|
+
return chat(`spark ${label}`, model, prompt, images, CLAUDE_SYSTEM);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Spark's prompts were written for Spark; this keeps another model's answers in the shape they expect. */
|
|
97
|
+
const CLAUDE_SYSTEM = 'You stand in for a vision-language model inside a game-making pipeline. When asked for JSON, reply with one valid JSON value and nothing else: no prose, no code fences. Inside JSON strings never use a plain double quote; for quotations in any language use the typographic quotes of that language (for Polish „ and ”) or single quotes. Keep the answer as short as the request allows.';
|
|
98
|
+
|
|
99
|
+
async function askClaude(model: string, { label, prompt, images = [], schema }: SparkAsk): Promise<Paid> {
|
|
100
|
+
const key = envValue('DIORAMA_ANTHROPIC_API_KEY');
|
|
101
|
+
if (!key) throw new Error('DIORAMA_SPARK_VIA=claude needs DIORAMA_ANTHROPIC_API_KEY, ANTHROPIC_API_KEY or CLAUDE_API_KEY; openrouter:anthropic/claude-sonnet-5.5 goes without');
|
|
102
|
+
const content = [...(await Promise.all(images.map(async (i) => ({ type: 'image', source: await claudeImage(i) })))), { type: 'text', text: prompt }];
|
|
103
|
+
let format = heldTo(schema);
|
|
104
|
+
for (let n = 1, dropped = 0; ; n++) {
|
|
105
|
+
let res: Response;
|
|
106
|
+
let body: { content?: { type: string; text?: string }[]; stop_reason?: string } | undefined;
|
|
107
|
+
try {
|
|
108
|
+
res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
signal: AbortSignal.timeout(240_000),
|
|
111
|
+
headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
model,
|
|
114
|
+
max_tokens: 32000,
|
|
115
|
+
system: CLAUDE_SYSTEM,
|
|
116
|
+
messages: [{ role: 'user', content }],
|
|
117
|
+
...(format ? { output_config: { format: { type: 'json_schema', schema: format } } } : {}),
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
if (res.ok) body = (await res.json()) as typeof body;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// A dropped or stalled connection ("fetch failed": a busy uplink, Wi-Fi gone) is asked again, like metaPost
|
|
123
|
+
// does for Meta, instead of failing the whole step: up to ~3 min of backoff in all.
|
|
124
|
+
if (++dropped >= 6) throw error;
|
|
125
|
+
console.log(` spark ${label} (claude): ${error instanceof Error && error.name === 'TimeoutError' ? 'no answer in 240 s' : error instanceof Error ? error.message.slice(0, 120) : error}; trying again (${dropped + 1}/6)`);
|
|
126
|
+
await new Promise((r) => setTimeout(r, Math.min(30_000, 2000 * 2 ** dropped)));
|
|
127
|
+
n--;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (body) {
|
|
131
|
+
if (body.stop_reason === 'max_tokens') throw new Error(`spark ${label} (claude): the answer ran past ${32000} tokens and was cut off`);
|
|
132
|
+
const text = (body.content ?? []).filter((c) => c.type === 'text').map((c) => c.text ?? '').join('');
|
|
133
|
+
if (!text.trim()) throw new Error(`spark ${label} (claude): empty answer`);
|
|
134
|
+
const usage = (body as { usage?: { input_tokens?: number; output_tokens?: number } }).usage;
|
|
135
|
+
// Paid from Anthropic credits, not Meta's: the budget counts it apart. Sonnet 5.5 list price.
|
|
136
|
+
return { data: Buffer.from(text, 'utf8'), usd: ((usage?.input_tokens ?? 0) * 2 + (usage?.output_tokens ?? 0) * 10) / 1e6, via: model };
|
|
137
|
+
}
|
|
138
|
+
if (res.status === 400 && format) {
|
|
139
|
+
refuse(label, format, await res.text());
|
|
140
|
+
format = undefined;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (n === 4 || ![429, 500, 502, 503, 529].includes(res.status)) throw new Error(`spark ${label} (claude): ${res.status} ${(await res.text()).slice(0, 300)}`);
|
|
144
|
+
await new Promise((r) => setTimeout(r, 1000 * 2 ** n));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function askSpark(memo: Memo, ask: SparkAsk): Promise<string> {
|
|
149
|
+
const via = sparkVia();
|
|
150
|
+
const answer = await memo.remember('spark', ask.label, sparkHash(ask), 'txt', () =>
|
|
151
|
+
!via ? askMeta(ask) : via.startsWith('anthropic:') ? askClaude(via.slice(10), ask) : askOpenRouter(via.replace(/^openrouter:/, ''), ask));
|
|
152
|
+
return answer.toString('utf8');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A bare `"` inside a string, escaped: models writing Polish or German quote „like this" close with a plain
|
|
157
|
+
* quote. A quote ends the string only when what follows can come after a string (`,` `:` `}` `]` or the end).
|
|
158
|
+
*/
|
|
159
|
+
export function escapeStrayQuotes(json: string): string {
|
|
160
|
+
let out = '';
|
|
161
|
+
let inString = false;
|
|
162
|
+
for (let i = 0; i < json.length; i++) {
|
|
163
|
+
const c = json[i];
|
|
164
|
+
if (inString && c === '\\') {
|
|
165
|
+
out += c + (json[++i] ?? '');
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (c === '"') {
|
|
169
|
+
if (!inString) inString = true;
|
|
170
|
+
else if (/^\s*([,:}\]]|$)/.test(json.slice(i + 1, i + 40))) inString = false;
|
|
171
|
+
else {
|
|
172
|
+
out += '\\"';
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// A raw line break inside a string is not JSON either.
|
|
177
|
+
out += inString && c === '\n' ? '\\n' : c;
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The first `{` to the last `}` of a reply, parsed. Spark often wraps JSON in prose or fences. */
|
|
183
|
+
export function jsonIn<T = unknown>(text: string): T {
|
|
184
|
+
const from = text.indexOf('{');
|
|
185
|
+
const to = text.lastIndexOf('}');
|
|
186
|
+
if (from < 0 || to <= from) throw new Error(`no JSON object in: ${text.slice(0, 160)}`);
|
|
187
|
+
const body = text.slice(from, to + 1);
|
|
188
|
+
try {
|
|
189
|
+
return JSON.parse(body) as T;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(escapeStrayQuotes(body)) as T;
|
|
193
|
+
} catch {
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Asks for JSON. A reply that is not JSON is forgotten, so the next run asks again instead of replaying it, and is
|
|
201
|
+
* asked again at once (twice more), told what broke: a long reply in Polish often quotes a word with bare double
|
|
202
|
+
* quotes inside a string, and one broken story should not stop the whole build.
|
|
203
|
+
*/
|
|
204
|
+
export async function askSparkJson<T = unknown>(memo: Memo, ask: SparkAsk<T>): Promise<T> {
|
|
205
|
+
let last: unknown;
|
|
206
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
207
|
+
const tried: SparkAsk<T> = attempt === 0 ? ask : {
|
|
208
|
+
...ask,
|
|
209
|
+
prompt: `${ask.prompt}\n\nYour previous answer was not valid JSON (${last instanceof Error ? last.message.slice(0, 120) : 'unreadable'}). Answer with valid JSON only: inside a string, never use a bare double quote (write \\" or use „…” or single quotes).`,
|
|
210
|
+
};
|
|
211
|
+
const text = await askSpark(memo, tried);
|
|
212
|
+
try {
|
|
213
|
+
return jsonIn<T>(text);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
memo.forget('spark', sparkHash(tried), 'txt');
|
|
216
|
+
last = error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
throw last;
|
|
220
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Which build step a model call belongs to. The build runs steps side by side, so "calls since the step began"
|
|
2
|
+
// counts other steps' calls too; each call is tagged with the step that asked it instead (memo/calls.jsonl `step`).
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
|
+
|
|
5
|
+
const current = new AsyncLocalStorage<string>();
|
|
6
|
+
|
|
7
|
+
/** Runs `work` with every model call inside it counted to `step`. */
|
|
8
|
+
export function inStep<T>(step: string, work: () => Promise<T>): Promise<T> {
|
|
9
|
+
return current.run(step, work);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function currentStep(): string | undefined {
|
|
13
|
+
return current.getStore();
|
|
14
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Meta Model API's file route: https://dev.meta.ai/docs/api-reference/voice/transcribe
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { apiKey, META_API, MetaHttpError } from './http.ts';
|
|
5
|
+
import type { SpeechOptions, SttProvider, Transcript } from '../speech/types.ts';
|
|
6
|
+
const MODEL = 'muse-voice-transcribe-1.0';
|
|
7
|
+
const ResponseBody = z.object({ sessionId: z.string(), transcript: z.string(), audioDurationMs: z.number().nonnegative(), turns: z.array(z.object({ turnId: z.number(), startMs: z.number().nonnegative(), endMs: z.number().nonnegative(), transcript: z.string(), speaker: z.string().optional() })) });
|
|
8
|
+
/** RIFF/WAVE is required by the file API. No ffmpeg conversion or lossy encoding is needed for PCM. */
|
|
9
|
+
export function wav24k(pcm: Buffer): Buffer {
|
|
10
|
+
const h = Buffer.alloc(44);
|
|
11
|
+
h.write('RIFF'); h.writeUInt32LE(pcm.length + 36, 4); h.write('WAVEfmt ', 8); h.writeUInt32LE(16, 16);
|
|
12
|
+
h.writeUInt16LE(1, 20); h.writeUInt16LE(1, 22); h.writeUInt32LE(24000, 24); h.writeUInt32LE(48000, 28);
|
|
13
|
+
h.writeUInt16LE(2, 32); h.writeUInt16LE(16, 34); h.write('data', 36); h.writeUInt32LE(pcm.length, 40);
|
|
14
|
+
return Buffer.concat([h, pcm]);
|
|
15
|
+
}
|
|
16
|
+
async function fileTranscribe(pcm: Buffer, options: SpeechOptions): Promise<Transcript> {
|
|
17
|
+
const seconds = pcm.length / 48000;
|
|
18
|
+
// A clipped utterance can otherwise be returned with empty text. Silence lets the model finish the last turn.
|
|
19
|
+
const padding = Math.min(1, 600 - seconds);
|
|
20
|
+
const wav = wav24k(Buffer.concat([pcm, Buffer.alloc(Math.floor(padding * 48000))])), sessionId = randomUUID(), key = apiKey();
|
|
21
|
+
const request = { model: MODEL, audioEncoding: 'WAV', mode: 'DIARIZATION', ...(options.languages?.length && { languageBias: options.languages }), ...(options.keywords?.length && { keywords: options.keywords }) };
|
|
22
|
+
let serverErrors = 0;
|
|
23
|
+
for (let attempt = 0; ; attempt++) {
|
|
24
|
+
try {
|
|
25
|
+
const body = new FormData();
|
|
26
|
+
body.set('request', new Blob([JSON.stringify(request)], { type: 'application/json' }));
|
|
27
|
+
body.set('audio', new Blob([new Uint8Array(wav)], { type: 'audio/wav' }), 'speech.wav');
|
|
28
|
+
const response = await fetch(`${META_API}/asr/transcribe?sessionId=${sessionId}`, { method: 'POST', headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' }, body, signal: AbortSignal.timeout(240000) });
|
|
29
|
+
if (!response.ok) throw new MetaHttpError(response.status, `Meta file STT: ${response.status} ${(await response.text()).slice(0, 300)}`);
|
|
30
|
+
const got = ResponseBody.parse(await response.json());
|
|
31
|
+
if (got.turns.some(t => t.endMs < t.startMs || t.endMs > (seconds + padding + .5) * 1000)) throw new z.ZodError([{ code: 'custom', path: ['turns'], message: 'STT timestamp lies outside its clip' }]);
|
|
32
|
+
const turns = got.turns.filter(t => t.transcript.trim() && t.startMs < seconds * 1000);
|
|
33
|
+
return { provider: 'meta', model: MODEL, text: got.transcript, timing: turns.length ? 'turn' : 'none', speakerScope: got.sessionId,
|
|
34
|
+
segments: turns.map(t => ({ start: t.startMs / 1000, end: Math.min(seconds, t.endMs / 1000), text: t.transcript, ...(t.speaker && { speaker: t.speaker }) })) };
|
|
35
|
+
} catch (error) {
|
|
36
|
+
// Retry server-budget errors once, and bounded rate-limit/network errors with backoff.
|
|
37
|
+
if (error instanceof z.ZodError || (error instanceof MetaHttpError && ![408, 429, 500, 502, 503, 504].includes(error.status))) throw error;
|
|
38
|
+
if (error instanceof MetaHttpError && error.status >= 500 && ++serverErrors > 1) throw error;
|
|
39
|
+
if (attempt >= 3) throw error;
|
|
40
|
+
await new Promise(r => setTimeout(r, 1000 * 2 ** attempt));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const QuickBody = z.object({ transcript: z.string() });
|
|
45
|
+
// A few file requests at a time: the realtime route takes the clip's own length and one stream per process, the file
|
|
46
|
+
// route answers a short line in 2-3 s and takes several at once.
|
|
47
|
+
const QUICK_LANES = 4;
|
|
48
|
+
let quickActive = 0;
|
|
49
|
+
const quickWaiting: Array<() => void> = [];
|
|
50
|
+
/**
|
|
51
|
+
* Plain transcript of one short clip on the file route (PUSH_TO_TALK: one speaker, no turns), for listening back to
|
|
52
|
+
* a voice take. It sometimes writes a word twice ("jest jest"), so callers confirm a mismatch on the realtime route.
|
|
53
|
+
*/
|
|
54
|
+
export async function quickTranscribe(pcm24k: Buffer): Promise<string> {
|
|
55
|
+
if (quickActive >= QUICK_LANES) await new Promise<void>((r) => quickWaiting.push(r));
|
|
56
|
+
quickActive++;
|
|
57
|
+
try {
|
|
58
|
+
const wav = wav24k(Buffer.concat([pcm24k, Buffer.alloc(48000)]));
|
|
59
|
+
const request = { model: MODEL, audioEncoding: 'WAV', mode: 'PUSH_TO_TALK' };
|
|
60
|
+
for (let attempt = 0; ; attempt++) {
|
|
61
|
+
try {
|
|
62
|
+
const body = new FormData();
|
|
63
|
+
body.set('request', new Blob([JSON.stringify(request)], { type: 'application/json' }));
|
|
64
|
+
body.set('audio', new Blob([new Uint8Array(wav)], { type: 'audio/wav' }), 'speech.wav');
|
|
65
|
+
const response = await fetch(`${META_API}/asr/transcribe?sessionId=${randomUUID()}`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey()}`, Accept: 'application/json' }, body, signal: AbortSignal.timeout(60000) });
|
|
66
|
+
if (!response.ok) throw new MetaHttpError(response.status, `Meta file STT: ${response.status} ${(await response.text()).slice(0, 300)}`);
|
|
67
|
+
return QuickBody.parse(await response.json()).transcript.trim();
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof z.ZodError || (error instanceof MetaHttpError && ![408, 429, 500, 502, 503, 504].includes(error.status)) || attempt >= 2) throw error;
|
|
70
|
+
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
quickActive--;
|
|
75
|
+
quickWaiting.shift()?.();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export const metaFileStt: SttProvider = { id: 'meta', model: MODEL, capabilities: { timing: 'turn', speakers: true, maxSeconds: 600 }, transcribe: fileTranscribe };
|