Ctrl K
ComunidadGuías…

registry.mjs

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

Archivo original ↗Preguntar a KiBot
// registry.mjs
// Projection of GET /api/modules for Slate -- mirrors vivijure/public/planner-registry.js and
// planner-render-config.js. Hook lists, pick_one choosers, and command availability are never
// hardcoded; they follow whatever modules are installed on the studio.

import { matchBackend } from './lib.mjs';
import { STUDIO_API_ROUTE_COUNT } from './contract.mjs';

export const PANEL_SKIP_HOOKS = new Set([
  'plan.enhance',
  'score',
  'dialogue',
  'cast.image',
  'notify',
]);

export const PANEL_ORDER = [
  'keyframe',
  'motion.backend',
  'speech',
  'finish',
  'master',
  'film.finish',
];

export const FALLBACK_TIERS = [
  { value: 'draft', label: 'draft', blurb: 'fastest, lowest quality' },
  { value: 'standard', label: 'standard', blurb: 'balanced' },
  { value: 'final', label: 'final', blurb: 'production quality' },
];

const PICK_ONE_HOOKS = new Set(['keyframe', 'motion.backend', 'dialogue', 'cast.image']);

/** Brief field storing the explicit pick_one choice per hook (null = registry default). */
export const PICK_ONE_BRIEF_FIELDS = {
  keyframe: 'keyframe_backend',
  'motion.backend': 'motion_backend',
  dialogue: 'dialogue_backend',
  'cast.image': 'cast_image_backend',
};

export function byName(registry) {
  return Object.fromEntries((registry?.modules || []).map((m) => [m.name, m]));
}

export function hookModules(registry, hook, filter) {
  if (!registry) return [];
  const order = registry.hooks?.[hook];
  const names = Array.isArray(order) ? order.filter(Boolean) : [];
  const named = byName(registry);
  const mods = names.map((n) => named[n]).filter(Boolean);
  return filter ? mods.filter(filter) : mods;
}

export function panelHooks(registry) {
  const catalog = Array.isArray(registry?.catalog) ? registry.catalog : [];
  const rank = (name) => {
    const i = PANEL_ORDER.indexOf(name);
    return i === -1 ? PANEL_ORDER.length : i;
  };
  return catalog
    .filter((h) => h?.name && !PANEL_SKIP_HOOKS.has(h.name))
    .map((h, i) => ({ hook: h.name, pickOne: h.cardinality === 'pick_one', blurb: h.blurb || '', _i: i }))
    .sort((a, b) => rank(a.hook) - rank(b.hook) || a._i - b._i)
    .map(({ hook, pickOne, blurb }) => ({ hook, pickOne, blurb }));
}

export function moduleLabel(mod) {
  if (!mod) return '';
  const l = mod.provides?.[0]?.label;
  return (l && String(l).trim()) || mod.name;
}

export function qualityTiers(registry) {
  const tiers = registry?.render?.quality_tiers;
  return Array.isArray(tiers) && tiers.length ? tiers : FALLBACK_TIERS;
}

export function defaultTier(registry) {
  return registry?.render?.default_tier ?? 'draft';
}

export function scoreMusicModules(registry) {
  return hookModules(registry, 'score', (m) => m.config_schema?.prompt);
}

export function scoreNarrationModules(registry) {
  return hookModules(registry, 'score', (m) => m.config_schema?.text);
}

export function planEnhanceInstalled(registry) {
  return hookModules(registry, 'plan.enhance').length > 0;
}

export function findSubtitleModule(registry) {
  const mods = Array.isArray(registry?.modules) ? registry.modules : [];
  const serving = registry?.hooks?.['film.finish'] || [];
  for (const name of serving) {
    const mod = mods.find((m) => m.name === name);
    if (!mod) continue;
    const schema = mod.config_schema || {};
    const isSubtitle = /subtitle|caption/i.test(mod.name)
      || Object.keys(schema).some((k) => /subtitle|caption|burn/i.test(k));
    if (isSubtitle) return mod;
  }
  return null;
}

export function pickOneChoice(rs, hook) {
  const field = PICK_ONE_BRIEF_FIELDS[hook];
  if (!field || !rs) return null;
  return rs[field] ?? null;
}

export function setPickOneChoice(rs, hook, value) {
  const field = PICK_ONE_BRIEF_FIELDS[hook];
  if (!field || !rs) return;
  rs[field] = value;
}

export function resolvePickOne(registry, hook, input) {
  const names = hookModules(registry, hook).map((m) => m.name);
  const v = (input ?? '').trim().toLowerCase();
  if (!v || v === 'auto' || v === 'default') return { value: null };
  return matchBackend(names, input);
}

export function renderScopeFields(mod) {
  const schema = mod?.config_schema || {};
  return Object.keys(schema).filter(
    (k) => schema[k]?.scope !== 'install' && k !== 'quality_tier' && k !== 'quality',
  );
}

export function installScopeFields(mod) {
  const schema = mod?.config_schema || {};
  return Object.keys(schema).filter((k) => schema[k]?.scope === 'install');
}

