Ctrl K
ComunidadGuías…

lib.mjs

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

Archivo original ↗Preguntar a KiBot
// lib.mjs
// Slate -- pure, side-effect-free helpers extracted from bot.mjs so they can be unit-tested
// directly (no Discord client, no network, no process env). Every function here is a pure
// transform: same input -> same output, no I/O. bot.mjs imports these; the async wrappers that
// need the live registry (e.g. resolveBackend) stay in bot.mjs and call into matchBackend here.
//
// Behaviour is byte-for-byte the same as the original in-bot definitions; this file only moves
// them out. Keep it dependency-free.

// Smart prompt clamp (issue #16). The pod's bg-pass feeds scene prompts to SDXL verbatim and the
// API caps a scene prompt at 50 words (CLIP truncates at 77 tokens after triggers + style_prefix),
// so an over-length prompt bounces the submit. Rather than a blind tail-truncate, keep the FIRST
// sentence (usually the motion-critical clause -- subject + action) and fill the remaining word
// budget from what follows, so the trim preserves the beat that drives the shot. Returns the
// trimmed text plus a flag so the caller can give the group a heads-up instead of silently
// dropping words.
export const PROMPT_WORD_CAP = 50;
export function smartTrimPrompt(text) {
  const raw = (text ?? '').trim();
  const words = raw.split(/\s+/).filter(Boolean);
  if (words.length <= PROMPT_WORD_CAP) return { text: raw, trimmed: false };

  // First sentence = the motion-critical clause we never want to lose.
  const firstSentence = (raw.match(/^[^.!?]*[.!?]/)?.[0] ?? '').trim();
  const headWords = firstSentence ? firstSentence.split(/\s+/).filter(Boolean) : [];
  let kept;
  if (headWords.length && headWords.length <= PROMPT_WORD_CAP) {
    // Keep the opening clause whole, then top up from the rest until the cap.
    const rest = words.slice(headWords.length);
    kept = headWords.concat(rest.slice(0, PROMPT_WORD_CAP - headWords.length));
  } else {
    // No usable leading sentence (or it alone exceeds the cap): fall back to a hard head slice.
    kept = words.slice(0, PROMPT_WORD_CAP);
  }
  return { text: kept.join(' '), trimmed: true };
}

// Shape film_titles for the API exactly as the studio planner does (vivijure PR #273): a subtitle
// alone is dropped (the title card requires text), blank credit lines are stripped, and the whole
// field is omitted when empty so the submit body never widens needlessly. Single source of the
// title/credit contract so a film carded in Slate and one carded in the planner come out identical.
export function buildFilmTitles(rs) {
  if (!rs) return undefined;
  const out = {};
  const titleText = (rs.titles?.text ?? '').trim();
  if (titleText) {
    out.title = { text: titleText };
    const sub = (rs.titles?.subtitle ?? '').trim();
    if (sub) out.title.subtitle = sub;
  }
  const lines = Array.isArray(rs.credits?.lines)
    ? rs.credits.lines.map((l) => (l ?? '').trim()).filter(Boolean)
    : [];
  if (lines.length) out.credits = { lines };
  return (out.title || out.credits) ? out : undefined;
}

// Parse credit lines from a single string: split on | or ; or newlines, trim, drop blanks.
export function parseCreditLines(raw) {
  return (raw ?? '').split(/[|;\n]/).map(s => s.trim()).filter(Boolean);
}

// The boolean "enable" field key in a subtitle module's config_schema. Modules name it differently
// (enabled / enable / burn / on); pick the first bool field whose key looks like an enable switch,
// else the first bool field, else "enabled" as a last resort. Projection over assumption.
export function subtitleEnableField(mod) {
  const schema = (mod && mod.config_schema) || {};
  const bools = Object.keys(schema).filter((k) => schema[k] && schema[k].type === 'bool');
  return bools.find((k) => /enabl|^on$|burn|subtitle|caption/i.test(k)) || bools[0] || 'enabled';
}

