feat: Vercel Hobby deploy readiness — image URLs, jsonrepair, DeepSeek

- Move vercel.json to apps/web/ with correct route paths; cap scene route
  maxDuration 120→60s for Hobby. Root vercel.json removed. Vercel project's
  Root Directory must be set to apps/web (Deploy button URL passes this).
- Switch image transport from base64-in-JSON to Runware-hosted URLs:
  generateImage now uses outputType=URL and returns {imageUrl, imageUuid};
  StartResponse/SceneResponse carry imageUrl; VisionRequest carries
  prevImageUrl (server re-fetches the bytes for click annotation). This
  eliminates the 4.5MB serverless body-size risk.
- Painter and director prefer URL over UUID for referenceImages — the UUID
  returned by Runware imageInference isn't always recognized in the refs
  pipeline (surfaces as `failedToTransferImage`).
- Client preloads scene images via `new Image().decode()` before committing
  to React state, so URL transitions render instantly; prefetched scenes
  also warm the HTTP cache.
- jsonParser uses the jsonrepair package (replaces hand-rolled repair) and
  adds a targeted preRepair regex for the missing-key-close-quote pattern
  that jsonrepair couldn't disambiguate. Full raw model output dumped on
  failure for diagnostic visibility.
- Default text provider switched to DeepSeek v4-flash via direct API
  (significantly more stable JSON than MiMo v2.5-pro). VISION/TTS stay on
  MiMo (DeepSeek has no multimodal / TTS offerings).
- next.config: drop dead experimental.serverActions.bodySizeLimit (no
  server actions used).
- README: real Deploy button URL (zonghaoyuan/yume + root-directory=apps/web
  + TTS/MOCK_IMAGE in env list); refreshed env vars table with optional
  TTS section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuanzonghao
