Compare commits

..

6 Commits

Author SHA1 Message Date
Carlos Narro
37f92dc402 feat(video): generar a 1344x768 (máx H3) por defecto + drenaje nocturno
- route videos: 16:9=1344x768 / 9:16=768x1344 (antes 832x480), el máximo legal del
  contrato H3 (w*h<=1.032.192px, múltiplos de 32). Es la resolución estándar ahora.
- video-service: normalizeResolution ajusta cualquier resolución al contrato H3.
- mcp: story_sync_video_jobs + proyección compacta (capítulos de 100+ planos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-21 12:32:47 +02:00
Carlos Narro
2ff41ad2bc fix(video): leer H3API_URL (nombre que entrega essia-server), fallback a H3API_BASE_URL
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-14 00:43:20 +02:00
Carlos Narro
f58ab93646 feat(video): generación de vídeo H3 async (no bloqueante) con estados queued→ready
En vez de esperar el render dentro de la request (imposible con la cola nocturna de H3),
ahora el ciclo es async y la cola/worker viven server-side en h3api:

- submit no bloqueante: POST /v1/jobs → se persiste una variación `queued` con h3_job_id y se
  responde al instante. video-service expone submitVideoJob() + getJobStatus() (sin polling).
- nueva acción `sync-jobs` en el route de vídeos: avanza los jobs pendientes del capítulo
  (queued→processing→ready/error); en `done` descarga el mp4, lo guarda y auto-selecciona la
  primera variación lista. Reintenta en el siguiente tick ante fallos de red (no marca error).
- SQLite: columnas `status`/`h3_job_id`/`error` en video_variations + migración idempotente
  (filas existentes → 'ready'). video_url pasa a DEFAULT '' (queued aún no tiene vídeo).
- UI: la tarjeta de variación muestra spinner 'En cola'/'Generando…' y estado 'error'; el
  player solo con variación 'ready'. La página hace polling cada 45s solo si hay pendientes.
- resolución por plano (16:9 832×480 / 9:16 480×832 vía body.aspect). cost_usd = 0.

NO mergear a master hasta que essia-server dé "live" + H3API_KEY.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-14 00:43:20 +02:00
Carlos Narro
659edfa68e feat(video): adaptar video-service a la H3 API (ref2va) — dormante hasta go-live
Sustituye BytePlus Ark/Seedance por el contrato async de essia-server (h3api.essia.coop):
POST /v1/jobs (multipart, mode=ref2va) -> poll GET /v1/jobs/{id} -> download /video (X-API-Key).
Firma de generateVideo/downloadVideo sin cambios (el route no se toca). Env: H3API_BASE_URL, H3API_KEY.
Coste 0 (self-hosted). Sin key no rompe el arranque (lazy).

Limitación anotada: jobs pedidos fuera de la ventana nocturna pueden tardar horas; el polling
síncrono solo es viable en ventana. Cola SQLite + worker pendiente de decidir UX. NO mergear a
master hasta que essia-server confirme "live" + provea H3API_KEY.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-14 00:43:20 +02:00
Carlos Narro
5aa5129865 feat(mcp): servidor MCP para controlar la app desde agentes
Nuevo paquete en mcp-server/ (TypeScript SDK, stdio) que envuelve la API HTTP de Story Studio
y expone 36 tools con prefijo story_ para conducir todo el pipeline: proyectos, idea motriz,
personajes (+assets de imagen), capítulos, guión, pre-escaleta, escaleta, prompts, fondos,
imágenes, vídeos y render. Base URL configurable via STORY_STUDIO_URL (default localhost:3000).

Excluido del build de Next (.dockerignore) — es una herramienta aparte, no se despliega con la app.
Verificado end-to-end contra la app desplegada (list_projects, get_idea_motriz).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-13 02:55:16 +02:00
Carlos Narro
2c08d47a0f fix: bugs y mejoras de la revisión de código
- guión conductor: al crear capítulo con virtud sugerida se generaba con GPT-4o pero se descartaba;
  ahora se persiste con saveGuionConductor (antes se pagaba y se perdía).
- escaleta GET: la condición de migración nunca se apagaba (planos sin personajes → []), reescribiendo
  toda la BD en cada carga; ahora migra solo si hay personajesEnPlano legacy y devuelve lo migrado.
- ficheros huérfanos: al borrar un personaje se elimina su carpeta de imágenes; savePromptsPlanos y
  saveFondos borran las imágenes de IDs que desaparecen (los reutilizados no se tocan).
- race en "generar todos": imágenes de plano y variaciones de personaje se persisten por fila
  (updatePromptPlano / nuevo updateCharacterAsset) en vez de reescribir todo el set por iteración.
- path traversal: path.basename en los segmentos de URL que forman rutas de fichero (image-service +
  serving routes de vídeo/fondo).
- hardcodes de localhost (Remotion chapter-data + CORS) ahora configurables por env con default local.
- IDs de personaje: backfill/dedup de id en generatePersonajes/improvePersonajes (evita 500 y sobrescritura).
- render: cola en memoria con TTL/purga (evita fuga y jobs zombie tras reinicio).
- selectVideoVariation valida pertenencia al plano/capítulo; createProject rechaza slug vacío;
  las migraciones loguean el error en vez de tragarlo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 18:15:22 +02:00
23 changed files with 2706 additions and 60 deletions

View File

@@ -3,6 +3,9 @@
node_modules node_modules
remotion/node_modules remotion/node_modules
# MCP server: herramienta aparte para agentes, no forma parte de la app Next
mcp-server
# Datos persistentes: NO van en la imagen, llegan por volúmenes montados # Datos persistentes: NO van en la imagen, llegan por volúmenes montados
projects projects
data data

3
mcp-server/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo

75
mcp-server/README.md Normal file
View File

@@ -0,0 +1,75 @@
# Story Studio MCP Server
Servidor **MCP** (Model Context Protocol) para controlar [Story Studio](https://story-studio.carlosnarro.com) desde agentes: crear proyectos y conducir todo el pipeline de producción de series animadas IA (idea motriz → personajes → capítulos → guión → escaleta → prompts → fondos → imágenes → vídeos → render).
Envuelve la **API HTTP** de la app; no accede a la BD directamente.
## Requisitos
- Node.js ≥ 18
- Una instancia de Story Studio accesible (local en `npm run dev`, o la desplegada).
## Instalar y compilar
```bash
cd mcp-server
npm install
npm run build # genera dist/
```
## Configuración
| Variable | Default | Descripción |
|----------|---------|-------------|
| `STORY_STUDIO_URL` | `http://localhost:3000` | Base URL de la app. Local: `http://localhost:3000`. Prod: `https://story-studio.carlosnarro.com`. |
> La app **no tiene autenticación**; el server hereda ese modelo. Apunta `STORY_STUDIO_URL` al entorno que quieras controlar.
## Uso con Claude Code / clientes MCP
Transporte **stdio**. Ejemplo de registro (`.mcp.json` o config del cliente):
```json
{
"mcpServers": {
"story-studio": {
"command": "node",
"args": ["C:/Users/carlo/Proyectos/story-studio/mcp-server/dist/index.js"],
"env": { "STORY_STUDIO_URL": "http://localhost:3000" }
}
}
}
```
O en desarrollo con recarga: `command: "npx"`, `args: ["tsx", ".../src/index.ts"]`.
## Herramientas
Prefijo `story_`. Lectura (`readOnlyHint`) vs generación (efectos, LLM/imagen/vídeo).
**Proyecto e idea**: `story_list_projects`, `story_get_project`, `story_create_project`,
`story_get_idea_motriz`, `story_generate_idea_motriz`, `story_refine_idea_motriz`.
**Personajes**: `story_get_personajes`, `story_generate_personajes`, `story_add_personaje`,
`story_improve_personajes`, y assets de imagen: `story_get_character_assets`,
`story_generate_asset_prompts`, `story_generate_base_image`, `story_lock_base_image`,
`story_generate_all_variations`.
**Capítulo y pipeline**: `story_list_capitulos`, `story_create_capitulo`,
`story_get_guion` / `story_generate_guion`,
`story_get_pre_escaleta` / `story_generate_pre_escaleta`,
`story_get_escaleta` / `story_generate_escaleta`,
`story_get_prompts` / `story_generate_prompts`,
`story_get_fondos` / `story_refresh_fondos` / `story_generate_fondo` / `story_generate_all_fondos`,
`story_get_imagenes` / `story_generate_plano_image` / `story_generate_all_images`,
`story_get_videos` / `story_generate_plano_video`,
`story_render_chapter` / `story_get_render_status`.
## Orden típico del pipeline
1. `story_create_project``story_generate_idea_motriz``story_generate_personajes`
2. (opcional) assets de personaje: `story_generate_asset_prompts``story_generate_base_image``story_lock_base_image``story_generate_all_variations`
3. `story_create_capitulo``story_generate_guion``story_generate_pre_escaleta``story_generate_escaleta``story_generate_prompts`
4. `story_refresh_fondos``story_generate_all_fondos``story_generate_all_images``story_generate_plano_video``story_render_chapter`
Las tools de generación pueden tardar (LLM/imagen); el server usa timeouts amplios.

1739
mcp-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
mcp-server/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "story-studio-mcp-server",
"version": "1.0.0",
"description": "MCP server para controlar Story Studio (series animadas IA) desde agentes",
"type": "module",
"main": "dist/index.js",
"bin": {
"story-studio-mcp-server": "dist/index.js"
},
"files": ["dist"],
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"clean": "rimraf dist"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

107
mcp-server/src/api.ts Normal file
View File

@@ -0,0 +1,107 @@
/**
* Cliente HTTP para la API de Story Studio.
*
* La app expone toda su funcionalidad como rutas REST bajo /api. Este cliente centraliza
* el fetch, los timeouts (la generación con LLM/imagen puede tardar minutos) y el formateo
* de errores accionables para el agente.
*/
export const BASE_URL = (process.env.STORY_STUDIO_URL || 'http://localhost:3000').replace(/\/$/, '');
// Límite de caracteres de una respuesta de tool para no saturar el contexto del agente.
export const CHARACTER_LIMIT = 25000;
const GET_TIMEOUT_MS = 60_000;
const POST_TIMEOUT_MS = 600_000; // generación (LLM / imagen) puede tardar varios minutos
export class ApiError extends Error {
constructor(public status: number, public bodyText: string, message: string) {
super(message);
this.name = 'ApiError';
}
}
function joinUrl(pathname: string): string {
return `${BASE_URL}${pathname.startsWith('/') ? '' : '/'}${pathname}`;
}
async function request(method: 'GET' | 'POST', pathname: string, body?: unknown): Promise<unknown> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
method === 'POST' ? POST_TIMEOUT_MS : GET_TIMEOUT_MS,
);
try {
const res = await fetch(joinUrl(pathname), {
method,
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
const text = await res.text();
if (!res.ok) {
// La API devuelve { error: "..." } en fallos.
let msg = `HTTP ${res.status}`;
try {
const parsed = JSON.parse(text);
if (parsed?.error) msg = parsed.error;
} catch { /* body no-JSON */ }
throw new ApiError(res.status, text, msg);
}
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text; // respuesta no-JSON (raro)
}
} catch (err) {
if (err instanceof ApiError) throw err;
if (err instanceof Error && err.name === 'AbortError') {
throw new ApiError(0, '', `La petición a ${pathname} superó el timeout (${method === 'POST' ? '10 min' : '60 s'}).`);
}
throw new ApiError(0, '', `No se pudo conectar con Story Studio en ${BASE_URL} (${pathname}). ¿Está la app levantada? Detalle: ${err instanceof Error ? err.message : String(err)}`);
} finally {
clearTimeout(timeout);
}
}
export function apiGet(pathname: string): Promise<unknown> {
return request('GET', pathname);
}
export function apiPost(pathname: string, body: unknown): Promise<unknown> {
return request('POST', pathname, body);
}
/** Codifica un segmento de path (slug, id) de forma segura para la URL. */
export function seg(value: string | number): string {
return encodeURIComponent(String(value));
}
/**
* Formatea el resultado de una tool: texto JSON (truncado si es enorme).
* Devuelve la forma { content } que espera el SDK de MCP.
*/
export function toolResult(data: unknown): { content: { type: 'text'; text: string }[] } {
let text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
if (text.length > CHARACTER_LIMIT) {
text = text.slice(0, CHARACTER_LIMIT) +
`\n\n… [truncado: la respuesta superaba ${CHARACTER_LIMIT} caracteres]`;
}
return { content: [{ type: 'text', text }] };
}
/** Convierte un error en un resultado de tool accionable (isError). */
export function toolError(err: unknown): { content: { type: 'text'; text: string }[]; isError: true } {
let text: string;
if (err instanceof ApiError) {
text = `Error: ${err.message}` + (err.status ? ` (HTTP ${err.status})` : '');
} else if (err instanceof Error) {
text = `Error: ${err.message}`;
} else {
text = `Error inesperado: ${String(err)}`;
}
return { content: [{ type: 'text', text }], isError: true };
}

489
mcp-server/src/index.ts Normal file
View File

@@ -0,0 +1,489 @@
#!/usr/bin/env node
/**
* MCP server para Story Studio — permite a agentes controlar la app de series animadas IA
* (crear proyectos, idea motriz, personajes, capítulos, guión, escaleta, prompts, fondos,
* imágenes, vídeos y render) a través de su API HTTP.
*
* Transporte: stdio. Configuración: STORY_STUDIO_URL (default http://localhost:3000).
*
* NOTA: la app no tiene autenticación; el server hereda ese modelo. Apunta STORY_STUDIO_URL
* al entorno correcto (local o https://story-studio.carlosnarro.com).
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { apiGet, apiPost, seg, toolResult, toolError, BASE_URL, ApiError } from './api.js';
const server = new McpServer({ name: 'story-studio-mcp-server', version: '1.0.0' });
// Anotaciones reutilizables.
const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } as const;
// Operaciones con efectos (crean/generan; muchas llaman a LLM/imagen/vídeo → openWorld, no idempotentes).
const GEN = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true } as const;
type Args = Record<string, unknown>;
/** Envuelve el handler con try/catch → resultado de tool uniforme. */
function wrap(fn: (args: Args) => Promise<unknown>) {
return async (args: Args) => {
try {
return toolResult(await fn(args));
} catch (e) {
return toolError(e);
}
};
}
/**
* Proyección compacta de la respuesta de vídeos/sync: un capítulo puede tener 100+ planos y el
* objeto completo satura el límite de respuesta (y se trunca a JSON inválido). Devolvemos solo lo
* necesario para conducir la generación: por plano su id, si tiene imagen, y las variaciones (estado).
*/
function compactVideos(data: unknown, onlyPlanoId?: string): unknown {
if (!data || typeof data !== 'object') return data;
const d = data as Record<string, any>;
if (!Array.isArray(d.planos)) return data;
const planosSrc = onlyPlanoId ? d.planos.filter((p: any) => p.id === onlyPlanoId) : d.planos;
// Proyección MUY lean: un capítulo con 100+ planos y sus videoUrl largos supera el límite de
// respuesta. La videoUrl es determinista (/api/projects/{slug}/capitulos/{cap}/videos/{planoId}-{variationId}),
// así que se omite aquí; el consumidor puede reconstruirla desde planoId+variationId si la necesita.
return {
total: d.total,
generated: d.generated,
pending: d.pending,
planos: planosSrc.map((p: any) => ({
id: p.id,
numeroPlano: p.numeroPlano,
imagen: !!p.imagenUrl,
variations: (p.videoVariations || []).map((v: any) => {
const out: Record<string, unknown> = { id: v.id, status: v.status };
if (v.isSelected) out.isSelected = true;
if (typeof v.generationTimeSeconds === 'number') out.generationTimeSeconds = v.generationTimeSeconds;
if (v.error) out.error = v.error;
return out;
}),
})),
};
}
const slugField = z.string().min(1).describe('Slug del proyecto (p.ej. "coco-y-miko"). Obtenlo de story_list_projects.');
const numeroField = z.number().int().min(1).describe('Número de capítulo (entero, p.ej. 1).');
// Helpers de path.
const capBase = (slug: string, numero: number) => `/api/projects/${seg(slug)}/capitulos/${numero}`;
// ============================================================
// PROYECTOS
// ============================================================
server.registerTool('story_list_projects', {
title: 'Listar proyectos',
description: 'Lista todos los proyectos de Story Studio (id, nombre, slug, estado, paso actual). Empieza por aquí para descubrir qué proyectos existen y su slug.',
inputSchema: {},
annotations: READ,
}, wrap(async () => apiGet('/api/projects')));
server.registerTool('story_get_project', {
title: 'Ver proyecto',
description: 'Devuelve los metadatos de un proyecto por su slug.',
inputSchema: { slug: slugField },
annotations: READ,
}, wrap(async ({ slug }) => apiGet(`/api/projects/${seg(slug as string)}`)));
server.registerTool('story_create_project', {
title: 'Crear proyecto',
description: 'Crea un proyecto nuevo a partir de un nombre. El slug se deriva del nombre. Falla si ya existe o si el nombre normaliza a vacío.',
inputSchema: { name: z.string().min(1).describe('Nombre del proyecto/serie.') },
annotations: GEN,
}, wrap(async ({ name }) => apiPost('/api/projects', { name })));
// ============================================================
// IDEA MOTRIZ
// ============================================================
server.registerTool('story_get_idea_motriz', {
title: 'Ver idea motriz',
description: 'Devuelve la Idea Motriz del proyecto (concepto, narrativa, protagonistas, valores, estructura de guión…) o null si aún no existe.',
inputSchema: { slug: slugField },
annotations: READ,
}, wrap(async ({ slug }) => apiGet(`/api/projects/${seg(slug as string)}/idea-motriz`)));
server.registerTool('story_generate_idea_motriz', {
title: 'Generar idea motriz',
description: 'Genera (con GPT-4o) la Idea Motriz a partir de una descripción libre y la guarda. Sobrescribe la existente. Devuelve la Idea Motriz generada.',
inputSchema: {
slug: slugField,
input: z.string().min(1).describe('Descripción/brief de la serie a partir del cual generar la idea motriz.'),
},
annotations: GEN,
}, wrap(async ({ slug, input }) =>
apiPost(`/api/projects/${seg(slug as string)}/idea-motriz`, { action: 'generate', input })));
server.registerTool('story_refine_idea_motriz', {
title: 'Refinar idea motriz',
description: 'Refina la Idea Motriz existente con feedback en lenguaje natural (GPT-4o) y guarda el resultado. Requiere que ya exista una Idea Motriz.',
inputSchema: {
slug: slugField,
feedback: z.string().min(1).describe('Instrucciones de cambio/mejora sobre la idea motriz actual.'),
},
annotations: GEN,
}, wrap(async ({ slug, feedback }) => {
const s = slug as string;
const current = await apiGet(`/api/projects/${seg(s)}/idea-motriz`);
if (!current) throw new ApiError(400, '', 'No hay Idea Motriz que refinar. Usa story_generate_idea_motriz primero.');
return apiPost(`/api/projects/${seg(s)}/idea-motriz`, { action: 'refine', current, input: feedback });
}));
// ============================================================
// PERSONAJES
// ============================================================
server.registerTool('story_get_personajes', {
title: 'Ver personajes',
description: 'Lista los personajes del proyecto (ficha completa: especie, apariencia, personalidad, utilería…).',
inputSchema: { slug: slugField },
annotations: READ,
}, wrap(async ({ slug }) => apiGet(`/api/projects/${seg(slug as string)}/personajes`)));
server.registerTool('story_generate_personajes', {
title: 'Generar personajes',
description: 'Genera el elenco de personajes (GPT-4o) a partir de la Idea Motriz del proyecto y los guarda (reemplaza los existentes). Requiere Idea Motriz creada.',
inputSchema: { slug: slugField },
annotations: GEN,
}, wrap(async ({ slug }) =>
apiPost(`/api/projects/${seg(slug as string)}/personajes`, { action: 'generate' })));
server.registerTool('story_add_personaje', {
title: 'Añadir personaje',
description: 'Genera y añade UN personaje nuevo (GPT-4o) a partir de nombre, tipo y una descripción breve, integrándolo con los existentes.',
inputSchema: {
slug: slugField,
nombre: z.string().min(1).describe('Nombre del personaje.'),
tipo: z.enum(['protagonista', 'aliado', 'antagonista']).describe('Rol del personaje.'),
descripcion: z.string().min(1).describe('Descripción breve para guiar la generación.'),
},
annotations: GEN,
}, wrap(async ({ slug, nombre, tipo, descripcion }) =>
apiPost(`/api/projects/${seg(slug as string)}/personajes`, {
action: 'add', nuevoPersonaje: { nombre, tipo, descripcion },
})));
server.registerTool('story_improve_personajes', {
title: 'Mejorar personajes',
description: 'Mejora TODO el elenco con feedback en lenguaje natural (GPT-4o) y guarda el resultado.',
inputSchema: {
slug: slugField,
feedback: z.string().min(1).describe('Instrucciones de mejora sobre el conjunto de personajes.'),
},
annotations: GEN,
}, wrap(async ({ slug, feedback }) => {
const s = slug as string;
const personajes = await apiGet(`/api/projects/${seg(s)}/personajes`);
return apiPost(`/api/projects/${seg(s)}/personajes`, { action: 'improve', feedback, personajes });
}));
// ============================================================
// CHARACTER ASSETS (imágenes de personaje)
// ============================================================
server.registerTool('story_get_character_assets', {
title: 'Ver assets de personaje',
description: 'Devuelve los assets de imagen de un personaje (T-poses, emociones): prompts, estado y URLs. baseLocked indica si la imagen base está fijada como referencia.',
inputSchema: { slug: slugField, personajeId: z.string().min(1).describe('id del personaje (de story_get_personajes).') },
annotations: READ,
}, wrap(async ({ slug, personajeId }) =>
apiGet(`/api/projects/${seg(slug as string)}/personajes/${seg(personajeId as string)}/assets`)));
server.registerTool('story_generate_asset_prompts', {
title: 'Generar prompts de assets',
description: 'Paso 1 del workflow de assets: genera con GPT-4o los prompts de las 9 imágenes (3 T-poses + 6 emociones) del personaje.',
inputSchema: { slug: slugField, personajeId: z.string().min(1).describe('id del personaje.') },
annotations: GEN,
}, wrap(async ({ slug, personajeId }) =>
apiPost(`/api/projects/${seg(slug as string)}/personajes/${seg(personajeId as string)}/assets`, { action: 'generate-prompts' })));
server.registerTool('story_generate_base_image', {
title: 'Generar imagen base de personaje',
description: 'Paso 2: genera la imagen base canónica (T-pose frontal) del personaje con Gemini. Acepta un prompt override opcional.',
inputSchema: {
slug: slugField,
personajeId: z.string().min(1).describe('id del personaje.'),
prompt: z.string().optional().describe('Prompt override opcional para la imagen base.'),
},
annotations: GEN,
}, wrap(async ({ slug, personajeId, prompt }) =>
apiPost(`/api/projects/${seg(slug as string)}/personajes/${seg(personajeId as string)}/assets`,
prompt ? { action: 'generate-base-image', prompt } : { action: 'generate-base-image' })));
server.registerTool('story_lock_base_image', {
title: 'Fijar imagen base',
description: 'Paso 3: fija la imagen base como referencia canónica (baseLocked=true). Necesario antes de generar variaciones.',
inputSchema: { slug: slugField, personajeId: z.string().min(1).describe('id del personaje.') },
annotations: GEN,
}, wrap(async ({ slug, personajeId }) =>
apiPost(`/api/projects/${seg(slug as string)}/personajes/${seg(personajeId as string)}/assets`, { action: 'lock-base' })));
server.registerTool('story_generate_all_variations', {
title: 'Generar todas las variaciones de personaje',
description: 'Paso 4: genera con Gemini (img2img desde la base) todas las T-poses y emociones pendientes del personaje. Requiere baseLocked. Puede tardar minutos.',
inputSchema: { slug: slugField, personajeId: z.string().min(1).describe('id del personaje.') },
annotations: GEN,
}, wrap(async ({ slug, personajeId }) =>
apiPost(`/api/projects/${seg(slug as string)}/personajes/${seg(personajeId as string)}/assets`, { action: 'generate-all-variations' })));
// ============================================================
// CAPÍTULOS
// ============================================================
server.registerTool('story_list_capitulos', {
title: 'Listar capítulos',
description: 'Lista los capítulos del proyecto (número, título, estado, paso actual).',
inputSchema: { slug: slugField },
annotations: READ,
}, wrap(async ({ slug }) => apiGet(`/api/projects/${seg(slug as string)}/capitulos`)));
server.registerTool('story_create_capitulo', {
title: 'Crear capítulo',
description: 'Crea un capítulo. Si pasas virtudSugerida, además genera y guarda su guión conductor (GPT-4o) automáticamente.',
inputSchema: {
slug: slugField,
numero: numeroField,
titulo: z.string().optional().describe('Título del capítulo (opcional; default "Capítulo N").'),
virtudSugerida: z.string().optional().describe('Si se indica, autogenera el guión conductor con esta virtud.'),
},
annotations: GEN,
}, wrap(async ({ slug, numero, titulo, virtudSugerida }) => {
const body: Record<string, unknown> = { numero };
if (titulo !== undefined) body.titulo = titulo;
if (virtudSugerida !== undefined) body.virtudSugerida = virtudSugerida;
return apiPost(`/api/projects/${seg(slug as string)}/capitulos`, body);
}));
// ============================================================
// GUIÓN CONDUCTOR
// ============================================================
server.registerTool('story_get_guion', {
title: 'Ver guión conductor',
description: 'Devuelve el guión conductor del capítulo (título, virtud, nudo del mundo, aliado, actos/segmentos, markdown) o null si no existe.',
inputSchema: { slug: slugField, numero: numeroField },
annotations: READ,
}, wrap(async ({ slug, numero }) => apiGet(`${capBase(slug as string, numero as number)}/guion`)));
server.registerTool('story_generate_guion', {
title: 'Generar guión conductor',
description: 'Genera con GPT-4o el guión conductor del capítulo (a partir de Idea Motriz + personajes) y lo guarda. Requiere Idea Motriz y personajes.',
inputSchema: { slug: slugField, numero: numeroField },
annotations: GEN,
}, wrap(async ({ slug, numero }) =>
apiPost(`${capBase(slug as string, numero as number)}/guion`, { action: 'generate' })));
// ============================================================
// PRE-ESCALETA (esqueleto de planos, computado sin LLM)
// ============================================================
server.registerTool('story_get_pre_escaleta', {
title: 'Ver pre-escaleta',
description: 'Devuelve el esqueleto de planos (segmentos y número de planos por segmento) del capítulo, o null.',
inputSchema: { slug: slugField, numero: numeroField },
annotations: READ,
}, wrap(async ({ slug, numero }) => apiGet(`${capBase(slug as string, numero as number)}/pre-escaleta`)));
server.registerTool('story_generate_pre_escaleta', {
title: 'Generar pre-escaleta',
description: 'Computa la pre-escaleta (planos por segmento, sin LLM) desde los actos del guión conductor y la guarda. Requiere un guión con actos estructurados.',
inputSchema: { slug: slugField, numero: numeroField },
annotations: GEN,
}, wrap(async ({ slug, numero }) => {
const s = slug as string; const n = numero as number;
const guion = await apiGet(`${capBase(s, n)}/guion`);
if (!guion || !(guion as any).actos?.length) {
throw new ApiError(400, '', 'El guión no tiene actos estructurados. Usa story_generate_guion primero.');
}
return apiPost(`${capBase(s, n)}/pre-escaleta`, { action: 'generate', guion });
}));
// ============================================================
// ESCALETA (desglose de planos con personajes)
// ============================================================
server.registerTool('story_get_escaleta', {
title: 'Ver escaleta',
description: 'Devuelve la escaleta del capítulo (planos con acción, sonido, personajes en plano y duración) o null.',
inputSchema: { slug: slugField, numero: numeroField },
annotations: READ,
}, wrap(async ({ slug, numero }) => apiGet(`${capBase(slug as string, numero as number)}/escaleta`)));
server.registerTool('story_generate_escaleta', {
title: 'Generar escaleta',
description: 'Genera con GPT-4o la escaleta (desglose de planos) desde el guión conductor y la pre-escaleta, y la guarda. Requiere guión y pre-escaleta creados.',
inputSchema: { slug: slugField, numero: numeroField },
annotations: GEN,
}, wrap(async ({ slug, numero }) => {
const s = slug as string; const n = numero as number;
const guion = await apiGet(`${capBase(s, n)}/guion`);
if (!guion || !(guion as any).actos?.length) {
throw new ApiError(400, '', 'Falta el guión con actos. Usa story_generate_guion y story_generate_pre_escaleta antes.');
}
return apiPost(`${capBase(s, n)}/escaleta`, { action: 'generate', guion });
}));
// ============================================================
// PROMPTS DE IMAGEN (por plano)
// ============================================================
server.registerTool('story_get_prompts', {
title: 'Ver prompts de imagen',
description: 'Devuelve el resumen de prompts de imagen por plano del capítulo (markdown + total) o null.',
inputSchema: { slug: slugField, numero: numeroField },
annotations: READ,
}, wrap(async ({ slug, numero }) => apiGet(`${capBase(slug as string, numero as number)}/prompts`)));
server.registerTool('story_generate_prompts', {
title: 'Generar prompts de imagen',
description: 'Genera con GPT-4o los prompts de imagen de todos los planos (desde la escaleta) y los guarda. Requiere escaleta creada.',
inputSchema: { slug: slugField, numero: numeroField },
annotations: GEN,
}, wrap(async ({ slug, numero }) =>
apiPost(`${capBase(slug as string, numero as number)}/prompts`, { action: 'generate' })));
// ============================================================
// FONDOS (escenarios de referencia)
// ============================================================
server.registerTool('story_get_fondos', {
title: 'Ver fondos',
description: 'Lista los fondos de referencia del capítulo (uno por escenario/segmento) con su estado de imagen.',
inputSchema: { slug: slugField, numero: numeroField },
annotations: READ,
}, wrap(async ({ slug, numero }) => apiGet(`${capBase(slug as string, numero as number)}/fondos`)));
server.registerTool('story_refresh_fondos', {
title: 'Recalcular fondos',
description: 'Re-extrae la lista de fondos desde el guión/escaleta (sin generar imágenes). Útil tras cambiar la escaleta.',
inputSchema: { slug: slugField, numero: numeroField },
annotations: GEN,
}, wrap(async ({ slug, numero }) =>
apiPost(`${capBase(slug as string, numero as number)}/fondos`, { action: 'refresh' })));
server.registerTool('story_generate_fondo', {
title: 'Generar imagen de un fondo',
description: 'Genera con Gemini la imagen de UN fondo (escenario vacío, sin personajes) por su id.',
inputSchema: { slug: slugField, numero: numeroField, fondoId: z.string().min(1).describe('id del fondo (p.ej. "fondo-I.1").') },
annotations: GEN,
}, wrap(async ({ slug, numero, fondoId }) =>
apiPost(`${capBase(slug as string, numero as number)}/fondos`, { action: 'generate', fondoId })));
server.registerTool('story_generate_all_fondos', {
title: 'Generar todos los fondos',
description: 'Genera con Gemini las imágenes de todos los fondos del capítulo. Puede tardar varios minutos.',
inputSchema: { slug: slugField, numero: numeroField },
annotations: GEN,
}, wrap(async ({ slug, numero }) =>
apiPost(`${capBase(slug as string, numero as number)}/fondos`, { action: 'generate-all' })));
// ============================================================
// IMÁGENES DE PLANO
// ============================================================
server.registerTool('story_get_imagenes', {
title: 'Ver imágenes de plano',
description: 'Devuelve los planos del capítulo con su estado de imagen (generado / con prompt) y contadores.',
inputSchema: { slug: slugField, numero: numeroField },
annotations: READ,
}, wrap(async ({ slug, numero }) => apiGet(`${capBase(slug as string, numero as number)}/imagenes`)));
server.registerTool('story_generate_plano_image', {
title: 'Generar imagen de un plano',
description: 'Genera con Gemini la imagen de UN plano por su id (usando el prompt del plano + referencias de personaje/fondo).',
inputSchema: { slug: slugField, numero: numeroField, planoId: z.string().min(1).describe('id del plano (p.ej. "plano-001").') },
annotations: GEN,
}, wrap(async ({ slug, numero, planoId }) =>
apiPost(`${capBase(slug as string, numero as number)}/imagenes`, { action: 'generate', planoId })));
server.registerTool('story_generate_all_images', {
title: 'Generar todas las imágenes de plano',
description: 'Genera con Gemini las imágenes de todos los planos del capítulo que tengan prompt. Puede tardar bastante (una llamada por plano).',
inputSchema: { slug: slugField, numero: numeroField },
annotations: GEN,
}, wrap(async ({ slug, numero }) =>
apiPost(`${capBase(slug as string, numero as number)}/imagenes`, { action: 'generate-all' })));
// ============================================================
// VÍDEOS (por plano)
// ============================================================
server.registerTool('story_get_videos', {
title: 'Ver vídeos de plano',
description: 'Devuelve (compacto) los planos del capítulo: id, numeroPlano, si tiene imagen, y sus variaciones de vídeo (id, status queued|processing|ready|error, seleccionada) + contadores generated/pending. En capítulos grandes (100+ planos) pasa `planoId` para traer solo ese plano.',
inputSchema: {
slug: slugField,
numero: numeroField,
planoId: z.string().optional().describe('Opcional: filtra a un único plano (recomendado en capítulos grandes para no exceder el límite de respuesta).'),
},
annotations: READ,
}, wrap(async ({ slug, numero, planoId }) =>
compactVideos(await apiGet(`${capBase(slug as string, numero as number)}/videos`), planoId as string | undefined)));
server.registerTool('story_generate_plano_video', {
title: 'Generar vídeo de un plano',
description: 'Genera una variación de vídeo para un plano (image-to-video) a partir de su imagen. Opcionalmente con un prompt de movimiento personalizado. La generación de vídeo puede ser async según el proveedor configurado.',
inputSchema: {
slug: slugField,
numero: numeroField,
planoId: z.string().min(1).describe('id del plano.'),
promptVideo: z.string().optional().describe('Prompt de movimiento opcional (si se omite se compone desde el plano).'),
},
annotations: GEN,
}, wrap(async ({ slug, numero, planoId, promptVideo }) => {
const body: Record<string, unknown> = { action: 'generate', planoId };
if (promptVideo !== undefined) body.promptVideo = promptVideo;
return apiPost(`${capBase(slug as string, numero as number)}/videos`, body);
}));
server.registerTool('story_sync_video_jobs', {
title: 'Sincronizar jobs de vídeo',
description: 'Avanza los jobs de vídeo pendientes (queued/processing) del capítulo: consulta el estado en H3, descarga los que estén listos y los marca "ready". Llama a esta tool repetidamente (cada ~45 s) tras story_generate_plano_video hasta que "pending" sea 0. Sincroniza TODO el capítulo server-side; pasa `planoId` para que la respuesta traiga solo ese plano. Devuelve { planos, generated, pending }.',
inputSchema: {
slug: slugField,
numero: numeroField,
planoId: z.string().optional().describe('Opcional: filtra la respuesta a un único plano (el sync server-side sigue afectando a todo el capítulo).'),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
}, wrap(async ({ slug, numero, planoId }) =>
compactVideos(await apiPost(`${capBase(slug as string, numero as number)}/videos`, { action: 'sync-jobs' }), planoId as string | undefined)));
// ============================================================
// RENDER FINAL (Remotion)
// ============================================================
server.registerTool('story_render_chapter', {
title: 'Renderizar capítulo (Remotion)',
description: 'Lanza el render final del capítulo (Remotion) en background y devuelve un jobId. Consulta el progreso con story_get_render_status.',
inputSchema: { slug: slugField, numero: numeroField },
annotations: GEN,
}, wrap(async ({ slug, numero }) =>
apiPost('/api/render', { projectSlug: slug, capituloNumero: numero })));
server.registerTool('story_get_render_status', {
title: 'Estado de un render',
description: 'Consulta el progreso/estado de un job de render por su jobId (devuelto por story_render_chapter).',
inputSchema: { jobId: z.string().min(1).describe('jobId devuelto por story_render_chapter.') },
annotations: READ,
}, wrap(async ({ jobId }) => apiGet(`/api/render?jobId=${seg(jobId as string)}`)));
// ============================================================
// Arranque (stdio)
// ============================================================
async function main(): Promise<void> {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`[story-studio-mcp] conectado (stdio). API: ${BASE_URL}`);
}
main().catch((err) => {
console.error('[story-studio-mcp] error fatal:', err);
process.exit(1);
});

View File

@@ -0,0 +1,38 @@
// Test manual del MCP server: lanza el server por stdio, lista tools y llama una de lectura.
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const url = process.env.STORY_STUDIO_URL || 'https://story-studio.carlosnarro.com';
const transport = new StdioClientTransport({
command: 'node',
args: ['dist/index.js'],
env: { ...process.env, STORY_STUDIO_URL: url },
});
const client = new Client({ name: 'test-client', version: '1.0.0' });
await client.connect(transport);
const { tools } = await client.listTools();
console.log(`\nTOOLS (${tools.length}):`);
console.log(tools.map((t) => t.name).join(', '));
console.log('\n== call story_list_projects ==');
const res = await client.callTool({ name: 'story_list_projects', arguments: {} });
const text = res.content?.[0]?.text ?? '';
console.log('isError:', res.isError === true);
console.log(text.slice(0, 600));
// Prueba una segunda tool de lectura con un slug real si hay proyectos.
try {
const projs = JSON.parse(text);
const slug = Array.isArray(projs) && projs[0]?.slug;
if (slug) {
console.log(`\n== call story_get_idea_motriz (slug=${slug}) ==`);
const r2 = await client.callTool({ name: 'story_get_idea_motriz', arguments: { slug } });
console.log('isError:', r2.isError === true, '| len:', (r2.content?.[0]?.text ?? '').length);
}
} catch { /* ignore */ }
await client.close();
console.log('\nOK');

18
mcp-server/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -58,13 +58,19 @@ export async function GET(
return NextResponse.json(null); return NextResponse.json(null);
} }
// Migrate legacy planos if needed // Migrar SOLO si hay datos legacy reales que convertir (personajesEnPlano sin personajesDetalle).
// Antes la condición era `personajesDetalle vacío`, que nunca se apaga (planos de establecimiento
// sin personajes quedan con []), reescribiendo toda la BD en cada GET y devolviendo datos sin migrar.
let planos = escaleta.planos || []; let planos = escaleta.planos || [];
if (planos.length > 0 && planos.some((p: any) => !p.personajesDetalle || p.personajesDetalle.length === 0)) { const needsMigration = planos.some((p: any) =>
(!Array.isArray(p.personajesDetalle) || p.personajesDetalle.length === 0) &&
Array.isArray(p.personajesEnPlano) && p.personajesEnPlano.length > 0
);
if (needsMigration) {
const personajes = getPersonajes(slug); const personajes = getPersonajes(slug);
planos = migrateLegacyPlanos(planos, personajes); planos = migrateLegacyPlanos(planos, personajes);
// Persist migration saveEscaleta(slug, parseInt(numero, 10), { ...escaleta, planos });
saveEscaleta(slug, parseInt(numero), { ...escaleta, planos }); return NextResponse.json({ ...escaleta, planos });
} }
return NextResponse.json(escaleta); return NextResponse.json(escaleta);