// Build the characterRefs map. Bound studio cast (cast_bindings) uses portrait + ref_keys from the
// catalog; session-created cast uses castId + portraitKey from Slate's own sync. Slots without a
// real ref are dropped so the bundle does not 400.
export function buildCharacterRefs(brief, castCatalog = []) {
  const catalogById = new Map((castCatalog || []).map((c) => [c.id, c]));
  const bindings = brief.cast_bindings || {};
  const characterRefs = {};

  for (const c of brief.cast || []) {
    const boundId = bindings[c.slot];
    if (boundId) {
      const member = catalogById.get(boundId);
      const ref = characterRefFromStudioMember(member, c.name);
      if (ref) characterRefs[c.slot] = ref;
      continue;
    }
    if (c.castId && c.portraitKey) {
      characterRefs[c.slot] = {
        name: c.name,
        portrait: { key: c.portraitKey },
        trainingImages: [{ key: c.portraitKey }],
      };
    }
  }
  return characterRefs;
}

// Pure core of resolveBackend: match a user backend choice against a known list of registry names.
// 'auto'/'default'/'' -> { value: null } (omit on submit). Exact case-insensitive match wins, else
// the first substring match. An unknown name returns { error } with the valid options so the handler
// can show them. resolveBackend in bot.mjs fetches the live names then delegates here.
export function matchBackend(names, input) {
  const v = (input ?? '').trim().toLowerCase();
  if (!v || v === 'auto' || v === 'default') return { value: null };
  const list = Array.isArray(names) ? names : [];
  const found = list.find(n => n.toLowerCase() === v) || list.find(n => n.toLowerCase().includes(v));
  if (!found) return { error: `Unknown backend \`${input}\`. Options: auto, ${list.join(', ') || '(none reported)'}.` };
  return { value: found };
}

// Pick the concrete motion.backend module name to send on an AUTO full-render submit (slate#58).
// The studio defaults an OMITTED motion_backend to serving[0] (its ui.order-first motion.backend
// module, locality-blind); with the local-consumer doors live that can be a bound-but-non-operational
// gpu-door (a local door whose ephemeral tunnel URL is not seeded server-side), so the keyframe phase
// burns and the film dies at assemble ("no clips rendered to assemble"). So Slate resolves auto here
// and always sends an explicit, serving name instead of relying on the studio default.
//
// `registry` is the GET /api/modules payload. Serving names come from hooks["motion.backend"], which
// the studio already sorts by ui.order then name, so the first match within a class is that class's
// order-first module. Each serving module is classified by its declared ui.locality (the same field
// the studio classifies on): "cloud" (or undeclared) = pay-per-render provider, "byo" = the operator's
// own GPU endpoint, "local" = a homelab door. Preference: a cloud module, then the operator's own GPU,
// then a local door (the door is chosen only when it is the sole thing serving, e.g. a pure self-host).
// Returns { value } with the chosen name, or { error } when nothing serves motion.backend (the caller
// fails the render loudly; it never falls back to omitting).
// LoRA status label for roster display.
export function loraStatusLabel(status) {
  switch (status) {
    case 'ready': return 'LoRA ready';
    case 'training': return 'training...';
    case 'failed': return 'LoRA failed';
    default: return 'no LoRA';
  }
}

// Resolve a cast member from the studio catalog by public id or case-insensitive name match.
export function resolveCastMember(catalog, query) {
  const q = (query ?? '').trim();
  if (!q || !Array.isArray(catalog)) return null;
  const byId = catalog.find((c) => c.id === q);
  if (byId) return byId;
  const lower = q.toLowerCase();
  return catalog.find((c) => (c.name ?? '').toLowerCase() === lower)
    || catalog.find((c) => (c.name ?? '').toLowerCase().includes(lower))
    || null;
}

// Build characterRefs entry from a persisted studio cast member (portrait + ref_keys).
export function characterRefFromStudioMember(member, fallbackName) {
  if (!member) return null;
  const trainingImages = [];
  if (member.portrait_key) trainingImages.push({ key: member.portrait_key });
  for (const rk of member.ref_keys || []) {
    // studio ref_keys are { key, mime } objects (or bare strings); pull out the R2 key string.
    // Pushing the whole object made trainingImages[i].key = [object Object] -> studio 400 (slate #88).
    const key = typeof rk === 'string' ? rk : (rk && rk.key);
    if (key && !trainingImages.some((t) => t.key === key)) trainingImages.push({ key });
  }
  if (!trainingImages.length) return null;
  const ref = {
    name: member.name || fallbackName,
    prompt: member.bible || '',
    trainingImages,
  };
  if (member.portrait_key) ref.portrait = { key: member.portrait_key };
  return ref;
}

