Ctrl K
ComunidadGuías…

studio-api.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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 <action> [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:<uuid>', 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:<uuid>', run: (ctx, a) => studio.getProject(ctx.cfg, a.id) },
  'renders-list': { help: 'GET /api/storyboard/renders  args: limit:10 project_id:<uuid>', 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:<module>', run: (ctx, a) => studio.getModuleInstallConfig(ctx.cfg, a.name) },
  'cast-lora-status': { help: 'GET /api/cast/:id/lora-status  args: id:<uuid>', run: (ctx, a) => studio.getCastLoraStatus(ctx.cfg, a.id) },
  'cast-refs-poll': { help: 'GET refs-job  args: id:<uuid> jobId:<id>', 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:<mod> 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 <action> [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 <slot>`, `!addref <slot>`, `!addsource <slot>`');
  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}` };
  }
}