lib.mjs
Rama main · réplica de lectura del repositorio original.
// 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 ?? '');
}