// cast_bindings: { [slot]: castPublicId } -> cast_loras for POST /api/render/film.
export function buildCastLoras(castBindings) {
  const out = {};
  if (!castBindings || typeof castBindings !== 'object') return out;
  for (const [slot, id] of Object.entries(castBindings)) {
    if (typeof slot === 'string' && slot && typeof id === 'string' && id) out[slot] = id;
  }
  return out;
}

// Format the studio cast roster for Discord.
export function formatCastRoster(castList) {
  if (!Array.isArray(castList) || !castList.length) {
    return 'No characters in the studio cast library yet. Use `!portrait` to create one, or add them at the control panel.';
  }
  const lines = ['**Studio cast library** (`!bind <slot> <name>` to reuse)\n'];
  for (const c of castList) {
    const portrait = c.portrait_key ? 'portrait' : 'no portrait';
    const refs = (c.ref_keys || []).length;
    const lora = loraStatusLabel(c.lora_status);
    const voice = c.voice_id ? `voice: ${c.voice_id}` : 'default voice';
    lines.push(`  \`${c.id.slice(0, 8)}…\` **${c.name}** -- ${lora}, ${portrait}, ${refs} ref(s), ${voice}`);
  }
  return lines.join('\n');
}

// Format preflight issues for Discord.
export function formatPreflightResult(result) {
  if (!result) return 'Preflight returned nothing.';
  const counts = result.counts || {};
  const header = result.ok
    ? `Preflight **OK** (${counts.warning || 0} warning(s), ${counts.info || 0} note(s)).`
    : `Preflight **blocked** (${counts.error || 0} error(s), ${counts.warning || 0} warning(s)).`;
  const issues = Array.isArray(result.issues) ? result.issues : [];
  if (!issues.length) return header;
  const lines = [header, ''];
  for (const i of issues.slice(0, 12)) {
    const tag = i.level === 'error' ? '**ERROR**' : i.level === 'warning' ? 'warn' : 'info';
    lines.push(`  [${tag}] ${i.message}`);
  }
  if (issues.length > 12) lines.push(`  ... and ${issues.length - 12} more`);
  return lines.join('\n');
}

// Decide whether a paid render may start. `run` performs the preflight and resolves to
// { ok, result } or { ok:false, error }. Returns null to proceed, or { ok:false, error } to refuse.
//
// POLICY (slate#183, option C): this gate FAILS CLOSED. Only an explicit `result.ok === true`
// proceeds. A preflight that says no, throws, times out, errors at the studio, or returns no verdict
// refuses, because an unvalidated storyboard must never reach paid GPU. An unrun check is "could not
// measure", never "passed". The character-ref step in bot.mjs (ensureCharacterRefs) is deliberately
// the opposite: it fails OPEN with a notice, because a missing ref costs a worse render, not money.
// Do not "simplify" the two to match. The operator override is opts.skipPreflight, applied by the caller.
export async function gatePreflight(run) {
  let pf;
  try {
    pf = await run();
  } catch (e) {
    return { ok: false, error: `I could not run the pre-render check (${e?.message || 'unknown error'}), so I did not start the render. Try again shortly.` };
  }
  if (!pf?.ok) {
    return { ok: false, error: pf?.error || 'I could not run the pre-render check, so I did not start the render. Try again shortly.' };
  }
  if (pf.result?.ok !== true) {
    if (pf.result?.ok === false) return { ok: false, error: formatPreflightResult(pf.result), preflight: pf.result };
    return { ok: false, error: 'The pre-render check gave no clear answer, so I did not start the render. Try again shortly.' };
  }
  return null;
}

