// studio-api.mjs // Slate surfaces for every Vivijure studio API route (docs/CONTRACT.md). Each action maps to one // HTTP call or a brief-aware wrapper. Dispatch via !api [args]. import * as studio from './studio.mjs'; import { CONTRACT_ROUTES, STUDIO_API_ROUTE_COUNT } from './contract.mjs'; import { buildCastLoras, buildCharacterRefs, buildStoryboardPayload, buildFilmTitles, formatCastRoster, formatPreflightResult, mapModuleOverridesToFilmConfigs, applySubtitleToFilmFinish, } from './lib.mjs'; /** Parse `key:value` pairs or JSON into an object. Manual scan (no polyredos regex). */ export function parseApiArgs(raw) { const s = (raw ?? '').trim(); if (!s) return {}; if (s.startsWith('{')) { return JSON.parse(s); } const out = {}; let i = 0; const isWs = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r'; const isKey = (c) => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c === '_'; while (i < s.length) { while (i < s.length && isWs(s[i])) i++; if (i >= s.length) break; const keyStart = i; while (i < s.length && isKey(s[i])) i++; if (i === keyStart || s[i] !== ':') { throw new Error('args must be JSON or key:value pairs (e.g. name:Wren bible:"a pilot")'); } const key = s.slice(keyStart, i); i++; // skip : let val = ''; if (s[i] === '"' || s[i] === "'") { const q = s[i++]; const vStart = i; while (i < s.length && s[i] !== q) i++; val = s.slice(vStart, i); if (s[i] === q) i++; } else { const vStart = i; while (i < s.length && !isWs(s[i])) i++; val = s.slice(vStart, i); } out[key] = val; } if (Object.keys(out).length) return out; throw new Error('args must be JSON or key:value pairs (e.g. name:Wren bible:"a pilot")'); } export function formatApiResult(res) { if (!res) return '(no response)'; if (!res.ok) { const err = res.data?.error || res.raw || `HTTP ${res.status}`; return `**Error ${res.status}:** ${String(err).slice(0, 1800)}`; } const d = res.data; if (typeof d === 'string') { return d.length > 1900 ? d.slice(0, 1900) + '…' : d; } return '```json\n' + JSON.stringify(d, null, 2).slice(0, 1850) + '\n```'; } async function briefStoryboard(ctx) { const catalog = await ctx.fetchCastCatalog(); const refs = buildCharacterRefs(ctx.brief, catalog); return buildStoryboardPayload(ctx.brief, refs); } async function briefBundleKey(ctx) { const catalog = await ctx.fetchCastCatalog(); const characterRefs = buildCharacterRefs(ctx.brief, catalog); const storyboard = buildStoryboardPayload(ctx.brief, characterRefs); const res = await studio.bundleStoryboard(ctx.cfg, { storyboard, characterRefs }); if (!res.ok) throw new Error(res.data?.error || res.raw || `bundle failed ${res.status}`); return res.data?.bundleKey; } function filmScenes(brief) { return brief.scenes.map((s) => ({ shot_id: s.id, prompt: s.prompt ?? '', seconds: s.target_seconds ?? brief.clip_seconds ?? 5, })); } // slate#209: a keyframes-only brief goes to POST /api/storyboard/render with keyframesOnly, the door that // stops after keyframes. POST /api/render/film has no keyframes-only mode: it never read a // keyframes_only field, so this action used to start, and pay for, a full film. The bot's own render // path already reroutes the same way (bot.mjs, the keyframes-only reroute); this body mirrors that one. async function buildKeyframesOnlyBody(ctx, opts = {}) { const { brief } = ctx; const rs = brief.render_settings || ctx.emptyRenderSettings(); return { bundleKey: opts.bundleKey ?? await briefBundleKey(ctx), scenes: filmScenes(brief), qualityTier: opts.quality ?? rs.quality_tier ?? 'draft', castLoras: buildCastLoras(brief.cast_bindings), keyframesOnly: true, }; } async function buildFilmBody(ctx, opts = {}) { const { brief } = ctx; const rs = brief.render_settings || ctx.emptyRenderSettings(); const quality = opts.quality ?? rs.quality_tier ?? 'draft'; const bundleKey = opts.bundleKey ?? await briefBundleKey(ctx); const registry = await ctx.fetchRegistry(); const mapped = registry ? mapModuleOverridesToFilmConfigs(registry, rs, quality) : { keyframe_config: { quality_tier: quality }, motion_config: {}, finish_config: {}, speech_config: {}, film_finish_config: {}, master_config: {} }; const body = { bundle_key: bundleKey, scenes: filmScenes(brief), keyframe_config: mapped.keyframe_config, finish_config: { 'finish-rife': { interpolate: false, face_restore: 'none' }, ...mapped.finish_config }, motion_backend: rs.motion_backend || mapped.motion_backend, motion_config: mapped.motion_config, }; if (mapped.keyframe_backend) body.keyframe_backend = mapped.keyframe_backend; if (Object.keys(mapped.speech_config || {}).length) body.speech_config = mapped.speech_config; if (Object.keys(mapped.master_config || {}).length) body.master_config = mapped.master_config; if (rs.audio_key) body.audio_key = rs.audio_key; const titles = buildFilmTitles(rs); if (titles) body.film_titles = titles; const dialogueLines = brief.scenes .filter((s) => s.dialogue && String(s.dialogue).trim()) .map((s) => ({ shot_id: s.id, text: String(s.dialogue).trim() })); if (dialogueLines.length) body.dialogue_lines = dialogueLines; let filmFinish = mapped.film_finish_config || {}; if (rs.subtitles && dialogueLines.length) { const subMod = await ctx.getSubtitleModule(); if (subMod) filmFinish = applySubtitleToFilmFinish(filmFinish, subMod, true); } if (Object.keys(filmFinish).length) body.film_finish_config = filmFinish; const castLoras = buildCastLoras(brief.cast_bindings); if (Object.keys(castLoras).length) body.cast_loras = castLoras; return body; } /** Every studio API surface. `run` may use brief context from ctx. */ export const STUDIO_ACTIONS = { help: { help: 'List all API actions', async run() { return { ok: true, data: formatStudioHelp() }; }, }, health: { help: 'GET /health', run: (ctx) => studio.getHealth(ctx.cfg) }, modules: { help: 'GET /api/modules', run: (ctx) => studio.getModules(ctx.cfg) }, voices: { help: 'GET /api/voices', run: (ctx) => studio.listVoices(ctx.cfg) }, whoami: { help: 'GET /api/whoami', run: (ctx) => studio.getWhoami(ctx.cfg) }, prefs: { help: 'GET /api/prefs', run: (ctx) => studio.getPrefs(ctx.cfg) }, models: { help: 'GET /api/storyboard/models', run: (ctx) => studio.getStoryboardModels(ctx.cfg) }, 'cast-list': { help: 'GET /api/cast', async run(ctx) { const res = await studio.listCast(ctx.cfg); if (!res.ok) return res; return { ok: true, data: formatCastRoster(res.data?.cast) }; }, }, 'cast-get': { help: 'GET /api/cast/:id args: id:', run: (ctx, a) => studio.getCast(ctx.cfg, a.id) }, 'projects-list': { help: 'GET /api/storyboard/projects', run: (ctx) => studio.listProjects(ctx.cfg) }, 'project-get': { help: 'GET /api/storyboard/projects/:id args: id:', run: (ctx, a) => studio.getProject(ctx.cfg, a.id) }, 'renders-list': { help: 'GET /api/storyboard/renders args: limit:10 project_id:', run: (ctx, a) => studio.listRenders(ctx.cfg, a) }, 'renders-tags': { help: 'GET /api/storyboard/renders/tags', run: (ctx) => studio.listRenderTags(ctx.cfg) }, 'module-config-get': { help: 'GET /api/modules/:name/config args: name:', run: (ctx, a) => studio.getModuleInstallConfig(ctx.cfg, a.name) }, 'cast-lora-status': { help: 'GET /api/cast/:id/lora-status args: id:', run: (ctx, a) => studio.getCastLoraStatus(ctx.cfg, a.id) }, 'cast-refs-poll': { help: 'GET refs-job args: id: jobId:', run: (ctx, a) => studio.pollCastRefsJob(ctx.cfg, a.id, a.jobId) }, 'render-poll': { help: 'GET /api/storyboard/render/:jobId args: jobId:', run: (ctx, a) => studio.pollStoryboardRender(ctx.cfg, a.jobId) }, 'film-poll': { help: 'GET /api/render/film/:id args: id:', run: (ctx, a) => studio.pollFilm(ctx.cfg, a.id) }, 'clips-poll': { help: 'GET /api/render/clips/:id args: id:', run: (ctx, a) => studio.pollClips(ctx.cfg, a.id) }, 'job-poll': { help: 'GET /api/job/:id args: id: module:', run: (ctx, a) => studio.pollJob(ctx.cfg, a.id, a.module) }, 'artifact-url': { help: 'artifact URL args: key:', run: (ctx, a) => ({ ok: true, data: { url: studio.artifactUrl(ctx.cfg, a.key) } }) }, 'cast-export-url': { help: 'export URL args: id:', run: (ctx, a) => ({ ok: true, data: { url: studio.exportCastUrl(ctx.cfg, a.id) } }) }, 'prefs-patch': { help: 'PATCH /api/prefs args: JSON', run: (ctx, a) => studio.patchPrefs(ctx.cfg, a) }, 'module-config-patch': { help: 'PATCH module install config args: name: field:value ...', run: (ctx, a) => { const { name, ...rest } = a; return studio.patchModuleInstallConfig(ctx.cfg, name, rest); }, }, 'cast-create': { help: 'POST /api/cast args: name: bible:', run: (ctx, a) => studio.createCast(ctx.cfg, a) }, 'cast-update': { help: 'PATCH /api/cast/:id args: id: name: bible: voice_id:', run: (ctx, a) => { const { id, ...body } = a; return studio.updateCast(ctx.cfg, id, body); }, }, 'cast-delete': { help: 'DELETE /api/cast/:id args: id:', run: (ctx, a) => studio.deleteCast(ctx.cfg, a.id) }, 'cast-portrait-set': { help: 'POST portrait args: id: key: | from_chat_artifact:', run: (ctx, a) => { const { id, ...body } = a; return studio.setCastPortrait(ctx.cfg, id, body); }, }, 'cast-portrait-clear': { help: 'DELETE portrait args: id:', run: (ctx, a) => studio.deleteCastPortrait(ctx.cfg, a.id) }, 'cast-ref-add': { help: 'POST ref args: id: key:', run: (ctx, a) => studio.addCastRef(ctx.cfg, a.id, { key: a.key, mime: a.mime || 'image/png' }) }, 'cast-ref-del': { help: 'DELETE ref args: id: key:', run: (ctx, a) => studio.deleteCastRef(ctx.cfg, a.id, { key: a.key }) }, 'cast-source-add': { help: 'POST source args: id: key:', run: (ctx, a) => studio.addCastSource(ctx.cfg, a.id, { key: a.key, mime: a.mime || 'image/png' }) }, 'cast-source-del': { help: 'DELETE source args: id: key:', run: (ctx, a) => studio.deleteCastSource(ctx.cfg, a.id, { key: a.key }) }, 'cast-generate-refs': { help: 'POST generate-refs args: id: [art_style:]', run: (ctx, a) => { const { id, ...body } = a; return studio.generateCastRefs(ctx.cfg, id, body); }, }, 'cast-train-lora': { help: 'POST train-lora args: id:', run: (ctx, a) => studio.trainCastLora(ctx.cfg, a.id, {}) }, 'project-create': { help: 'POST project args: name:', run: (ctx, a) => studio.createProject(ctx.cfg, a) }, 'project-patch': { help: 'PATCH project args: id: name:', run: (ctx, a) => { const { id, ...body } = a; return studio.updateProject(ctx.cfg, id, body); }, }, 'project-delete': { help: 'DELETE project args: id:', run: (ctx, a) => studio.deleteProject(ctx.cfg, a.id) }, 'project-save-storyboard': { help: 'Save current brief to project args: id:', async run(ctx, a) { return studio.saveProjectStoryboard(ctx.cfg, a.id, await briefStoryboard(ctx)); }, }, plan: { help: 'POST plan args: brief: model:', run: (ctx, a) => studio.planStoryboard(ctx.cfg, a) }, refine: { help: 'POST refine args: message: model:', async run(ctx, a) { return studio.refineStoryboard(ctx.cfg, { storyboard: await briefStoryboard(ctx), message: a.message, model: a.model }); }, }, enhance: { help: 'POST enhance args: brief:', async run(ctx, a) { return studio.enhanceStoryboard(ctx.cfg, { storyboard: await briefStoryboard(ctx), brief: a.brief }); }, }, preflight: { help: 'POST preflight (current brief)', async run(ctx) { const catalog = await ctx.fetchCastCatalog(); const characterRefs = buildCharacterRefs(ctx.brief, catalog); const storyboard = buildStoryboardPayload(ctx.brief, characterRefs); const rs = ctx.brief.render_settings || {}; const res = await studio.preflightStoryboard(ctx.cfg, { storyboard, castBindings: buildCastLoras(ctx.brief.cast_bindings), motionBackend: rs.motion_backend, quality: rs.quality_tier, audioKey: rs.audio_key, }); if (res.ok) return { ok: true, data: formatPreflightResult(res.data) }; return res; }, }, bundle: { help: 'POST bundle (current brief)', async run(ctx) { const catalog = await ctx.fetchCastCatalog(); const characterRefs = buildCharacterRefs(ctx.brief, catalog); return studio.bundleStoryboard(ctx.cfg, { storyboard: buildStoryboardPayload(ctx.brief, characterRefs), characterRefs }); }, }, yaml: { help: 'POST yaml export (current brief)', async run(ctx) { return studio.storyboardYaml(ctx.cfg, await briefStoryboard(ctx)); }, }, markers: { help: 'POST markers args: format:premiere_csv|resolve_csv fps:24', async run(ctx, a) { return studio.storyboardMarkers(ctx.cfg, { storyboard: await briefStoryboard(ctx), format: a.format || 'premiere_csv', fps: a.fps ? Number(a.fps) : undefined, }); }, }, chat: { help: 'POST /api/chat args: model: user_input:', run: (ctx, a) => studio.studioChat(ctx.cfg, a) }, 'score-bed': { help: 'POST score-bed args: kind:music|narration prompt: text:', run: (ctx, a) => studio.startScoreBed(ctx.cfg, a) }, 'audio-analyze': { help: 'POST audio/analyze args: audioKey:', run: (ctx, a) => studio.analyzeAudio(ctx.cfg, a) }, 'render-plan': { help: 'POST render-plan args: selection:JSON', run: (ctx, a) => studio.submitRenderPlan(ctx.cfg, a) }, 'storyboard-render': { help: 'POST /api/storyboard/render args: keyframesOnly:true|false', async run(ctx, a) { const rs = ctx.brief.render_settings || {}; return studio.submitStoryboardRender(ctx.cfg, { bundleKey: await briefBundleKey(ctx), scenes: filmScenes(ctx.brief), qualityTier: rs.quality_tier || 'draft', motion_backend: rs.motion_backend, castLoras: buildCastLoras(ctx.brief.cast_bindings), audioKey: rs.audio_key, keyframesOnly: a.keyframesOnly === true || a.keyframesOnly === 'true', renderOverrides: rs.module_overrides, }); }, }, 'render-from-keyframes': { help: 'POST render-from-keyframes args: bundleKey:', run: (ctx, a) => studio.submitRenderFromKeyframes(ctx.cfg, a) }, scatter: { help: 'POST scatter render args: shardCount:2', async run(ctx, a) { const rs = ctx.brief.render_settings || {}; return studio.submitScatterRender(ctx.cfg, { bundleKey: await briefBundleKey(ctx), shotIds: ctx.brief.scenes.map((s) => s.id), shardCount: a.shardCount ? Number(a.shardCount) : 2, qualityTier: rs.quality_tier || 'draft', castLoras: buildCastLoras(ctx.brief.cast_bindings), motion_backend: rs.motion_backend, renderOverrides: rs.module_overrides, audioKey: rs.audio_key, film_titles: buildFilmTitles(rs), }); }, }, 'film-submit': { help: 'POST /api/render/film (current brief; a keyframes-only brief goes to /api/storyboard/render)', async run(ctx, a) { const rs = ctx.brief.render_settings || ctx.emptyRenderSettings(); if (rs.keyframes_only) return studio.submitStoryboardRender(ctx.cfg, await buildKeyframesOnlyBody(ctx, a)); return studio.submitFilm(ctx.cfg, await buildFilmBody(ctx, a)); }, }, 'clips-submit': { help: 'POST /api/render/clips args: shots:JSON', run: (ctx, a) => studio.submitClips(ctx.cfg, typeof a.shots === 'string' ? { ...a, shots: JSON.parse(a.shots) } : a), }, 'render-cancel': { help: 'DELETE render job args: jobId:', run: (ctx, a) => studio.cancelStoryboardRender(ctx.cfg, a.jobId) }, 'render-patch': { help: 'PATCH render row args: id: label: tags:JSON', run: (ctx, a) => { const { id, ...body } = a; if (typeof body.tags === 'string') body.tags = JSON.parse(body.tags); return studio.patchRender(ctx.cfg, id, body); }, }, 'render-delete': { help: 'DELETE render row args: id:', run: (ctx, a) => studio.deleteRender(ctx.cfg, a.id) }, 'render-regen-shot': { help: 'POST regen-shot args: id: shotId:', run: (ctx, a) => studio.regenShot(ctx.cfg, a.id, { shotId: a.shotId }) }, 'render-retry': { help: 'POST /api/storyboard/renders/:id/retry args: id:', run: (ctx, a) => studio.retryRender(ctx.cfg, a.id, a) }, 'render-add-audio': { help: 'POST add-audio args: id: audioKey:', run: (ctx, a) => studio.addRenderAudio(ctx.cfg, a.id, { audioKey: a.audioKey }) }, 'render-add-narration': { help: 'POST add-narration args: id: text:', run: (ctx, a) => studio.addRenderNarration(ctx.cfg, a.id, a) }, 'render-finalize': { help: 'POST finalize args: id: [audioKey:]', run: (ctx, a) => { const { id, ...body } = a; return studio.finalizeRender(ctx.cfg, id, body); }, }, 'render-animate-cloud': { help: 'POST animate-cloud args: id: [model:]', run: (ctx, a) => { const { id, ...body } = a; return studio.animateRenderCloud(ctx.cfg, id, body); }, }, 'render-animate-hybrid': { help: 'POST animate-hybrid args: id: [backends:JSON]', run: (ctx, a) => { const { id, ...body } = a; if (typeof body.backends === 'string') body.backends = JSON.parse(body.backends); return studio.animateRenderHybrid(ctx.cfg, id, body); }, }, 'render-adopt': { help: 'POST renders/adopt args: JSON body', run: (ctx, a) => studio.adoptRender(ctx.cfg, a) }, }; export function formatStudioHelp() { const lines = [`**Vivijure studio API** (${STUDIO_API_ROUTE_COUNT} routes, \`!api [args]\`)\n`]; const actionRoute = Object.fromEntries( CONTRACT_ROUTES.flatMap((r) => r.actions.map((a) => [a, r])), ); for (const [k, v] of Object.entries(STUDIO_ACTIONS)) { if (k === 'help') continue; const route = actionRoute[k]; const tag = route ? `#${route.id}` : '(extra)'; lines.push(` \`${k}\` ${tag} -- ${v.help}`); } lines.push('\nUploads (attach file to message): `!importcast`, `!upload`, `!audioupload`, `!characterref `, `!addref `, `!addsource `'); lines.push('Conformance matrix: `!conformance` or `docs/CONTRACT-conformance.md`'); return lines.join('\n'); } export async function executeStudioAction(action, argsRaw, ctx) { const key = (action ?? '').trim().toLowerCase(); if (!key || key === 'help') { return { ok: true, text: formatStudioHelp() }; } const def = STUDIO_ACTIONS[key]; if (!def) { return { ok: false, text: `Unknown action \`${key}\`. Try \`!api help\`.` }; } if (!ctx.cfg?.vivijureUrl) { return { ok: false, text: 'Studio not configured (VIVIJURE_API_URL + STUDIO_API_TOKEN).' }; } let args = {}; if (argsRaw?.trim()) { try { args = parseApiArgs(argsRaw); } catch (e) { return { ok: false, text: `Bad args: ${e.message}` }; } } try { const res = await def.run(ctx, args); if (typeof res?.data === 'string' && !res.data.startsWith('{')) { return { ok: res.ok !== false, text: res.data }; } return { ok: res?.ok !== false, text: formatApiResult(res) }; } catch (e) { return { ok: false, text: `Action failed: ${e.message}` }; } }