View File

@@ -17,14 +17,15 @@ export async function GET(
) { ) {
try { try {
const { slug, numero, fondoId } = await params; const { slug, numero, fondoId } = await params;
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos'); const capitulosDir = path.join(PROJECTS_DIR, path.basename(slug), 'capitulos');
const capDir = await findCapDir(capitulosDir, numero); const capDir = await findCapDir(capitulosDir, numero);
if (!capDir) { if (!capDir) {
return NextResponse.json({ error: 'Capítulo no encontrado' }, { status: 404 }); return NextResponse.json({ error: 'Capítulo no encontrado' }, { status: 404 });
} }
const imagePath = path.join(capitulosDir, capDir.name, 'fondos', `${fondoId}.png`); // path.basename evita traversal por el id (p.ej. "../../..").
const imagePath = path.join(capitulosDir, capDir.name, 'fondos', `${path.basename(fondoId)}.png`);
try { try {
const imageBuffer = await fs.readFile(imagePath); const imageBuffer = await fs.readFile(imagePath);

View File

@@ -3,7 +3,7 @@ import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import { loadImage, generateSceneImage, savePlanoImage, getPlanoImagePath } from '@/lib/image-service'; import { loadImage, generateSceneImage, savePlanoImage, getPlanoImagePath } from '@/lib/image-service';
import { import {
getPromptsPlanos, savePromptsPlanos, getEscaleta, getPromptsPlanos, getEscaleta,
getCapDirName, savePromptComposition, updatePromptPlano, getFondos, getCapDirName, savePromptComposition, updatePromptPlano, getFondos,
getPersonajes, getCharacterAssetsMap, getIdeaMotriz, getPersonajes, getCharacterAssetsMap, getIdeaMotriz,
} from '@/lib/db-service'; } from '@/lib/db-service';
@@ -189,7 +189,8 @@ export async function POST(
const imagenUrl = await savePlanoImage(slug, capDirName, plano.id, imageBuffer); const imagenUrl = await savePlanoImage(slug, capDirName, plano.id, imageBuffer);
planos[planoIdx] = { ...plano, imagenUrl }; planos[planoIdx] = { ...plano, imagenUrl };
savePromptsPlanos(slug, num, planos); // Update por fila (no reescribir todo el set) para no pisar generaciones concurrentes.
updatePromptPlano(slug, num, plano.id, { imagenUrl });
return NextResponse.json({ plano: { ...planos[planoIdx], referenceAssets: resolvedRefs } }); return NextResponse.json({ plano: { ...planos[planoIdx], referenceAssets: resolvedRefs } });
} }
@@ -215,7 +216,7 @@ export async function POST(
); );
const imagenUrl = await savePlanoImage(slug, capDirName, plano.id, imageBuffer); const imagenUrl = await savePlanoImage(slug, capDirName, plano.id, imageBuffer);
planos[i] = { ...plano, imagenUrl }; planos[i] = { ...plano, imagenUrl };
savePromptsPlanos(slug, num, planos); updatePromptPlano(slug, num, plano.id, { imagenUrl });
results.push({ id: plano.id, success: true }); results.push({ id: plano.id, success: true });
} catch (err: any) { } catch (err: any) {
console.error(`Error generando imagen para ${plano.id}:`, err); console.error(`Error generando imagen para ${plano.id}:`, err);

View File

@@ -3,6 +3,8 @@ import fs from 'fs/promises';
import path from 'path'; import path from 'path';
const PROJECTS_DIR = path.join(process.cwd(), 'projects'); const PROJECTS_DIR = path.join(process.cwd(), 'projects');
// Origen permitido para el Remotion Studio de desarrollo (configurable; default = local previo).
const STUDIO_ORIGIN = process.env.REMOTION_STUDIO_ORIGIN || 'http://localhost:3001';
async function findCapDir(capitulosDir: string, numero: string) { async function findCapDir(capitulosDir: string, numero: string) {
const dirs = await fs.readdir(capitulosDir, { withFileTypes: true }); const dirs = await fs.readdir(capitulosDir, { withFileTypes: true });
@@ -17,14 +19,15 @@ export async function GET(
) { ) {
try { try {
const { slug, numero, planoId } = await params; const { slug, numero, planoId } = await params;
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos'); const capitulosDir = path.join(PROJECTS_DIR, path.basename(slug), 'capitulos');
const capDir = await findCapDir(capitulosDir, numero); const capDir = await findCapDir(capitulosDir, numero);
if (!capDir) { if (!capDir) {
return NextResponse.json({ error: 'Capítulo no encontrado' }, { status: 404 }); return NextResponse.json({ error: 'Capítulo no encontrado' }, { status: 404 });
} }
const videoPath = path.join(PROJECTS_DIR, slug, 'capitulos', capDir.name, 'videos', `${planoId}.mp4`); // path.basename evita traversal por el id.
const videoPath = path.join(PROJECTS_DIR, path.basename(slug), 'capitulos', capDir.name, 'videos', `${path.basename(planoId)}.mp4`);
try { try {
const videoBuffer = await fs.readFile(videoPath); const videoBuffer = await fs.readFile(videoPath);
@@ -32,7 +35,7 @@ export async function GET(
headers: { headers: {
'Content-Type': 'video/mp4', 'Content-Type': 'video/mp4',
'Cache-Control': 'no-store, must-revalidate', 'Cache-Control': 'no-store, must-revalidate',
'Access-Control-Allow-Origin': 'http://localhost:3001', 'Access-Control-Allow-Origin': STUDIO_ORIGIN,
'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Allow-Headers': 'Content-Type',
}, },
@@ -43,7 +46,7 @@ export async function GET(
{ {
status: 404, status: 404,
headers: { headers: {
'Access-Control-Allow-Origin': 'http://localhost:3001', 'Access-Control-Allow-Origin': STUDIO_ORIGIN,
}, },
} }
); );
@@ -58,7 +61,7 @@ export async function OPTIONS() {
return new NextResponse(null, { return new NextResponse(null, {
status: 200, status: 200,
headers: { headers: {
'Access-Control-Allow-Origin': 'http://localhost:3001', 'Access-Control-Allow-Origin': STUDIO_ORIGIN,
'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Allow-Headers': 'Content-Type',
}, },

View File

@@ -108,9 +108,10 @@ export async function POST(
return NextResponse.json({ error: 'No se pudo leer la imagen del plano' }, { status: 500 }); return NextResponse.json({ error: 'No se pudo leer la imagen del plano' }, { status: 500 });
} }
// Resolución por plano: 16:9 (default) o 9:16 según body.aspect. // Resolución por plano al máximo legal de H3 (w*h <= 1.032.192 px, múltiplos de 32):
// 16:9 (default) = 1344x768 · 9:16 = 768x1344. video-service normaliza igualmente al contrato.
const aspect = body.aspect === '9:16' ? '9:16' : '16:9'; const aspect = body.aspect === '9:16' ? '9:16' : '16:9';
const [width, height] = aspect === '9:16' ? [480, 832] : [832, 480]; const [width, height] = aspect === '9:16' ? [768, 1344] : [1344, 768];
console.log(`[Videos API] Encolando vídeo H3 para plano ${planoId} (${aspect})`); console.log(`[Videos API] Encolando vídeo H3 para plano ${planoId} (${aspect})`);

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getCapitulos, createCapitulo, getIdeaMotriz, getPersonajes } from '@/lib/db-service'; import { getCapitulos, createCapitulo, getIdeaMotriz, getPersonajes, saveGuionConductor } from '@/lib/db-service';
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
@@ -32,7 +32,9 @@ export async function POST(
if (ideaMotriz && personajes.length > 0) { if (ideaMotriz && personajes.length > 0) {
const { generateGuionConductor } = await import('@/lib/llm-service'); const { generateGuionConductor } = await import('@/lib/llm-service');
await generateGuionConductor(ideaMotriz, personajes, numero, virtudSugerida); // Antes se descartaba el resultado: se pagaba la llamada a GPT-4o y el guión se perdía.
const guion = await generateGuionConductor(ideaMotriz, personajes, numero, virtudSugerida);
saveGuionConductor(slug, numero, guion);
} }
} }

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getPersonajes, getCharacterAssetsData, saveCharacterAssetsData, getIdeaMotriz, getVariationTemplates } from '@/lib/db-service'; import { getPersonajes, getCharacterAssetsData, saveCharacterAssetsData, updateCharacterAsset, getIdeaMotriz, getVariationTemplates } from '@/lib/db-service';
import { generateCharacterAssetPrompts } from '@/lib/llm-service'; import { generateCharacterAssetPrompts } from '@/lib/llm-service';
import { generateImage, generateVariation, saveImage, loadImage } from '@/lib/image-service'; import { generateImage, generateVariation, saveImage, loadImage } from '@/lib/image-service';
import { resolveEstilo, withEstilo } from '@/lib/prompt-utils'; import { resolveEstilo, withEstilo } from '@/lib/prompt-utils';
@@ -148,7 +148,7 @@ export async function POST(
return NextResponse.json({ error: 'Imagen base no encontrada en disco' }, { status: 400 }); return NextResponse.json({ error: 'Imagen base no encontrada en disco' }, { status: 400 });
} }
targetAsset.status = 'generating'; targetAsset.status = 'generating';
saveCharacterAssetsData(slug, id, data); updateCharacterAsset(slug, id, targetAsset.id, { status: 'generating' });
const templates = getVariationTemplates(slug); const templates = getVariationTemplates(slug);
const templateKey = `${targetAsset.type}-${targetAsset.variant}`; const templateKey = `${targetAsset.type}-${targetAsset.variant}`;
@@ -158,7 +158,8 @@ export async function POST(
const imageUrl = await saveImage(slug, id, targetAsset.id, imageBuffer); const imageUrl = await saveImage(slug, id, targetAsset.id, imageBuffer);
targetAsset.imageUrl = imageUrl; targetAsset.imageUrl = imageUrl;
targetAsset.status = 'generated'; targetAsset.status = 'generated';
saveCharacterAssetsData(slug, id, data); // Update por fila (no reescribir todo el set) para no pisar generaciones concurrentes.
updateCharacterAsset(slug, id, targetAsset.id, { imageUrl, status: 'generated' });
return NextResponse.json(data); return NextResponse.json(data);
} }
@@ -182,18 +183,18 @@ export async function POST(
for (const asset of pending) { for (const asset of pending) {
try { try {
asset.status = 'generating'; asset.status = 'generating';
saveCharacterAssetsData(slug, id, data); updateCharacterAsset(slug, id, asset.id, { status: 'generating' });
const templateKey = `${asset.type}-${asset.variant}`; const templateKey = `${asset.type}-${asset.variant}`;
const variationPrompt = templates[templateKey] || asset.prompt; const variationPrompt = templates[templateKey] || asset.prompt;
const imageBuffer = await generateVariation(refImage, withEstilo(variationPrompt, estilo)); const imageBuffer = await generateVariation(refImage, withEstilo(variationPrompt, estilo));
const imageUrl = await saveImage(slug, id, asset.id, imageBuffer); const imageUrl = await saveImage(slug, id, asset.id, imageBuffer);
asset.imageUrl = imageUrl; asset.imageUrl = imageUrl;
asset.status = 'generated'; asset.status = 'generated';
saveCharacterAssetsData(slug, id, data); updateCharacterAsset(slug, id, asset.id, { imageUrl, status: 'generated' });
} catch (err: any) { } catch (err: any) {
console.error(`Error generando variación ${asset.id}:`, err); console.error(`Error generando variación ${asset.id}:`, err);
asset.status = 'prompt_ready'; asset.status = 'prompt_ready';
saveCharacterAssetsData(slug, id, data); updateCharacterAsset(slug, id, asset.id, { status: 'prompt_ready' });
} }
} }
return NextResponse.json(data); return NextResponse.json(data);

View File

@@ -1,6 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getPromptsPlanos, getVideoVariations } from '@/lib/db-service'; import { getPromptsPlanos, getVideoVariations } from '@/lib/db-service';
// Origen desde el que Remotion (headless Chromium, mismo contenedor) descarga los vídeos, y origen
// permitido para el Remotion Studio de desarrollo. Configurables por env; defaults = comportamiento
// local previo (en el contenedor Dokploy `localhost:3000` resuelve a la propia app).
const MEDIA_ORIGIN = process.env.REMOTION_MEDIA_ORIGIN || 'http://localhost:3000';
const STUDIO_ORIGIN = process.env.REMOTION_STUDIO_ORIGIN || 'http://localhost:3001';
interface PlanoVideoData { interface PlanoVideoData {
id: string; id: string;
numeroPlano: string; numeroPlano: string;
@@ -38,7 +44,7 @@ export async function GET(
// Convert file path to HTTP URL for browser playback // Convert file path to HTTP URL for browser playback
// Format: /api/projects/{slug}/capitulos/{numero}/videos/{planoId}-{variationId} // Format: /api/projects/{slug}/capitulos/{numero}/videos/{planoId}-{variationId}
const videoUrl = `http://localhost:3000/api/projects/${slug}/capitulos/${String(capituloNumero).padStart(2, '0')}/videos/${plano.id}-${selected.id}`; const videoUrl = `${MEDIA_ORIGIN}/api/projects/${slug}/capitulos/${String(capituloNumero).padStart(2, '0')}/videos/${plano.id}-${selected.id}`;
return { return {
id: plano.id, id: plano.id,
@@ -63,7 +69,7 @@ export async function GET(
// Return with CORS headers for Remotion Studio // Return with CORS headers for Remotion Studio
return NextResponse.json(chapterData, { return NextResponse.json(chapterData, {
headers: { headers: {
'Access-Control-Allow-Origin': 'http://localhost:3001', 'Access-Control-Allow-Origin': STUDIO_ORIGIN,
'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Allow-Headers': 'Content-Type',
}, },
@@ -75,7 +81,7 @@ export async function GET(
{ {
status: 500, status: 500,
headers: { headers: {
'Access-Control-Allow-Origin': 'http://localhost:3001', 'Access-Control-Allow-Origin': STUDIO_ORIGIN,
}, },
} }
); );
@@ -87,7 +93,7 @@ export async function OPTIONS() {
return new NextResponse(null, { return new NextResponse(null, {
status: 200, status: 200,
headers: { headers: {
'Access-Control-Allow-Origin': 'http://localhost:3001', 'Access-Control-Allow-Origin': STUDIO_ORIGIN,
'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Allow-Headers': 'Content-Type',
}, },

View File

@@ -1,8 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { renderChapterVideo, RenderProgress } from '@/lib/remotion-service'; import { renderChapterVideo, RenderProgress } from '@/lib/remotion-service';
// Simple in-memory queue (in production, use Redis or DB) // Cola en memoria (para producción real convendría Redis/DB). Guardamos updatedAt para poder
const renderQueue = new Map<string, RenderProgress>(); // purgar entradas viejas y no crecer sin límite / dejar jobs zombies tras un reinicio.
const renderQueue = new Map<string, { progress: RenderProgress; updatedAt: number }>();
const JOB_TTL_MS = 60 * 60 * 1000; // 1h
function setJob(jobId: string, progress: RenderProgress): void {
renderQueue.set(jobId, { progress, updatedAt: Date.now() });
}
function pruneJobs(): void {
const now = Date.now();
for (const [id, entry] of renderQueue) {
if (now - entry.updatedAt > JOB_TTL_MS) renderQueue.delete(id);
}
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -16,27 +29,20 @@ export async function POST(request: NextRequest) {
); );
} }
pruneJobs();
const jobId = `${projectSlug}-${capituloNumero}-${Date.now()}`; const jobId = `${projectSlug}-${capituloNumero}-${Date.now()}`;
// Initialize job in queue // Initialize job in queue
renderQueue.set(jobId, { setJob(jobId, { phase: 'loading', progress: 0, message: 'Iniciando...' });
phase: 'loading',
progress: 0,
message: 'Iniciando...',
});
// Start render in background (non-blocking) // Start render in background (non-blocking)
renderChapterVideo( renderChapterVideo(
{ projectSlug, capituloNumero, ...options }, { projectSlug, capituloNumero, ...options },
(progress) => { (progress) => {
renderQueue.set(jobId, progress); setJob(jobId, progress);
} }
).catch((error) => { ).catch((error) => {
renderQueue.set(jobId, { setJob(jobId, { phase: 'error', progress: 0, message: error.message });
phase: 'error',
progress: 0,
message: error.message,
});
}); });
return NextResponse.json({ jobId }); return NextResponse.json({ jobId });
@@ -55,11 +61,11 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Missing jobId' }, { status: 400 }); return NextResponse.json({ error: 'Missing jobId' }, { status: 400 });
} }
const progress = renderQueue.get(jobId); const entry = renderQueue.get(jobId);
if (!progress) { if (!entry) {
return NextResponse.json({ error: 'Job not found' }, { status: 404 }); return NextResponse.json({ error: 'Job not found' }, { status: 404 });
} }
return NextResponse.json(progress); return NextResponse.json(entry.progress);
} }

View File

@@ -84,6 +84,7 @@ export function getProject(slug: string): Project | null {
export function createProject(name: string): Project { export function createProject(name: string): Project {
const slug = slugify(name); const slug = slugify(name);
if (!slug) throw new Error('El nombre del proyecto no es válido (queda vacío al normalizar)');
const existing = getProject(slug); const existing = getProject(slug);
if (existing) throw new Error(`El proyecto "${name}" ya existe`); if (existing) throw new Error(`El proyecto "${name}" ya existe`);
@@ -177,6 +178,7 @@ export function savePersonajes(slug: string, personajes: Personaje[]): void {
if (!projectId) throw new Error('Proyecto no encontrado'); if (!projectId) throw new Error('Proyecto no encontrado');
const db = getDb(); const db = getDb();
const removedIds: string[] = [];
const tx = db.transaction(() => { const tx = db.transaction(() => {
const existingIds = db.prepare('SELECT id FROM personajes WHERE project_id = ?') const existingIds = db.prepare('SELECT id FROM personajes WHERE project_id = ?')
.all(projectId).map((r: any) => r.id); .all(projectId).map((r: any) => r.id);
@@ -187,6 +189,7 @@ export function savePersonajes(slug: string, personajes: Personaje[]): void {
for (const id of existingIds) { for (const id of existingIds) {
if (!newIds.has(id)) { if (!newIds.has(id)) {
db.prepare('DELETE FROM personajes WHERE id = ?').run(id); db.prepare('DELETE FROM personajes WHERE id = ?').run(id);
removedIds.push(id);
} }
} }
@@ -204,6 +207,12 @@ export function savePersonajes(slug: string, personajes: Personaje[]): void {
}); });
tx(); tx();
// Limpiar en disco las imágenes de los personajes borrados (antes quedaban huérfanas).
for (const id of removedIds) {
const dir = path.join(PROJECTS_DIR, slug, 'personajes', id);
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
}
const now = new Date().toISOString(); const now = new Date().toISOString();
db.prepare('UPDATE projects SET updated_at = ? WHERE id = ?').run(now, projectId); db.prepare('UPDATE projects SET updated_at = ? WHERE id = ?').run(now, projectId);
} }
@@ -280,6 +289,32 @@ export function saveCharacterAssetsData(slug: string, personajeId: string, data:
tx(); tx();
} }
/**
* Actualiza UN asset de personaje por fila (status y/o imageUrl), sin reescribir todo el set.
* Evita que una generación individual y un "generar todas" concurrentes se pisen.
*/
export function updateCharacterAsset(
slug: string, personajeId: string, assetId: string,
patch: Partial<Pick<CharacterAsset, 'imageUrl' | 'status'>>
): void {
const projectId = resolveProjectId(slug);
if (!projectId) throw new Error('Proyecto no encontrado');
const sets: string[] = [];
const vals: any[] = [];
if (patch.status !== undefined) { sets.push('status = ?'); vals.push(patch.status); }
if (patch.imageUrl !== undefined) {
sets.push('image_url = ?'); vals.push(patch.imageUrl || null);
sets.push('image_path = ?'); vals.push(patch.imageUrl ? `personajes/${personajeId}/images/${assetId}.png` : null);
}
if (sets.length === 0) return;
vals.push(assetId, personajeId, projectId);
getDb().prepare(
`UPDATE character_assets SET ${sets.join(', ')} WHERE id = ? AND personaje_id = ? AND project_id = ?`
).run(...vals);
}
// ===================== // =====================
// VARIATION TEMPLATES // VARIATION TEMPLATES
// ===================== // =====================
@@ -624,6 +659,12 @@ export function savePromptsPlanos(slug: string, numero: number, planos: PlanoCon
if (!capId) throw new Error('Capítulo no encontrado'); if (!capId) throw new Error('Capítulo no encontrado');
const db = getDb(); const db = getDb();
// IDs que desaparecen tras el guardado → sus imágenes de plano quedarían huérfanas en disco.
const prevIds = db.prepare('SELECT id FROM prompts_planos WHERE capitulo_id = ?')
.all(capId).map((r: any) => r.id) as string[];
const nextIds = new Set(planos.map(p => p.id));
const removedPlanoIds = prevIds.filter(id => !nextIds.has(id));
const tx = db.transaction(() => { const tx = db.transaction(() => {
// Delete existing planos for this chapter // Delete existing planos for this chapter
db.prepare('DELETE FROM prompts_planos WHERE capitulo_id = ?').run(capId); db.prepare('DELETE FROM prompts_planos WHERE capitulo_id = ?').run(capId);
@@ -650,6 +691,18 @@ export function savePromptsPlanos(slug: string, numero: number, planos: PlanoCon
} }
}); });
tx(); tx();
// Borrar en disco las imágenes de planos que ya no existen (huérfanos). Los IDs reutilizados
// no se tocan (regenerar con los mismos IDs conserva la imagen actual).
if (removedPlanoIds.length > 0) {
const capDir = getCapDirName(slug, numero);
if (capDir) {
const imagenesDir = path.join(PROJECTS_DIR, slug, 'capitulos', capDir, 'imagenes');
for (const id of removedPlanoIds) {
try { fs.rmSync(path.join(imagenesDir, `${id}.png`), { force: true }); } catch { /* best-effort */ }
}
}
}
} }
export function updatePromptPlano( export function updatePromptPlano(
@@ -718,6 +771,11 @@ export function saveFondos(slug: string, numero: number, fondos: FondoReferencia
if (!capId) throw new Error('Capítulo no encontrado'); if (!capId) throw new Error('Capítulo no encontrado');
const db = getDb(); const db = getDb();
const prevIds = db.prepare('SELECT id FROM fondos WHERE capitulo_id = ?')
.all(capId).map((r: any) => r.id) as string[];
const nextIds = new Set(fondos.map(f => f.id));
const removedFondoIds = prevIds.filter(id => !nextIds.has(id));
const tx = db.transaction(() => { const tx = db.transaction(() => {
db.prepare('DELETE FROM fondos WHERE capitulo_id = ?').run(capId); db.prepare('DELETE FROM fondos WHERE capitulo_id = ?').run(capId);
@@ -731,6 +789,17 @@ export function saveFondos(slug: string, numero: number, fondos: FondoReferencia
} }
}); });
tx(); tx();
// Borrar imágenes de fondos que ya no existen (huérfanos). IDs reutilizados no se tocan.
if (removedFondoIds.length > 0) {
const capDir = getCapDirName(slug, numero);
if (capDir) {
const fondosDir = path.join(PROJECTS_DIR, slug, 'capitulos', capDir, 'fondos');
for (const id of removedFondoIds) {
try { fs.rmSync(path.join(fondosDir, `${id}.png`), { force: true }); } catch { /* best-effort */ }
}
}
}
} }
export function updateFondo( export function updateFondo(
@@ -882,12 +951,12 @@ export function selectVideoVariation(slug: string, numero: number, planoId: stri
WHERE capitulo_id = ? AND plano_id = ? WHERE capitulo_id = ? AND plano_id = ?
`).run(capId, planoId); `).run(capId, planoId);
// Select the target variation // Select the target variation (validando que pertenezca a este plano/capítulo).
db.prepare(` db.prepare(`
UPDATE video_variations UPDATE video_variations
SET is_selected = 1 SET is_selected = 1
WHERE id = ? WHERE id = ? AND capitulo_id = ? AND plano_id = ?
`).run(variationId); `).run(variationId, capId, planoId);
}); });
tx(); tx();
} }

View File

@@ -192,7 +192,8 @@ function runMigrations(db: Database.Database): void {
db.exec('ALTER TABLE prompts_planos ADD COLUMN prompt_video TEXT'); db.exec('ALTER TABLE prompts_planos ADD COLUMN prompt_video TEXT');
console.log('[Migration] Added prompt_video column to prompts_planos'); console.log('[Migration] Added prompt_video column to prompts_planos');
} catch (e) { } catch (e) {
// Column already exists or other error - safe to ignore // Normalmente "duplicate column" (ya migrada). Logueamos por si es otro error de esquema.
console.warn('[Migration] ALTER prompts_planos falló (posiblemente ya aplicada):', e);
} }
} }
@@ -201,7 +202,8 @@ function runMigrations(db: Database.Database): void {
db.exec('ALTER TABLE prompts_planos ADD COLUMN video_url TEXT'); db.exec('ALTER TABLE prompts_planos ADD COLUMN video_url TEXT');
console.log('[Migration] Added video_url column to prompts_planos'); console.log('[Migration] Added video_url column to prompts_planos');
} catch (e) { } catch (e) {
// Column already exists or other error - safe to ignore // Normalmente "duplicate column" (ya migrada). Logueamos por si es otro error de esquema.
console.warn('[Migration] ALTER prompts_planos falló (posiblemente ya aplicada):', e);
} }
} }

View File

@@ -20,12 +20,18 @@ function getGenAI(): GoogleGenAI {
// IMAGE STORAGE // IMAGE STORAGE
// --------------------- // ---------------------
// Sanea un segmento de ruta que viene de params de URL: nunca debe contener separadores ni "..".
// path.basename descarta cualquier intento de traversal (p.ej. "../../etc/passwd" → "passwd").
function safeSeg(segment: string): string {
return path.basename(String(segment ?? ''));
}
function getImagesDir(slug: string, personajeId: string): string { function getImagesDir(slug: string, personajeId: string): string {
return path.join(PROJECTS_DIR, slug, 'personajes', personajeId, 'images'); return path.join(PROJECTS_DIR, safeSeg(slug), 'personajes', safeSeg(personajeId), 'images');
} }
export function getImagePath(slug: string, personajeId: string, assetId: string): string { export function getImagePath(slug: string, personajeId: string, assetId: string): string {
return path.join(getImagesDir(slug, personajeId), `${assetId}.png`); return path.join(getImagesDir(slug, personajeId), `${safeSeg(assetId)}.png`);
} }
export async function saveImage(slug: string, personajeId: string, assetId: string, imageBuffer: Buffer): Promise<string> { export async function saveImage(slug: string, personajeId: string, assetId: string, imageBuffer: Buffer): Promise<string> {
@@ -190,11 +196,11 @@ export async function generateSceneImage(
// --------------------- // ---------------------
function getPlanoImagesDir(slug: string, capDir: string): string { function getPlanoImagesDir(slug: string, capDir: string): string {
return path.join(PROJECTS_DIR, slug, 'capitulos', capDir, 'imagenes'); return path.join(PROJECTS_DIR, safeSeg(slug), 'capitulos', safeSeg(capDir), 'imagenes');
} }
export function getPlanoImagePath(slug: string, capDir: string, planoId: string): string { export function getPlanoImagePath(slug: string, capDir: string, planoId: string): string {
return path.join(getPlanoImagesDir(slug, capDir), `${planoId}.png`); return path.join(getPlanoImagesDir(slug, capDir), `${safeSeg(planoId)}.png`);
} }
export async function savePlanoImage(slug: string, capDir: string, planoId: string, imageBuffer: Buffer): Promise<string> { export async function savePlanoImage(slug: string, capDir: string, planoId: string, imageBuffer: Buffer): Promise<string> {

View File

@@ -69,10 +69,24 @@ export async function refineIdeaMotriz(current: IdeaMotriz, feedback: string): P
// PERSONAJES // PERSONAJES
// ===================== // =====================
/**
* Garantiza que cada personaje tenga un id único. El LLM a veces omite el id (→ crash al
* bindear undefined en SQLite) o lo repite (→ upsert que sobrescribe uno con otro).
*/
function ensurePersonajeIds(personajes: Personaje[]): Personaje[] {
const seen = new Set<string>();
return (personajes || []).map(p => {
let id = p.id;
if (!id || seen.has(id)) id = crypto.randomUUID();
seen.add(id);
return { ...p, id };
});
}
export async function generatePersonajes(ideaMotriz: IdeaMotriz): Promise<Personaje[]> { export async function generatePersonajes(ideaMotriz: IdeaMotriz): Promise<Personaje[]> {
const content = await callLLM(getPersonajesGeneratePrompt(ideaMotriz)); const content = await callLLM(getPersonajesGeneratePrompt(ideaMotriz));
const result = JSON.parse(content); const result = JSON.parse(content);
return result.personajes as Personaje[]; return ensurePersonajeIds(result.personajes as Personaje[]);
} }
export async function refinePersonaje(personaje: Personaje, feedback: string): Promise<Personaje> { export async function refinePersonaje(personaje: Personaje, feedback: string): Promise<Personaje> {
@@ -83,7 +97,7 @@ export async function refinePersonaje(personaje: Personaje, feedback: string): P
export async function improvePersonajes(personajes: Personaje[], feedback: string): Promise<Personaje[]> { export async function improvePersonajes(personajes: Personaje[], feedback: string): Promise<Personaje[]> {
const content = await callLLM(getPersonajesImprovePrompt(personajes), feedback, { temperature: 0.7 }); const content = await callLLM(getPersonajesImprovePrompt(personajes), feedback, { temperature: 0.7 });
const result = JSON.parse(content); const result = JSON.parse(content);
return result.personajes as Personaje[]; return ensurePersonajeIds(result.personajes as Personaje[]);
} }
export async function generateNewPersonaje( export async function generateNewPersonaje(

View File

@@ -16,7 +16,8 @@
* submitVideoJob() lanza error solo si se invoca (no rompe el arranque). * submitVideoJob() lanza error solo si se invoca (no rompe el arranque).
*/ */
const H3_BASE_URL = (process.env.H3API_BASE_URL || 'https://h3api.essia.coop').replace(/\/$/, ''); // essia-server entrega la env como H3API_URL; aceptamos también H3API_BASE_URL por compatibilidad.
const H3_BASE_URL = (process.env.H3API_URL || process.env.H3API_BASE_URL || 'https://h3api.essia.coop').replace(/\/$/, '');
const H3_API_KEY = process.env.H3API_KEY || ''; const H3_API_KEY = process.env.H3API_KEY || '';
// Modo i2v del MVP: ref2va (imagen->vídeo, validado). fl2va (primer frame exacto, más fiel a // Modo i2v del MVP: ref2va (imagen->vídeo, validado). fl2va (primer frame exacto, más fiel a
@@ -46,11 +47,33 @@ interface H3JobResponse {
export interface SubmitVideoOptions { export interface SubmitVideoOptions {
duration?: number; // segundos, 4-15 (default 5) duration?: number; // segundos, 4-15 (default 5)
cameraFixed?: boolean; // añade indicación de movimiento mínimo al prompt cameraFixed?: boolean; // añade indicación de movimiento mínimo al prompt
width?: number; // múltiplo de 32 (default 832 = 16:9) width?: number; // se ajusta a la rejilla de 32 px de H3 (default 832)
height?: number; // múltiplo de 32 (default 480 = 16:9) height?: number; // ídem (default 480); tope H3: width*height <= 1.032.192 px
mode?: string; // 'ref2va' (default) | 'fl2va' | 't2va' mode?: string; // 'ref2va' (default) | 'fl2va' | 't2va'
} }
// Contrato de resolución del nodo H3 (verificado 2026-08-17 contra H3ContractError del RH node):
// width/height deben ser múltiplos de 32 y width*height <= 1_032_192 px (= 1344x768, su máximo
// ~16:9). Fuera de eso el render falla a los pocos segundos ya en el server, así que ajustamos
// aquí para que ningún caller pueda encolar un job imposible (p.ej. 1280x720 o 1920x1088).
const H3_MAX_PIXELS = 1_032_192;
function normalizeResolution(width: number, height: number): { width: number; height: number } {
const snap = (v: number) => Math.max(32, Math.round(v / 32) * 32);
let w = snap(width);
let h = snap(height);
if (w * h > H3_MAX_PIXELS) {
const scale = Math.sqrt(H3_MAX_PIXELS / (w * h));
w = snap(w * scale);
h = snap(h * scale);
while (w * h > H3_MAX_PIXELS) {
if (w >= h) w -= 32;
else h -= 32;
}
}
return { width: w, height: h };
}
export const H3_COST_USD = COST_PER_VIDEO_USD; export const H3_COST_USD = COST_PER_VIDEO_USD;
function ensureConfigured(): void { function ensureConfigured(): void {
@@ -88,8 +111,12 @@ export async function submitVideoJob(
ensureConfigured(); ensureConfigured();
const dur = Math.min(15, Math.max(4, options.duration || 5)); const dur = Math.min(15, Math.max(4, options.duration || 5));
const width = options.width ?? 832; const requestedW = options.width ?? 832;
const height = options.height ?? 480; const requestedH = options.height ?? 480;
const { width, height } = normalizeResolution(requestedW, requestedH);
if (width !== requestedW || height !== requestedH) {
console.log(`[Video Service/H3] Resolución ajustada al contrato H3: ${requestedW}x${requestedH} -> ${width}x${height}`);
}
const mode = options.mode || DEFAULT_I2V_MODE; const mode = options.mode || DEFAULT_I2V_MODE;
const fullPrompt = options.cameraFixed const fullPrompt = options.cameraFixed
? `${prompt} Minimal camera movement, subtle motion only.` ? `${prompt} Minimal camera movement, subtle motion only.`