// Storyboard payload for bundle / preflight (mirrors bot submit shape).
export function buildStoryboardPayload(brief, characterRefs = {}) {
  const refSlots = new Set(Object.keys(characterRefs));
  const sceneSlots = (slots) => (slots ?? []).filter((slot) => refSlots.has(slot));
  return {
    title: brief.title ?? 'Untitled',
    full_prompt: brief.full_prompt ?? undefined,
    style_prefix: brief.style_prefix ? brief.style_prefix.slice(0, 256) : undefined,
    style_category: brief.style_category ?? 'None',
    duration_seconds: brief.duration_seconds ?? undefined,
    clip_seconds: brief.clip_seconds ?? undefined,
    use_characters: [...new Set(brief.scenes.flatMap((s) => sceneSlots(s.character_slots)))],
    scenes: (brief.scenes || []).map((s) => {
      const slots = sceneSlots(s.character_slots);
      const scene = {
        id: s.id,
        prompt: s.prompt ?? '',
        act: s.act ?? undefined,
        character_slots: slots,
        target_seconds: s.target_seconds ?? undefined,
      };
      // The studio storyboard validator (vivijure storyboard-validate.ts) requires scene.dialogue to
      // be an object { slot, text }, NOT a bare string -- a string 400s the bundle (assembleBundle
      // re-validates), which silently broke EVERY dialogue-bearing render (slate contract audit). The
      // brief carries dialogue as a plain spoken line, so attribute it to the shot when there is a
      // single unambiguous speaking slot (the studio then voices it in that cast member voice; #582
      // resolves an explicit line voice via the bundle scene dialogue.slot). With no single speaker we
      // omit it; the line still ships via the top-level dialogue_lines (default voice), never a hard
      // bundle failure and never a string on the wire.
      const line = (s.dialogue ?? '').toString().trim();
      if (line && slots.length === 1) scene.dialogue = { slot: slots[0], text: line };
      return scene;
    }),
  };
}

// Group module_overrides.config by hook for /api/render/film body fields.
export function mapModuleOverridesToFilmConfigs(registry, rs, qualityTier) {
  const mods = Array.isArray(registry?.modules) ? registry.modules : [];
  const hooks = registry?.hooks || {};
  const cfg = rs?.module_overrides?.config || {};
  const byHook = (hook) => (hooks[hook] || [])
    .map((name) => mods.find((m) => m.name === name))
    .filter(Boolean);

  const pickOneConfig = (hook, choice) => {
    const serving = byHook(hook);
    const mod = (choice && serving.find((m) => m.name === choice)) || serving[0];
    if (!mod) return {};
    const c = { ...(cfg[mod.name] || {}) };
    if (hook === 'keyframe') c.quality_tier = qualityTier;
    if (hook === 'motion.backend' && mod.config_schema?.quality) c.quality = qualityTier;
    return { mod, config: c };
  };

  const chainConfig = (hook) => {
    const out = {};
    for (const mod of byHook(hook)) {
      const c = cfg[mod.name];
      if (c && typeof c === 'object' && Object.keys(c).length) out[mod.name] = { ...c };
    }
    return out;
  };

  const kf = pickOneConfig('keyframe', rs?.keyframe_backend);
  const motionChoice = rs?.motion_backend || rs?.module_overrides?.motion_backend;
  const motion = pickOneConfig('motion.backend', motionChoice);

  return {
    keyframe_backend: kf.mod?.name,
    keyframe_config: kf.mod ? kf.config : { quality_tier: qualityTier },
    motion_backend: motion.mod?.name,
    motion_config: motion.mod ? motion.config : {},
    finish_config: chainConfig('finish'),
    speech_config: chainConfig('speech'),
    film_finish_config: chainConfig('film.finish'),
    master_config: chainConfig('master'),
  };
}

// Merge film_finish subtitle toggle into mapped configs (registry-projected enable field).
export function applySubtitleToFilmFinish(filmFinishConfig, subMod, enabled) {
  if (!enabled || !subMod) return filmFinishConfig;
  const field = subtitleEnableField(subMod);
  return {
    ...filmFinishConfig,
    [subMod.name]: { ...(filmFinishConfig[subMod.name] || {}), [field]: true },
  };
}

export function pickAutoMotionBackend(registry) {
  const serving = registry?.hooks?.['motion.backend'];
  const names = Array.isArray(serving) ? serving.filter(Boolean) : [];
  if (!names.length) {
    return { error: 'the studio has no motion backend installed to render with' };
  }
  const mods = Array.isArray(registry?.modules) ? registry.modules : [];
  // Undeclared locality classifies as cloud, mirroring the studio's own default.
  const localityOf = (name) => {
    const m = mods.find((x) => x && x.name === name);
    return m?.ui?.locality ?? 'cloud';
  };
  const firstOfClass = (cls) => names.find((n) => localityOf(n) === cls);
  const chosen = firstOfClass('cloud') ?? firstOfClass('byo') ?? firstOfClass('local') ?? names[0];
  return { value: chosen };
}