/** Which Slate commands are live for this registry snapshot. */
export function commandAvailability(registry, studioConfigured) {
  const kf = hookModules(registry, 'keyframe');
  const motion = hookModules(registry, 'motion.backend');
  const dialogue = hookModules(registry, 'dialogue');
  const castImage = hookModules(registry, 'cast.image');
  const scoreMusic = scoreMusicModules(registry);
  const scoreNarr = scoreNarrationModules(registry);
  const subMod = findSubtitleModule(registry);
  const panel = panelHooks(registry);
  const chainHooks = panel.filter((h) => !h.pickOne);

  return {
    studio: { ok: !!studioConfigured, reason: 'Studio not configured (VIVIJURE_API_URL + STUDIO_API_TOKEN).' },
    tier: { ok: !!studioConfigured, reason: 'Studio not configured.' },
    hooks: { ok: !!studioConfigured && !!registry, reason: 'Could not load the studio registry.' },
    keyframe: {
      ok: kf.length > 0,
      modules: kf.map((m) => m.name),
      reason: 'No `keyframe` module installed on the studio.',
    },
    backend: {
      ok: motion.length > 0,
      modules: motion.map((m) => m.name),
      reason: 'No `motion.backend` (i2v) module installed on the studio.',
    },
    'keyframes-only': {
      ok: kf.length > 0,
      modules: kf.map((m) => m.name),
      reason: 'No `keyframe` module installed (keyframes-only preview needs one).',
    },
    subtitles: {
      ok: !!subMod,
      module: subMod?.name,
      reason: 'No subtitle module in the `film.finish` chain.',
    },
    config: {
      ok: !!studioConfigured && panel.some((h) => hookModules(registry, h.hook).length > 0),
      reason: 'No render modules with configurable knobs installed.',
    },
    'install-config': {
      ok: !!studioConfigured && (registry?.modules || []).some((m) => installScopeFields(m).length > 0),
      reason: 'No modules with install-scoped config on the studio.',
    },
    'score-music': {
      ok: scoreMusic.length > 0,
      modules: scoreMusic.map((m) => m.name),
      reason: 'No `score` module with a music prompt knob installed.',
    },
    'score-narration': {
      ok: scoreNarr.length > 0,
      modules: scoreNarr.map((m) => m.name),
      reason: 'No `score` module with narration text installed.',
    },
    autodirect: {
      ok: planEnhanceInstalled(registry),
      modules: hookModules(registry, 'plan.enhance').map((m) => m.name),
      reason: 'No `plan.enhance` module installed.',
    },
    voices: {
      ok: !!studioConfigured && dialogue.length > 0,
      modules: dialogue.map((m) => m.name),
      reason: 'No `dialogue` (TTS) module installed.',
    },
    cast: { ok: !!studioConfigured, reason: 'Studio not configured.' },
    'cast-image': {
      ok: castImage.length > 0,
      modules: castImage.map((m) => m.name),
      reason: 'No `cast.image` module installed.',
    },
    api: { ok: !!studioConfigured, reason: 'Studio not configured.' },
    chain: { ok: chainHooks.some((h) => hookModules(registry, h.hook).length > 0) },
  };
}

export function gateMessage(gate) {
  return gate?.reason || 'That command is not available on this studio.';
}

export function formatPickOneList(hook, registry, rs, command) {
  const mods = hookModules(registry, hook);
  const current = pickOneChoice(rs, hook);
  const lines = [`**${hook}** (\`${command} <name|auto>\`)\n`];
  const autoActive = !current ? ' **<-- active**' : '';
  lines.push(`  \`auto\` -- registry default (first serving module)${autoActive}`);
  for (const m of mods) {
    const active = m.name === current ? ' **<-- active**' : '';
    lines.push(`  \`${m.name}\` -- ${moduleLabel(m)}${active}`);
  }
  return lines.join('\n');
}

export function formatTierList(registry, current) {
  const tiers = qualityTiers(registry);
  const lines = ['**Quality tier** (`!tier <name>` / `/tier`)\n'];
  for (const t of tiers) {
    const active = t.value === current ? ' **<-- active**' : '';
    const blurb = t.blurb ? ` (${t.blurb})` : '';
    lines.push(`  \`${t.value}\`${blurb}${active}`);
  }
  return lines.join('\n');
}

export function formatHooksStatus(registry, rs) {
  if (!registry) return 'Could not load the studio registry.';
  const catalog = Array.isArray(registry.catalog) ? registry.catalog : [];
  const lines = ['**Studio hook catalog** (live from `GET /api/modules`)\n'];
  for (const h of catalog) {
    const mods = hookModules(registry, h.name);
    const card = h.cardinality === 'pick_one' ? 'pick_one' : 'chain';
    if (!mods.length) {
      lines.push(`\n**${h.name}** (${card}) -- *(no modules installed)*`);
      continue;
    }
    lines.push(`\n**${h.name}** (${card})${h.blurb ? ` -- ${h.blurb}` : ''}`);
    if (PICK_ONE_HOOKS.has(h.name)) {
      const cur = pickOneChoice(rs, h.name) || mods[0]?.name || 'auto';
      lines.push(`  active: \`${cur}\``);
    }
    for (const m of mods) {
      const knobs = renderScopeFields(m);
      const knobHint = knobs.length ? ` [${knobs.length} knob(s)]` : '';
      lines.push(`  \`${m.name}\` -- ${moduleLabel(m)}${knobHint}`);
    }
  }
  lines.push('\nPick_one hooks: `!keyframe`, `!backend`, `!dialogue`, `!castimage`');
  lines.push('Chain knobs: `!config <module> <field> <value>`');
  lines.push('Install knobs: `!install-config`');
  return lines.join('\n');
}