2026-06-01 16:04:13 +08:00
parent a426b82275
commit addbede929
21 changed files with 392 additions and 325 deletions
+20 -12
View File
@@ -37,21 +37,28 @@ There is no traditional game UI baked into the art. The AI paints the world in w
## One-click deploy
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/YOUR_USERNAME/yume&env=TEXT_BASE_URL,TEXT_API_KEY,TEXT_MODEL,IMAGE_BASE_URL,IMAGE_API_KEY,IMAGE_MODEL,VISION_BASE_URL,VISION_API_KEY,VISION_MODEL&envDescription=Three%20independently%20configurable%20providers.%20Any%20OpenAI-compatible%20endpoint%20works.&envLink=https://github.com/YOUR_USERNAME/yume%23environment-variables)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/zonghaoyuan/yume&root-directory=apps/web&env=TEXT_BASE_URL,TEXT_API_KEY,TEXT_MODEL,IMAGE_BASE_URL,IMAGE_API_KEY,IMAGE_MODEL,VISION_BASE_URL,VISION_API_KEY,VISION_MODEL,TTS_BASE_URL,TTS_API_KEY,TTS_SPEECH_MODEL,MOCK_IMAGE&envDescription=Three%20required%20providers%20%2B%20optional%20TTS.%20Any%20OpenAI-compatible%20endpoint%20works%20for%20text%2Fvision%2Ftts.&envLink=https://github.com/zonghaoyuan/yume%23environment-variables)
After deploy, set the nine environment variables (see below) in your Vercel project. That's it.
After deploy, set the environment variables (see below) in your Vercel project. Nine are required; TTS is optional (leave blank to run silently); `MOCK_IMAGE=true` skips image generation for cheap TTS-only testing. The Vercel project's **Root Directory** must be set to `apps/web` (the deploy button passes this; if you configure manually, set it in Project Settings).
---
## Environment variables
Three providers, all independently configurable. Text and Vision accept any OpenAI-compatible endpoint (OpenAI, Anthropic via OpenAI-compat proxy, Gemini, OpenRouter, DeepSeek, local Ollama, …). Image goes to **Runware** (its own task-array protocol, not OpenAI-compatible).
Three required providers + optional TTS. Text, Vision, and TTS accept any OpenAI-compatible endpoint (OpenAI, Anthropic via OpenAI-compat proxy, Gemini, OpenRouter, DeepSeek, local Ollama, …). Image goes to **Runware** (its own task-array protocol, not OpenAI-compatible).
| Provider | Variables | Recommended |
|---|---|---|
| Text · story director | `TEXT_BASE_URL` `TEXT_API_KEY` `TEXT_MODEL` | `claude-opus-4-7` via Anthropic |
| Image · UI renderer | `IMAGE_BASE_URL` `IMAGE_API_KEY` `IMAGE_MODEL` | `runware:400@6` (FLUX.2 [klein] 9B KV) via [Runware](https://runware.ai) |
| Vision · click reader | `VISION_BASE_URL` `VISION_API_KEY` `VISION_MODEL` | `gemini-3-flash` via Google |
| Provider | Variables | Required? | Recommended |
|---|---|---|---|
| Text · story director | `TEXT_BASE_URL` `TEXT_API_KEY` `TEXT_MODEL` | ✅ | `claude-opus-4-7` via Anthropic |
| Image · UI renderer | `IMAGE_BASE_URL` `IMAGE_API_KEY` `IMAGE_MODEL` | ✅ | `runware:400@6` (FLUX.2 [klein] 9B KV) via [Runware](https://runware.ai) |
| Vision · click reader | `VISION_BASE_URL` `VISION_API_KEY` `VISION_MODEL` | ✅ | `gemini-3-flash` via Google |
| TTS · per-character voice | `TTS_BASE_URL` `TTS_API_KEY` `TTS_SPEECH_MODEL` | optional — leave blank to run silently | `mimo-v2.5-tts` via Xiaomi MiMo |
There's also a flag for cheap testing:
| Variable | Effect |
|---|---|
| `MOCK_IMAGE=true` | Skip image generation; the renderer returns a static placeholder. Story, voice, and choices still run normally. Great for iterating on TTS without burning Runware credits. |
See `apps/web/.env.example` for the exact shape.
@@ -64,7 +71,7 @@ Requires Node 20+ and pnpm 9+.
```bash
pnpm install
cp apps/web/.env.example apps/web/.env.local
# fill in the nine env vars
# fill in env vars (9 required + optional TTS/MOCK_IMAGE)
pnpm dev
# open http://localhost:3000
```
@@ -75,11 +82,12 @@ pnpm dev
```
yume/
├── apps/web/ Next.js 16 app — pages + API routes
├── apps/web/ Next.js 16 app — pages + API routes (Vercel root)
└── packages/
├── types/ shared TypeScript types
├── ai-client/ unified OpenAI-compatible clients
── engine/ three-stage AI orchestration (open core)
├── ai-client/ unified OpenAI-compatible clients + Runware adapter
── tts-client/ Xiaomi MiMo TTS adapter
└── engine/ multi-agent AI orchestration (open core)
```
`packages/engine` is the open core — pure TS, no Next.js or browser dependency. Import it directly to build your own visual-novel front-end (Tauri, Electron, CLI, anywhere).
+13 -9
View File
@@ -12,12 +12,18 @@
# =============================================================
# ---- 1. Text LLM · scene director ----------------------------------
# Recommended: MiMo V2.5 Pro (1M context, native JSON-mode, strong CN)
# Token Plan host: https://token-plan-sgp.xiaomimimo.com/v1
# Pay-as-you-go host: https://api.xiaomimimo.com/v1 (sk- keys)
TEXT_BASE_URL=https://token-plan-sgp.xiaomimimo.com/v1
TEXT_API_KEY=tp-xxx
TEXT_MODEL=mimo-v2.5-pro
# Any OpenAI-compatible endpoint works: OpenAI, Anthropic (via proxy),
# Gemini, OpenRouter, DeepSeek, OpenCode, MiMo, local Ollama, …
# Recommended starters:
# A. DeepSeek v4-flash direct (https://api.deepseek.com/v1) — pay-as-you-go,
# fastest first-token latency, very stable JSON output.
# B. OpenCode Go (https://opencode.ai/zen/go/v1) — $10/mo flat-rate bundle of
# 12 open-source models (DeepSeek v4-flash, Qwen, Kimi, GLM, MiMo, …).
# Cheaper at high volume, slower at the tail.
# C. MiMo v2.5 via Xiaomi Token Plan — bundles VISION + TTS in one tp- key.
TEXT_BASE_URL=https://api.deepseek.com/v1
TEXT_API_KEY=sk-xxx
TEXT_MODEL=deepseek-v4-flash
# ---- 2. Image generator (renders the scene background) -------------
# Recommended: Runware + FLUX.2 [klein] 9B KV — distilled 4-step model,
@@ -30,9 +36,7 @@ IMAGE_API_KEY=runware-xxx
IMAGE_MODEL=runware:400@6
# ---- 3. Vision model · multimodal click interpretation -------------
# Recommended: MiMo V2.5 omni — multimodal.
# ⚠️ DO NOT use mimo-v2.5-pro for this slot — Pro is text-only and
# rejects image_url content parts.
# Recommended: MiMo V2.5 — multimodal, accepts image_url content parts.
VISION_BASE_URL=https://token-plan-sgp.xiaomimimo.com/v1
VISION_API_KEY=tp-xxx
VISION_MODEL=mimo-v2.5
+5 -1
View File
@@ -4,7 +4,11 @@ import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
export const runtime = "nodejs";
export const maxDuration = 120;
// Capped at 60 for Vercel Hobby (300 allowed on Pro). The scene pipeline is
// Writer + CharDesigner×N + Cinematographer + Painter — happy path 912s; the
// tail (cold provider, multiple new characters) can push 3045s, so 60 is a
// reasonable headroom on Hobby.
export const maxDuration = 60;
export async function POST(req: Request) {
let body: SceneRequest;
+2 -2
View File
@@ -14,9 +14,9 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.session || !body.prevImageBase64 || !body.click) {
if (!body.session || !body.prevImageUrl || !body.click) {
return NextResponse.json(
{ error: "session, prevImageBase64, click are required" },
{ error: "session, prevImageUrl, click are required" },
{ status: 400 },
);
}
+63 -8
View File
@@ -28,6 +28,42 @@ import type {
const MUTED_STORAGE_KEY = "yume:muted";
// Cap how long we wait for the browser to download + decode a scene image
// before giving up and rendering anyway. Runware's CDN is normally <2s for a
// 1792×1024 PNG; tolerate up to 8s before the typewriter starts so a slow
// download can't strand the player on a blank screen forever.
const IMAGE_PRELOAD_TIMEOUT_MS = 8000;
// ──────────────────────────────────────────────────────────────────────
// Image preload — decode the Runware URL in memory before committing to
// React state, so when the <img> mounts, the browser cache is warm and
// rendering is instant. Without this the user sees a blank canvas during
// the Runware-CDN download (~1-3s) after /api/scene returns.
//
// Data URIs (MOCK_IMAGE mode) and prefetched-then-cached real URLs both
// resolve fast / instantly. Errors and timeouts resolve quietly — better
// to render a broken-image than to hang the play loop indefinitely.
// ──────────────────────────────────────────────────────────────────────
function preloadImage(url: string): Promise<void> {
return new Promise<void>((resolve) => {
const img = new Image();
const done = () => resolve();
const timer = setTimeout(done, IMAGE_PRELOAD_TIMEOUT_MS);
img.onload = () => {
clearTimeout(timer);
// .decode() forces the bitmap to be fully decoded before we proceed —
// without it, a slow decode could still cause a flash on first paint.
img.decode().then(done, done);
};
img.onerror = () => {
clearTimeout(timer);
done();
};
img.src = url;
});
}
// ──────────────────────────────────────────────────────────────────────
// Prefetch pool — speculative SceneResponses keyed by choice path.
//
@@ -123,6 +159,12 @@ function prefetchScenePath(
}
const data = (await res.json()) as SceneResponse;
// Warm the browser's HTTP + image-decode cache for this URL so when the
// player eventually picks this choice and we render the <img>, it's
// instant. Don't await — let the bytes stream in the background; the
// transition path will await its own preloadImage() before committing.
void preloadImage(data.imageUrl);
// Recursive: if the resulting scene has exactly one change-scene exit,
// it is a must-pass node — prefetch its child too.
if (depth + 1 < PREFETCH_MAX_DEPTH) {
@@ -193,7 +235,7 @@ function PlayInner() {
const [session, setSession] = useState<Session | null>(null);
const [currentScene, setCurrentScene] = useState<Scene | null>(null);
const [currentBeatId, setCurrentBeatId] = useState<string | null>(null);
const [imageBase64, setImageBase64] = useState<string | null>(null);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [beatAudioMap, setBeatAudioMap] = useState<Record<string, BeatAudio>>({});
// Lazy-initialize from localStorage so PlayCanvas never mounts with the
// wrong muted value (an effect-based read would briefly let audio play
@@ -434,7 +476,12 @@ function PlayInner() {
}
return (await r.json()) as StartResponse;
})
.then((data) => {
.then(async (data) => {
// Decode the Runware image in memory before committing to state, so
// the <img> renders instantly when it mounts (same rationale as the
// performSceneTransition path).
await preloadImage(data.imageUrl);
const initial: Session = {
id: data.sessionId,
createdAt: Date.now(),
@@ -452,7 +499,7 @@ function PlayInner() {
setSession(initial);
setCurrentScene(data.scene);
setCurrentBeatId(data.scene.entryBeatId);
setImageBase64(data.imageBase64);
setImageUrl(data.imageUrl);
// beatAudioMap is populated lazily by the per-beat fetch effect once
// currentScene becomes non-null (see fetchBeatAudio).
setPhase("ready");
@@ -520,6 +567,14 @@ function PlayInner() {
const base = sessionRef.current;
if (!base) throw new Error("Session lost mid-transition");
// Wait for the browser to download + decode the Runware-hosted image
// BEFORE committing it to state, so the <img> renders instantly when it
// mounts. For prefetched scenes the preloadImage call inside
// prefetchScenePath has already warmed the cache, so this resolves
// almost immediately. For cold transitions we trade an extra ~1-3s of
// "transitioning" overlay for an image-pop-in-from-blank flash.
await preloadImage(result.imageUrl);
const closedHistory = base.history.map((h, i, arr) =>
i === arr.length - 1
? { ...h, visitedBeatIds: visitedForCurrent, exit }
@@ -540,7 +595,7 @@ function PlayInner() {
setSession(newSession);
setCurrentScene(result.scene);
setCurrentBeatId(result.scene.entryBeatId);
setImageBase64(result.imageBase64);
setImageUrl(result.imageUrl);
// beatAudioMap reset + per-beat fetches kicked off by the scene effect.
setLastExitLabel(exitLabel);
setPhase("ready");
@@ -607,7 +662,7 @@ function PlayInner() {
}
async function onBackgroundClick(click: { x: number; y: number }) {
if (phase !== "ready" || !session || !currentScene || !imageBase64) return;
if (phase !== "ready" || !session || !currentScene || !imageUrl) return;
setPhase("vision-thinking");
setPendingClick(click);
@@ -615,7 +670,7 @@ function PlayInner() {
const visionRes = await fetch("/api/vision", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session, prevImageBase64: imageBase64, click }),
body: JSON.stringify({ session, prevImageUrl: imageUrl, click }),
});
if (!visionRes.ok) {
const j = (await visionRes.json().catch(() => ({}))) as {
@@ -763,7 +818,7 @@ function PlayInner() {
return (
<div className="fixed inset-0 bg-black flex items-center justify-center z-50">
<PlayCanvas
imageBase64={imageBase64}
imageUrl={imageUrl}
audioBase64={audioBase64}
audioMime={audioMime}
muted={muted}
@@ -805,7 +860,7 @@ function PlayInner() {
<main className="flex-1 flex flex-col items-center justify-center px-4 md:px-8 py-6 md:py-10">
<PlayCanvas
imageBase64={imageBase64}
imageUrl={imageUrl}
audioBase64={audioBase64}
audioMime={audioMime}
muted={muted}
+7 -7
View File
@@ -159,7 +159,7 @@ function ChoiceButton({
// ── Main component ─────────────────────────────────────────────────────
export function PlayCanvas({
imageBase64,
imageUrl,
audioBase64,
audioMime,
muted,
@@ -171,7 +171,7 @@ export function PlayCanvas({
onSelectChoice,
fullViewport = false,
}: {
imageBase64: string | null;
imageUrl: string | null;
audioBase64: string | null;
audioMime: string | null;
muted: boolean;
@@ -271,7 +271,7 @@ export function PlayCanvas({
});
}
const interactive = phase === "ready" && !!imageBase64;
const interactive = phase === "ready" && !!imageUrl;
const dimmed = phase === "transitioning";
const sizeStyle = fullViewport
@@ -306,16 +306,16 @@ export function PlayCanvas({
/>
)}
{imageBase64 ? (
{imageUrl ? (
<div
className="relative inline-block"
style={{ boxShadow: fullViewport ? "none" : SHADOW }}
>
{/* Background image */}
{/* Background image — Runware CDN URL or data URI (mock mode) */}
<img
key={imageBase64.slice(-48)}
key={imageUrl.slice(-48)}
ref={imgRef}
src={`data:image/png;base64,${imageBase64}`}
src={imageUrl}
alt="Generated scene"
onClick={handleImageClick}
onLoad={(e) => {
-5
View File
@@ -14,11 +14,6 @@ const config: NextConfig = {
turbopack: {
root: path.join(__dirname, "..", ".."),
},
experimental: {
serverActions: {
bodySizeLimit: "10mb",
},
},
};
export default config;
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": "nextjs",
"functions": {
"app/api/start/route.ts": { "maxDuration": 60 },
"app/api/scene/route.ts": { "maxDuration": 60 },
"app/api/vision/route.ts": { "maxDuration": 60 },
"app/api/insert-beat/route.ts": { "maxDuration": 60 },
"app/api/beat-audio/route.ts": { "maxDuration": 30 }
}
}
+39 -83
View File
@@ -4,21 +4,23 @@ import { fetchWithRetry } from "./fetchWithRetry";
// Runware uses its own task-array protocol (not OpenAI-compatible).
// POST <baseUrl> with [{ taskType: "imageInference", ... }]; errors come
// back as a 200 with `errors[]`, so we have to inspect the body either way.
// FLUX img2img specifics:
// - strength < 0.8 has minimal-to-no visible effect on FLUX models (per
// Runware docs); we default to 0.85 which leaves room to deviate while
// still anchoring on the seed image's composition.
// - referenceImages caps at 4 per request; the FLUX.2 [klein] 9B KV model
// (runware:400@6) accelerates multi-reference inference by ~2.5× via its
// KV cache for reference latents (cached only WITHIN one inference run —
// not persisted across API calls, hence the upload-once-then-reference
// strategy below).
//
// referenceImages accepts UUIDs, public URLs, or base64. UUID is cheapest
// in transport cost; URL is next; base64 last resort. The FLUX.2 [klein] 9B
// KV variant (runware:400@6) accelerates multi-reference inference ~2.5× via
// its KV cache for reference latents (cached only within one inference run,
// not persisted across calls — hence the need to keep stable UUIDs/URLs for
// later reuse).
//
// We request outputType=URL so Runware persists the image and returns a CDN
// link the client can render directly. The same response also carries the
// image UUID, so we never need a separate uploadImage round-trip to anchor
// future referenceImages.
const DEFAULT_IMG2IMG_STRENGTH = 0.85;
const MAX_REFERENCE_IMAGES = 4;
type RunwareImageResult = {
imageBase64Data?: string;
imageURL?: string;
imageUUID?: string;
};
type RunwareError = {
@@ -33,32 +35,40 @@ type RunwareResponse = {
export type GenerateImageOptions = {
/**
* Reference image (UUID, plain base64, or data URI) to use as the
* img2img starting point. When set, FLUX preserves the seed image's
* composition and applies `strength` to allow deviation from it.
* Used for cross-scene visual continuity when sceneKey hits.
* Reference image (UUID, public URL, or base64) for img2img. When set,
* FLUX preserves the seed image's composition and applies `strength` to
* deviate. NOTE: FLUX.2 [klein] 9B KV does NOT support seedImage — use
* `referenceImages` for visual continuity instead.
*/
seedImage?: string;
/**
* Reference images (UUIDs or base64) to condition the generation on —
* typically character portraits to anchor identity / outfit / style
* across scenes. Runware caps at 4; we silently truncate beyond that.
* Reference images (UUIDs, URLs, or base64) to condition generation on —
* typically character portraits + the prior scene image. Runware caps at 4;
* we silently truncate beyond that.
*/
referenceImages?: string[];
/** 01, FLUX needs ≥ 0.8 to actually have an effect. */
strength?: number;
};
export type GenerateImageResult = {
/** Public CDN URL of the generated image (Runware-hosted). */
imageUrl: string;
/** Stable UUID for cheap re-reference in later `referenceImages`. */
imageUuid: string;
};
// ──────────────────────────────────────────────────────────────────────
// generateImage — text-to-image (default) or img2img / multi-reference
// when seedImage / referenceImages are supplied. Returns base64.
// generateImage — text-to-image (default) or referenceImages-conditioned.
// Returns both the public URL (for client display + future references)
// and the UUID (cheapest reference form for subsequent calls).
// ──────────────────────────────────────────────────────────────────────
export async function generateImage(
config: ProviderConfig,
prompt: string,
options?: GenerateImageOptions,
): Promise<string> {
): Promise<GenerateImageResult> {
const url = config.baseUrl.replace(/\/$/, "");
const task: Record<string, unknown> = {
@@ -71,8 +81,9 @@ export async function generateImage(
steps: 4,
CFGScale: 3.5,
numberResults: 1,
outputType: "base64Data",
outputType: "URL",
outputFormat: "PNG",
includeCost: false,
};
if (options?.seedImage) {
@@ -109,66 +120,11 @@ export async function generateImage(
);
}
const b64 = json.data?.[0]?.imageBase64Data;
if (!b64) {
throw new Error(`No image in Runware response: ${text.slice(0, 300)}`);
const result = json.data?.[0];
const imageUrl = result?.imageURL;
const imageUuid = result?.imageUUID;
if (!imageUrl || !imageUuid) {
throw new Error(`No image URL/UUID in Runware response: ${text.slice(0, 300)}`);
}
return b64;
}
// ──────────────────────────────────────────────────────────────────────
// uploadImage — registers a base64 image on Runware and returns its
// UUID, so subsequent generateImage calls can pass the UUID in
// referenceImages / seedImage instead of resending the base64 payload
// every time. Character base portraits and scene snapshots both flow
// through this path.
//
// Runware exposes the imageUpload taskType for exactly this purpose.
// Returns the UUID. Caller treats a thrown error as "fall back to
// sending base64 next time" — non-fatal.
// ──────────────────────────────────────────────────────────────────────
export async function uploadImage(
config: ProviderConfig,
base64: string,
): Promise<string> {
const url = config.baseUrl.replace(/\/$/, "");
const body = [
{
taskType: "imageUpload",
taskUUID: crypto.randomUUID(),
image: `data:image/png;base64,${base64}`,
},
];
const res = await fetchWithRetry(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify(body),
});
const text = await res.text();
let json: RunwareResponse;
try {
json = JSON.parse(text) as RunwareResponse;
} catch {
throw new Error(`Image upload API error ${res.status}: ${text.slice(0, 500)}`);
}
if (json.errors?.length) {
const e = json.errors[0]!;
throw new Error(
`Runware upload error [${e.code ?? "unknown"}]: ${e.message ?? "no message"}`,
);
}
const uuid = json.data?.[0]?.imageUUID;
if (!uuid) {
throw new Error(`No UUID in upload response: ${text.slice(0, 300)}`);
}
return uuid;
return { imageUrl, imageUuid };
}
+2 -2
View File
@@ -1,5 +1,5 @@
export { chat } from "./chat";
export { generateImage, uploadImage } from "./image";
export type { GenerateImageOptions } from "./image";
export { generateImage } from "./image";
export type { GenerateImageOptions, GenerateImageResult } from "./image";
export { interpretClick } from "./vision";
export type { ChatMessage } from "./chat";
+1
View File
@@ -15,6 +15,7 @@
"@yume/ai-client": "workspace:*",
"@yume/tts-client": "workspace:*",
"@yume/types": "workspace:*",
"jsonrepair": "^3.14.0",
"sharp": "^0.33.5"
}
}
+26 -44
View File
@@ -1,4 +1,4 @@
import { chat, generateImage, uploadImage } from "@yume/ai-client";
import { chat, generateImage } from "@yume/ai-client";
import { provisionVoice } from "@yume/tts-client";
import type {
Character,
@@ -7,7 +7,7 @@ import type {
Session,
} from "@yume/types";
import { parseJsonLoose } from "../jsonParser";
import { mockImageBase64 } from "../mockImage";
import { mockImageDataUri } from "../mockImage";
import {
CHARACTER_DESIGNER_SYSTEM,
buildCharacterDesignerUserMessage,
@@ -24,8 +24,8 @@ import {
// which keeps appearance and vocal personality coherent)
//
// 2. In parallel:
// a. Image gen — base portrait from visualDescription + styleGuide
// then upload to Runware → get UUID for cheap re-reference
// a. Image gen — base portrait (Runware returns URL + UUID in one shot;
// no separate upload round-trip is needed for cheap re-reference)
// b. Voice provisioning — Xiaomi MiMo voicedesign from voiceDescription
// → reference audio for later voiceclone synth
//
@@ -66,57 +66,39 @@ async function runDesignLLM(
return parseJsonLoose<CharacterDesignOutput>(raw);
}
// Generate the per-character base portrait and upload it. The portrait is
// a "concept sheet" — single character, neutral pose, plain background —
// so it works well as a Runware referenceImages anchor for later scenes.
// Generate the per-character base portrait. The portrait is a "concept
// sheet" — single character, neutral pose, plain background — so it works
// well as a Runware referenceImages anchor for later scenes.
//
// Returns both the base64 (for client-side asset use, e.g., 立绘登场
// animations) and the Runware UUID (for cheap referencing in subsequent
// Painter calls without resending the 100KB+ base64 each time).
// Returns the URL (for any client display + URL-form references) and the
// UUID (cheapest reference form for subsequent Painter calls). Both come
// back in one `imageInference` response now that we use outputType=URL —
// no separate upload step needed.
//
// The upload step is best-effort: if it fails, we still return the base64
// so the next scene can pass it as a referenceImages entry directly (just
// pays the bandwidth cost each call instead of once).
async function renderAndUploadPortrait(
// In mock mode we return the data URI as basePortraitUrl with no UUID
// (Painter is short-circuited anyway, so the lack of a UUID is moot).
async function renderPortrait(
config: EngineConfig,
charName: string,
visualDescription: string,
styleGuide: string,
): Promise<{ basePortraitBase64?: string; basePortraitUuid?: string }> {
let base64: string;
): Promise<{ basePortraitUrl?: string; basePortraitUuid?: string }> {
try {
if (config.mockImage) {
base64 = await mockImageBase64();
} else {
const prompt = buildCharacterPortraitPrompt(
charName,
visualDescription,
styleGuide,
);
base64 = await generateImage(config.image, prompt);
return { basePortraitUrl: await mockImageDataUri() };
}
const prompt = buildCharacterPortraitPrompt(
charName,
visualDescription,
styleGuide,
);
const { imageUrl, imageUuid } = await generateImage(config.image, prompt);
return { basePortraitUrl: imageUrl, basePortraitUuid: imageUuid };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[characterDesigner] portrait gen failed for ${charName}: ${msg}`);
return {}; // no portrait at all — degrade gracefully
}
// Skip upload in mock mode — the mock image is the same static SVG every
// time and uploading it gives us a UUID that points to a useless asset.
if (config.mockImage) {
return { basePortraitBase64: base64 };
}
try {
const uuid = await uploadImage(config.image, base64);
return { basePortraitBase64: base64, basePortraitUuid: uuid };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(
`[characterDesigner] portrait upload failed for ${charName}: ${msg} — will pass base64 in subsequent calls`,
);
return { basePortraitBase64: base64 };
}
}
async function provisionVoiceSafe(
@@ -157,8 +139,8 @@ export async function designCharacter(
// Step 2 — parallel: portrait + voice provisioning.
const tProvision = Date.now();
const portraitPromise = visualDescription
? renderAndUploadPortrait(config, charName, visualDescription, session.styleGuide)
: Promise.resolve({} as Awaited<ReturnType<typeof renderAndUploadPortrait>>);
? renderPortrait(config, charName, visualDescription, session.styleGuide)
: Promise.resolve({} as Awaited<ReturnType<typeof renderPortrait>>);
const voicePromise = provisionVoiceSafe(config, voiceDescription, charName);
const [portrait, voice] = await Promise.all([portraitPromise, voicePromise]);
@@ -170,7 +152,7 @@ export async function designCharacter(
name: charName,
voiceDescription,
visualDescription,
basePortraitBase64: portrait.basePortraitBase64,
basePortraitUrl: portrait.basePortraitUrl,
basePortraitUuid: portrait.basePortraitUuid,
voice,
};
+29 -11
View File
@@ -1,12 +1,12 @@
import { generateImage } from "@yume/ai-client";
import type { GenerateImageOptions } from "@yume/ai-client";
import type { GenerateImageOptions, GenerateImageResult } from "@yume/ai-client";
import type {
Beat,
Character,
EngineConfig,
ProviderConfig,
} from "@yume/types";
import { mockImageBase64 } from "../mockImage";
import { mockImageDataUri } from "../mockImage";
import { buildPainterPrompt } from "../prompts";
// ──────────────────────────────────────────────────────────────────────
@@ -24,6 +24,11 @@ import { buildPainterPrompt } from "../prompts";
// (most visually prominent)
// 3. Other on-stage NPCs' portraits — secondary characters in the frame
//
// References are sent as UUIDs (preferred — cheapest in transport) or URLs
// (fallback — still cheaper than base64). Base64 fallback was removed when
// generateImage switched to outputType=URL, which always returns both a UUID
// and a URL so we never lack a cheap reference handle.
//
// Failure handling — two-tier degradation:
// A. referenceImages call (preferred — full visual anchoring)
// B. pure text-to-image fallback (last resort if Runware refs API errors)
@@ -36,8 +41,8 @@ export type PainterInput = {
styleGuide: string;
onStageCharacters: Character[];
/**
* Prior scene's Runware UUID or base64. When set (= sceneKey hit a
* prior scene), it slots into referenceImages[0] for spatial continuity.
* Prior scene's Runware UUID or URL. When set (= sceneKey hit a prior
* scene), it slots into referenceImages[0] for spatial continuity.
* Capacity-wise this displaces ONE character portrait — slot is shared
* with character refs, capped at 4 total per Runware spec.
*/
@@ -67,10 +72,16 @@ export function collectReferenceImages(
}
// Slot 1+ — character portraits, speaker-first.
//
// Prefer URL over UUID: Runware's `imageInference` returns a UUID, but that
// UUID isn't always recognized by the `referenceImages` pipeline (the error
// surfaces as `failedToTransferImage`). The URL is Runware's own CDN link —
// they can always fetch it from their own infra. UUID is kept as a backstop
// for any edge case where URL is missing (e.g., legacy session state).
const speakerName = entryBeat?.speaker;
if (speakerName) {
const speaker = characters.find((c) => c.name === speakerName);
const ref = speaker?.basePortraitUuid ?? speaker?.basePortraitBase64;
const ref = speaker?.basePortraitUrl ?? speaker?.basePortraitUuid;
if (ref && refs.length < MAX_REFERENCE_IMAGES) {
refs.push(ref);
seen.add(speakerName);
@@ -81,7 +92,7 @@ export function collectReferenceImages(
if (refs.length >= MAX_REFERENCE_IMAGES) break;
if (seen.has(c.name)) continue;
const char = characters.find((x) => x.name === c.name);
const ref = char?.basePortraitUuid ?? char?.basePortraitBase64;
const ref = char?.basePortraitUrl ?? char?.basePortraitUuid;
if (ref) {
refs.push(ref);
seen.add(c.name);
@@ -96,7 +107,7 @@ async function tryGenerate(
prompt: string,
options: GenerateImageOptions,
label: string,
): Promise<string | null> {
): Promise<GenerateImageResult | null> {
try {
return await generateImage(config, prompt, options);
} catch (err) {
@@ -106,12 +117,18 @@ async function tryGenerate(
}
}
export type PainterResult =
| { kind: "real"; imageUrl: string; imageUuid: string }
| { kind: "mock"; imageUrl: string };
export async function runPainter(
config: EngineConfig,
input: PainterInput,
entryBeat: Beat | undefined,
): Promise<string> {
if (config.mockImage) return mockImageBase64();
): Promise<PainterResult> {
if (config.mockImage) {
return { kind: "mock", imageUrl: await mockImageDataUri() };
}
const prompt = buildPainterPrompt(
input.integratedPrompt,
@@ -135,11 +152,12 @@ export async function runPainter(
{ referenceImages: refs },
`referenceImages (${refs.length})`,
);
if (r) return r;
if (r) return { kind: "real", imageUrl: r.imageUrl, imageUuid: r.imageUuid };
}
// Tier B — pure text-to-image. Last resort, used when Tier A failed OR
// there are no references to send (first scene with no characters yet).
// Errors here propagate to the caller.
return generateImage(config.image, prompt);
const r = await generateImage(config.image, prompt);
return { kind: "real", imageUrl: r.imageUrl, imageUuid: r.imageUuid };
}
+36 -2
View File
@@ -1,10 +1,44 @@
import sharp from "sharp";
const FETCH_TIMEOUT_MS = 5000;
// Pull the bytes from an image URL or data URI into a Buffer suitable for
// sharp. Data URIs are decoded inline (no network); http(s) URLs are fetched
// with a short timeout — if Runware's CDN is slow we'd rather fail the vision
// step quickly than tie up a 60s Vercel function on a single image read.
async function loadImageBuffer(imageUrl: string): Promise<Buffer> {
if (imageUrl.startsWith("data:")) {
const comma = imageUrl.indexOf(",");
if (comma === -1) throw new Error("Malformed data URI in prevImageUrl");
const b64 = imageUrl.slice(comma + 1);
return Buffer.from(b64, "base64");
}
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(imageUrl, { signal: ctrl.signal });
if (!res.ok) {
throw new Error(
`Failed to fetch prevImageUrl (${res.status}): ${imageUrl.slice(0, 120)}`,
);
}
const arr = await res.arrayBuffer();
return Buffer.from(arr);
} finally {
clearTimeout(timer);
}
}
// Marks the player's click point on the scene image so the vision LLM can see
// WHERE they tapped. Output is base64 because the vision LLM is called over
// the OpenAI-compatible chat endpoint, which only accepts image_url data URIs
// — we can't hand it a Runware CDN URL directly.
export async function annotateClick(
imageBase64: string,
imageUrl: string,
click: { x: number; y: number },
): Promise<string> {
const buf = Buffer.from(imageBase64, "base64");
const buf = await loadImageBuffer(imageUrl);
const resized = await sharp(buf)
.resize({ width: 768, withoutEnlargement: true, fit: "inside" })
+19 -49
View File
@@ -1,4 +1,4 @@
import { chat, uploadImage } from "@yume/ai-client";
import { chat } from "@yume/ai-client";
import type {
Character,
EngineConfig,
@@ -29,7 +29,7 @@ import { INSERT_BEAT_SYSTEM, buildInsertBeatUserMessage } from "./prompts";
// │
// ├─ CharacterDesigner LLM × N (parallel per new char)
// │ │
// │ ├─ portrait gen + upload (parallel within agent)
// │ ├─ portrait gen (Runware returns URL + UUID in one call)
// │ └─ voice provisioning (parallel within agent)
// │
// ├─ Cinematographer LLM (parallel with all of the above)
@@ -37,13 +37,11 @@ import { INSERT_BEAT_SYSTEM, buildInsertBeatUserMessage } from "./prompts";
// └─ wait for all parallel branches
// │
// ▼
// Painter (FLUX referenceImages — two-tier degradation chain)
// Painter — generateImage with referenceImages (UUID/URL refs only;
// no base64 to upload, since outputType=URL gives both back)
// │
// ▼
// upload final scene image → Scene.imageUuid
// │
// ▼
// return { scene, sceneImageBase64, characters }
// return { scene, sceneImageUrl, characters }
//
// The Cinematographer intentionally does NOT depend on CharacterDesigner
// output — it only positions named characters in the frame, not their
@@ -80,7 +78,7 @@ export function mergeCharacters(
...u,
voice: u.voice ?? prev.voice,
visualDescription: u.visualDescription ?? prev.visualDescription,
basePortraitBase64: u.basePortraitBase64 ?? prev.basePortraitBase64,
basePortraitUrl: u.basePortraitUrl ?? prev.basePortraitUrl,
basePortraitUuid: u.basePortraitUuid ?? prev.basePortraitUuid,
voiceDescription: u.voiceDescription || prev.voiceDescription,
});
@@ -92,27 +90,22 @@ export function mergeCharacters(
// scene — used by the Painter as one of the `referenceImages` (NOT as a
// seedImage, because FLUX.2 [klein] 9B KV does not support seedImage).
//
// Returns the UUID if available (cheap reference, ~36 chars over the wire),
// else the base64 of the most recent matching scene's image. Returns
// undefined when no prior scene shares the current sceneKey.
// Prefer URL over UUID for the same reason painter.collectReferenceImages
// does: the UUID returned by `imageInference` isn't always recognized by
// Runware's `referenceImages` pipeline, surfacing as `failedToTransferImage`.
// The URL is Runware's own CDN link — they can always fetch it. UUID is kept
// as a backstop. Returns undefined when no prior scene shares the sceneKey.
function pickPriorSceneReference(
session: Session,
currentSceneKey: string | undefined,
priorImageBase64ByUuid: Map<string, string>,
): { priorSceneReference?: string; priorSceneKey?: string } {
if (!currentSceneKey) return {};
for (let i = session.history.length - 1; i >= 0; i--) {
const prior = session.history[i]!.scene;
if (prior.sceneKey === currentSceneKey) {
if (prior.imageUuid) {
return {
priorSceneReference: prior.imageUuid,
priorSceneKey: prior.sceneKey,
};
}
const cached = priorImageBase64ByUuid.get(prior.id);
if (cached) {
return { priorSceneReference: cached, priorSceneKey: prior.sceneKey };
const ref = prior.imageUrl ?? prior.imageUuid;
if (ref) {
return { priorSceneReference: ref, priorSceneKey: prior.sceneKey };
}
}
}
@@ -121,25 +114,18 @@ function pickPriorSceneReference(
export type SceneResult = {
scene: Scene;
sceneImageBase64: string;
sceneImageUrl: string;
characters: Character[];
};
// ──────────────────────────────────────────────────────────────────────
// directScene — the multi-agent pipeline. Used by orchestrator's
// startSession and requestScene.
//
// priorImageBase64ByUuid: optional map from prior Scene.id → base64
// the caller has on-hand. If a sceneKey-hit scene's imageUuid is missing
// but the base64 is cached locally, we can still feed it as one of the
// Painter's referenceImages. Pass an empty map when caller has no cache
// (orchestrator does pass it for the start-session bootstrap).
// ──────────────────────────────────────────────────────────────────────
export async function directScene(
config: EngineConfig,
session: Session,
priorImageBase64ByUuid: Map<string, string> = new Map(),
): Promise<SceneResult> {
const tTotal = Date.now();
@@ -168,7 +154,6 @@ export async function directScene(
const { priorSceneReference, priorSceneKey } = pickPriorSceneReference(
session,
writerOut.sceneKey,
priorImageBase64ByUuid,
);
// Stage 2 — parallel: CharacterDesigner(s) and Cinematographer.
@@ -237,7 +222,7 @@ export async function directScene(
);
const tPainter = Date.now();
const sceneImageBase64 = await runPainter(
const painted = await runPainter(
config,
{
integratedPrompt: cinemaOut.integratedPrompt,
@@ -249,22 +234,6 @@ export async function directScene(
);
tlog("[directScene] Painter", tPainter);
// Stage 4 — best-effort upload of the final scene image so the NEXT
// sceneKey-match call can reference its UUID instead of carrying base64.
// If upload fails, the scene still works; only loses cheap referencing
// on the next hop. Don't wait on mock images (static placeholder).
let imageUuid: string | undefined;
if (!config.mockImage) {
try {
const tUpload = Date.now();
imageUuid = await uploadImage(config.image, sceneImageBase64);
tlog("[directScene] image upload", tUpload);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[directScene] scene image upload failed: ${msg} — sceneKey reuse will need base64 fallback`);
}
}
const scene: Scene = {
id: newSceneId(),
// scenePrompt is the cinematographer's English compositional output;
@@ -276,12 +245,13 @@ export async function directScene(
beats: writerOut.beats,
entryBeatId: writerOut.entryBeatId,
sceneKey: writerOut.sceneKey,
imageUuid,
imageUuid: painted.kind === "real" ? painted.imageUuid : undefined,
imageUrl: painted.imageUrl,
};
tlog("[directScene] TOTAL", tTotal);
return { scene, sceneImageBase64, characters };
return { scene, sceneImageUrl: painted.imageUrl, characters };
}
// ──────────────────────────────────────────────────────────────────────
+64 -51
View File
@@ -1,13 +1,44 @@
import { jsonrepair, JSONRepairError } from "jsonrepair";
// Strict-then-forgiving JSON parser for LLM output. Tries in order:
// 1. Direct JSON.parse on the trimmed text.
// 2. Extract from ```json``` fenced block.
// 3. Slice between first { and last } and parse.
// 4. Apply best-effort regex repair (trailing commas, missing commas
// between adjacent values) and try again.
// 4. Apply targeted regex pre-repairs (see preRepair) and try jsonrepair.
//
// On final failure, logs the first 800 chars of the raw model output so we
// can see what the LLM did wrong (the standard error message only shows
// the position, not the surrounding context).
// On final failure, logs the FULL raw model output so we can diagnose the
// actual syntax error.
//
// jsonrepair (npm package josdejong/jsonrepair — 2.3k+ stars) handles the
// broad LLM-output failure modes: truncated JSON, missing commas/brackets,
// single quotes, Python None/True/False, JS comments. We layer a small set
// of targeted pre-repairs in front of it for failure modes jsonrepair can't
// disambiguate on its own (see preRepair).
// ──────────────────────────────────────────────────────────────────────
// preRepair — fix specific LLM error patterns before handing to jsonrepair.
//
// Pattern 1: missing closing quote on a key.
// Broken: "lineDelivery: "语速稍快...",
// Correct: "lineDelivery": "语速稍快...",
//
// jsonrepair fails on this because it's ambiguous — "lineDelivery: " could
// be a complete string value, leaving "语速稍快..." as a syntax error. But
// if we see "<key-like>:<whitespace>" we know structurally it should be
// a key-colon-value triplet.
//
// Match constraints:
// - The key match excludes " \n : so we can't overrun into adjacent
// fields or absorb the colon as part of the key name.
// - The colon must be followed by whitespace and another " (the value
// string's opening quote). This is what disambiguates from a value
// string that happens to contain a colon.
// ──────────────────────────────────────────────────────────────────────
function preRepair(s: string): string {
return s.replace(/"([^"\n:]+):(\s+)"/g, '"$1":$2"');
}
export function parseJsonLoose<T>(raw: string): T {
const trimmed = raw.trim();
@@ -28,54 +59,36 @@ export function parseJsonLoose<T>(raw: string): T {
const first = trimmed.indexOf("{");
const last = trimmed.lastIndexOf("}");
if (first !== -1 && last > first) {
const slice = trimmed.slice(first, last + 1);
try {
return JSON.parse(slice) as T;
} catch {
// Last resort: try repairing common LLM-output malformations.
const repaired = repairJsonString(slice);
const slice =
first !== -1 && last > first ? trimmed.slice(first, last + 1) : trimmed;
// Try the brace-sliced version first; if there were no braces at all
// (slice === trimmed), this is just a second attempt at the raw text.
try {
return JSON.parse(slice) as T;
} catch {
// Targeted pre-repair (no-op on already-valid JSON) → jsonrepair.
const prefixed = preRepair(slice);
// If preRepair changed something, give the cheap path another shot —
// the input might already be valid now without needing jsonrepair.
if (prefixed !== slice) {
try {
return JSON.parse(repaired) as T;
} catch (err) {
console.error(
`[parseJsonLoose] all strategies failed. Raw output (first 800 chars):\n${raw.slice(0, 800)}`,
);
throw err;
return JSON.parse(prefixed) as T;
} catch {
// fall through to jsonrepair
}
}
try {
const repaired = jsonrepair(prefixed);
return JSON.parse(repaired) as T;
} catch (err) {
const isRepairErr = err instanceof JSONRepairError;
console.error(
`[parseJsonLoose] jsonrepair ${isRepairErr ? "could not repair" : "succeeded but JSON.parse rejected its output"}. Full raw model output:\n${raw}`,
);
throw err;
}
}
console.error(
`[parseJsonLoose] no { ... } found. Raw output (first 800 chars):\n${raw.slice(0, 800)}`,
);
throw new Error(`Failed to parse JSON from model output: ${raw.slice(0, 200)}`);
}
// Best-effort repair of LLM-typical JSON syntax errors. Targeted at the two
// most common failures we see in practice:
// 1. Trailing comma before } or ].
// 2. Missing comma between two adjacent JSON values (the specific error
// mode we hit at position 3390).
//
// Deliberately conservative — does NOT try to fix unclosed strings,
// unbalanced braces, or strip JS-style comments. The comment-stripping
// path was previously included but would corrupt JSON string values
// containing `//` (e.g. URLs like "https://example.com"); since LLMs in
// `responseFormat: "json_object"` mode essentially never emit comments,
// dropping that step is a net win for safety.
function repairJsonString(s: string): string {
return s
// 1. Strip trailing commas before } or ].
.replace(/,(\s*[}\]])/g, "$1")
// 2. Insert missing commas between two adjacent JSON values. The cases:
// } { → },{ ] [ → ],[ } [ → },[ ] { → ],{
// "string" "key" "string" { "string" [
// number then "key" / { / [
//
// The regex looks for a closing token (} ] " or a digit) followed by
// a newline and an opening token (} ] " a letter), and inserts a
// comma between them. Requires the newline (\s*\n\s*) so it only
// fires across line boundaries, never within a single-line value.
.replace(/(\}|\]|"|\d)(\s*\n\s*)(\{|\[|")/g, "$1,$2$3");
}
+9 -5
View File
@@ -1,11 +1,15 @@
import sharp from "sharp";
let cached: string | undefined;
let cachedDataUri: string | undefined;
// A static 16:9 placeholder used when MOCK_IMAGE=true, so we can exercise the
// TTS path without paying for image generation. Generated once, then memoized.
export async function mockImageBase64(): Promise<string> {
if (cached) return cached;
// Returned as a data URI so the rest of the pipeline can treat it as an
// `imageUrl` interchangeably with real Runware URLs (the client's <img src>
// accepts both, and we never feed a mock image to Runware's referenceImages
// because mockImage mode short-circuits the Painter entirely).
export async function mockImageDataUri(): Promise<string> {
if (cachedDataUri) return cachedDataUri;
const W = 1792;
const H = 1024;
@@ -20,6 +24,6 @@ export async function mockImageBase64(): Promise<string> {
</svg>`;
const png = await sharp(Buffer.from(svg)).png().toBuffer();
cached = png.toString("base64");
return cached;
cachedDataUri = `data:image/png;base64,${png.toString("base64")}`;
return cachedDataUri;
}
+5 -5
View File
@@ -49,14 +49,14 @@ export async function startSession(
characters: [],
};
const { scene, sceneImageBase64, characters } = await directScene(config, session);
const { scene, sceneImageUrl, characters } = await directScene(config, session);
tlog("[start] TOTAL", tTotal);
return {
sessionId: session.id,
scene,
imageBase64: sceneImageBase64,
imageUrl: sceneImageUrl,
characters,
};
}
@@ -71,7 +71,7 @@ export async function requestScene(
): Promise<SceneResponse> {
const tTotal = Date.now();
const { scene, sceneImageBase64, characters } = await directScene(
const { scene, sceneImageUrl, characters } = await directScene(
config,
req.session,
);
@@ -80,7 +80,7 @@ export async function requestScene(
return {
scene,
imageBase64: sceneImageBase64,
imageUrl: sceneImageUrl,
characters,
};
}
@@ -95,7 +95,7 @@ export async function visionDecide(
config: EngineConfig,
req: VisionRequest,
): Promise<VisionResponse> {
const annotated = await annotateClick(req.prevImageBase64, req.click);
const annotated = await annotateClick(req.prevImageUrl, req.click);
const current = req.session.history.at(-1)?.scene ?? null;
return interpret(config.vision, annotated, current);
}
+32 -18
View File
@@ -56,17 +56,24 @@ export type Scene = {
* e.g. "classroom-dusk", "rooftop-night". When the next Scene shares this
* key, the Painter slots the previous Scene's image into Runware's
* `referenceImages` (alongside character portraits) so the same physical
* space stays visually consistent across cuts. (Originally planned as a
* seedImage / img2img anchor, but FLUX.2 [klein] 9B KV does not support
* seedImage — referenceImages serves the same purpose with the model.)
* space stays visually consistent across cuts.
*/
sceneKey?: string;
/**
* Runware UUID of this Scene's generated image — once uploaded, subsequent
* Scenes that match sceneKey can reference it via `referenceImages`
* without resending base64.
* Runware UUID of this Scene's generated image. Cheapest form to send back
* to Runware's `referenceImages` in subsequent calls (UUID > URL > base64
* in transport cost). Not shown to the client — `imageUrl` is what renders.
*/
imageUuid?: string;
/**
* Public CDN URL of this Scene's generated image. Returned to the client for
* `<img src>` rendering, and is what the client passes back to `/api/vision`
* as `prevImageUrl` so the server can re-fetch the bytes for click annotation.
*
* For MOCK_IMAGE=true this is a `data:image/png;base64,...` data URI, not a
* Runware URL — the client renders both forms transparently.
*/
imageUrl?: string;
};
export type SceneExit =
@@ -111,17 +118,17 @@ export type Character = {
*/
visualDescription?: string;
/**
* Base portrait image generated by the CharacterDesigner once, then reused
* as a Runware `referenceImages` entry in every subsequent scene the
* character appears in. Stored as base64 for client display.
*/
basePortraitBase64?: string;
/**
* Runware UUID for the base portrait. Once uploaded via the image-upload
* endpoint, subsequent Painter calls reference this UUID instead of
* resending the full base64 payload.
* Runware UUID for the base portrait. Generated by the CharacterDesigner
* once, reused as a `referenceImages` entry on every subsequent scene the
* character appears in. UUID is the cheapest reference form for Runware.
*/
basePortraitUuid?: string;
/**
* Public CDN URL for the base portrait. Same image as `basePortraitUuid`;
* kept around for the client (if it ever wants to render character cards)
* and as a fallback reference form for `referenceImages` when UUID is absent.
*/
basePortraitUrl?: string;
/** Xiaomi MiMo voice reference audio. */
voice?: CharacterVoice;
};
@@ -196,7 +203,8 @@ export type StartRequest = {
export type StartResponse = {
sessionId: string;
scene: Scene;
imageBase64: string;
/** Public CDN URL (or data URI in MOCK_IMAGE mode) for the rendered scene background. */
imageUrl: string;
/** Character registry with voice references + visual cards provisioned. */
characters: Character[];
};
@@ -210,7 +218,8 @@ export type SceneRequest = {
export type SceneResponse = {
scene: Scene;
imageBase64: string;
/** Public CDN URL (or data URI in MOCK_IMAGE mode) for the rendered scene background. */
imageUrl: string;
characters: Character[];
};
@@ -235,7 +244,12 @@ export type BeatAudioResponse = {
// trigger a scene change.
export type VisionRequest = {
session: Session;
prevImageBase64: string;
/**
* Public CDN URL (or data URI in MOCK_IMAGE mode) of the scene the player
* just clicked. The server re-fetches the bytes to annotate the click and
* pass an OpenAI-compatible image_url to the vision LLM.
*/
prevImageUrl: string;
click: { x: number; y: number };
};
+9
View File
@@ -75,6 +75,9 @@ importers:
'@yume/types':
specifier: workspace:*
version: link:../types
jsonrepair:
specifier: ^3.14.0
version: 3.14.0
sharp:
specifier: ^0.33.5
version: 0.33.5
@@ -594,6 +597,10 @@ packages:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true
jsonrepair@3.14.0:
resolution: {integrity: sha512-tWPGKMZf/8UPim+fcW2EfcQ/d/7aKUrP6IECz9G3Tu6Q5dX0orSleqJ9z6sSw7qrQkjF8/Edo4DvsWBZ8H+HNg==}
hasBin: true
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@@ -1240,6 +1247,8 @@ snapshots:
jiti@1.21.7: {}
jsonrepair@3.14.0: {}
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
-11
View File
@@ -1,11 +0,0 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": "nextjs",
"buildCommand": "pnpm build",
"installCommand": "pnpm install",
"functions": {
"apps/web/app/api/interact/route.ts": { "maxDuration": 60 },
"apps/web/app/api/vision/route.ts": { "maxDuration": 60 },
"apps/web/app/api/start/route.ts": { "maxDuration": 60 }
}
}