// Auto-bind resolver (#84): decide whether an inline cast slot with a given NAME should reuse an
// existing studio cast member instead of minting a fresh one. The natural Slate flow (describe a
// character / !portrait) POSTs a NEW cast member every session and never sets cast_bindings, so a
// user who already trained "Wren" gets a generic, LoRA-less character unless they know the hidden
// /bind command. This makes reuse the DEFAULT: on an EXACT (case-insensitive) name match to a
// single studio member, bind it (so cast_loras carries the trained LoRA). Guardrails: a generic
// placeholder name ("Character A") never matches; MULTIPLE same-name rows (e.g. two "Riff") are
// AMBIGUOUS -- we never silently pick, we surface the roster so the user binds by id. Returns one of:
//   { status: skip }                 -- no real name to match on
//   { status: none }                 -- a real name, but no studio member has it
//   { status: bound, member }        -- exactly one match; caller binds + hydrates from it
//   { status: ambiguous, matches }   -- >1 match; caller surfaces, never auto-picks
const GENERIC_SLOT_NAME = /^character\s+[a-d]$/i;
export function pickAutoBind(catalog, name) {
  const n = (name ?? "").trim();
  if (!n || GENERIC_SLOT_NAME.test(n)) return { status: "skip" };
  const lower = n.toLowerCase();
  const matches = (Array.isArray(catalog) ? catalog : []).filter(
    (c) => (c.name ?? "").trim().toLowerCase() === lower,
  );
  if (!matches.length) return { status: "none" };
  if (matches.length > 1) return { status: "ambiguous", matches };
  return { status: "bound", member: matches[0] };
}

// Build the live-context block injected into the assistant brain every turn (#84 chat fix / #90
// down-payment). The brain otherwise sees only the system prompt + chat history, so it is BLIND to the
// studio cast and the current bindings and confabulates ("I can't see your cast"). This hands it (a)
// the studio cast library (reusable trained characters, name + LoRA readiness + voice) and (b) the
// current storyboard's cast slots and which are bound, so it can reason about reuse instead of telling
// the user to run a command it never sees the output of. Returns '' when there is nothing to add.
export function buildCastContextBlock(brief, castCatalog = []) {
  const catalog = Array.isArray(castCatalog) ? castCatalog : [];
  const slots = (brief && Array.isArray(brief.cast)) ? brief.cast : [];
  const bindings = (brief && brief.cast_bindings) || {};
  if (!catalog.length && !slots.length) return '';

  const lines = ['--- LIVE STUDIO CONTEXT (you can see this every turn; NEVER tell the user to run a command to "show you" the cast -- you already have it) ---'];

  if (catalog.length) {
    lines.push('Studio cast library (reusable trained characters -- reuse one and its TRAINED likeness/LoRA is used):');
    for (const c of catalog) {
      const voice = c.voice_id ? `, voice ${c.voice_id}` : '';
      lines.push(`  - ${c.name} -- ${loraStatusLabel(c.lora_status)}${voice}`);
    }
  } else {
    lines.push('Studio cast library: (empty -- no trained characters yet).');
  }

  if (slots.length) {
    lines.push('Current storyboard cast slots:');
    for (const c of slots) {
      const bound = bindings[c.slot] || c.bound;
      const tag = bound
        ? 'BOUND to a studio character (its trained LoRA will be used on render)'
        : 'inline (auto-binds to a studio character by exact name at render, else rendered from its description)';
      lines.push(`  - ${c.slot}: ${c.name || '(unnamed)'} -- ${tag}`);
    }
  }

  lines.push('To reuse a trained character: /bind <slot> <name> (or just NAME them in the story -- an exact name match auto-binds at render). /unbind <slot> makes a fresh character instead.');
  return lines.join('\n');
}

// Command-dispatch predicate for the bare !render command. A bare startsWith('!render') also
// swallows !renders, !rendertags, !renderpoll, !render-plan and !render-keyframes, so the match must
// be the exact command or the command followed by whitespace.
export function isRenderCommand(text) {
  return /^!render(\s|$)/.test(text ?? '');
}