export function formatModuleConfigByHook(registry, rs, moduleFilter) {
  if (!registry) return 'Could not load the studio module registry.';
  const cfg = rs?.module_overrides?.config || {};
  const hooks = panelHooks(registry);
  const lines = ['**Render module config** (`!config <module> <field> <value>`)\n'];

  for (const h of hooks) {
    const mods = hookModules(registry, h.hook);
    const visible = moduleFilter
      ? mods.filter((m) => m.name === moduleFilter || m.name.includes(moduleFilter))
      : mods;
    if (!visible.length) continue;
    lines.push(`\n### ${h.hook}${h.pickOne ? ' (pick_one)' : ' (chain)'}`);
    for (const mod of visible) {
      const keys = renderScopeFields(mod);
      if (!keys.length) {
        lines.push(`\n**${mod.name}** -- ${moduleLabel(mod)} (no per-render knobs)`);
        continue;
      }
      lines.push(`\n**${mod.name}** -- ${moduleLabel(mod)}`);
      for (const k of keys) {
        const cur = cfg[mod.name]?.[k];
        const def = mod.config_schema[k]?.default;
        const val = cur !== undefined ? cur : `(default: ${def})`;
        lines.push(`  \`${k}\` = ${val}`);
      }
    }
  }
  return lines.length > 1 ? lines.join('\n') : 'No configurable render modules installed.';
}

export function formatInstallConfig(registry, modFilter) {
  if (!registry) return 'Could not load the studio module registry.';
  const mods = Array.isArray(registry.modules) ? registry.modules : [];
  const lines = ['**Install config** (`!install-config <module> <field> <value>`)\n'];
  let any = false;
  for (const mod of mods) {
    if (modFilter && mod.name !== modFilter && !mod.name.includes(modFilter)) continue;
    const keys = installScopeFields(mod);
    if (!keys.length) continue;
    any = true;
    lines.push(`\n**${mod.name}** (${(mod.hooks || []).join(', ')})`);
    for (const k of keys) {
      const f = mod.config_schema[k];
      lines.push(`  \`${k}\` -- ${f?.label || k}`);
    }
  }
  return any ? lines.join('\n') : 'No install-scoped module config on this studio.';
}

/** Dynamic help: only commands whose gates pass. */
export function formatAvailableCommands(avail) {
  const lines = ['**Available commands** (module-gated; changes when the studio registry changes)\n'];
  const add = (cmd, desc, key) => {
    if (avail[key]?.ok !== false) lines.push(`  \`${cmd}\` -- ${desc}`);
  };

  lines.push('\n**Storyboard**');
  add('!brief', 'current storyboard + render settings', 'studio');
  add('!reset', 'clear project', 'studio');
  add('!undo', 'roll back last extraction', 'studio');
  add('!portrait', 'generate character portrait', 'studio');
  add('!thumbnail', 'scene thumbnail', 'studio');

  lines.push('\n**Render**');
  add('!tier', 'quality tier (draft/standard/final)', 'tier');
  add('!keyframe', 'pick keyframe module', 'keyframe');
  add('!backend', 'pick i2v / motion module', 'backend');
  add('!keyframes-only on|off', 'SDXL preview without motion', 'keyframes-only');
  add('!titlecard', 'opening title + credits', 'studio');
  add('!subtitles on|off', 'burn dialogue captions', 'subtitles');
  add('!config', 'per-render module knobs', 'config');
  add('!install-config', 'operator install knobs', 'install-config');
  add('!preflight', 'validate before render', 'studio');
  add('!render / !ship', 'submit to Vivijure', 'studio');

  lines.push('\n**Cast**');
  add('!cast / !bind / !train', 'studio cast library + LoRA', 'cast');
  add('!voices / !voice', 'dialogue TTS voices', 'voices');
  add('!castimage', 'pick cast.image module', 'cast-image');
  add('!importcast / !addref / !addsource', 'cast uploads', 'cast');

  lines.push('\n**Score & enhance**');
  add('!score music', 'music bed', 'score-music');
  add('!score narration', 'narration bed', 'score-narration');
  add('!autodirect', 'plan.enhance auto-direction', 'autodirect');

  lines.push('\n**Studio API**');
  add('!api help', `full API surface (${STUDIO_API_ROUTE_COUNT} routes)`, 'api');
  add('!conformance', 'route-to-command conformance matrix', 'api');
  add('!hooks', 'hook catalog + active picks', 'hooks');
  add('!commands', 'this list', 'studio');

  return lines.join('\n');
}