Compare commits
5 Commits
a698c6b225
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37f92dc402 | ||
|
|
2ff41ad2bc | ||
|
|
f58ab93646 | ||
|
|
659edfa68e | ||
|
|
5aa5129865 |
@@ -3,6 +3,9 @@
|
||||
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
|
||||
projects
|
||||
data
|
||||
|
||||
3
mcp-server/.gitignore
vendored
Normal file
3
mcp-server/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
75
mcp-server/README.md
Normal file
75
mcp-server/README.md
Normal 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
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
29
mcp-server/package.json
Normal 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
107
mcp-server/src/api.ts
Normal 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
489
mcp-server/src/index.ts
Normal 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);
|
||||
});
|
||||
38
mcp-server/test-client.mjs
Normal file
38
mcp-server/test-client.mjs
Normal 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
18
mcp-server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { generateVideo, downloadVideo } from '@/lib/video-service';
|
||||
import { submitVideoJob, getJobStatus, downloadVideo, H3_COST_USD } from '@/lib/video-service';
|
||||
import {
|
||||
getPromptsPlanos, updatePromptPlano, getCapDirName,
|
||||
getVideoVariations, saveVideoVariation, selectVideoVariation, deleteVideoVariation,
|
||||
getPendingVideoVariations, updateVideoVariationProgress,
|
||||
} from '@/lib/db-service';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
@@ -34,13 +35,18 @@ export async function GET(
|
||||
};
|
||||
});
|
||||
|
||||
const generated = planosWithVariations.filter(p => p.videoVariations && p.videoVariations.length > 0).length;
|
||||
const generated = planosWithVariations.filter(p => p.videoVariations.some(v => v.status === 'ready')).length;
|
||||
const pending = planosWithVariations.reduce(
|
||||
(n, p) => n + p.videoVariations.filter(v => v.status === 'queued' || v.status === 'processing').length,
|
||||
0,
|
||||
);
|
||||
const withPrompt = planos.filter(p => p.promptVideo).length;
|
||||
|
||||
return NextResponse.json({
|
||||
planos: planosWithVariations,
|
||||
total: planos.length,
|
||||
generated,
|
||||
pending,
|
||||
withPrompt,
|
||||
});
|
||||
} catch (error: any) {
|
||||
@@ -82,7 +88,8 @@ export async function POST(
|
||||
return NextResponse.json({ plano: { ...plano, promptVideo } });
|
||||
}
|
||||
|
||||
// Action: generate (generate new video variation)
|
||||
// Action: generate — encola un job de vídeo en H3 y devuelve al instante.
|
||||
// El render lo drena el worker de H3 (ventana nocturna); el estado se refresca con 'sync-jobs'.
|
||||
if (action === 'generate') {
|
||||
if (!plano.imagenUrl) {
|
||||
return NextResponse.json({ error: 'El plano no tiene imagen generada' }, { status: 400 });
|
||||
@@ -91,75 +98,52 @@ export async function POST(
|
||||
// Use custom prompt if provided, otherwise compose from plano data
|
||||
const videoPrompt = promptVideo || plano.promptVideo || composeVideoPrompt(plano);
|
||||
|
||||
// Read image from disk and convert to base64 data URL
|
||||
// This is needed because localhost URLs are not accessible by Seedance API
|
||||
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
|
||||
const imagePath = path.join(capitulosDir, capDirName, 'imagenes', `${planoId}.png`);
|
||||
|
||||
// Read image from disk and convert to base64 data URL (la API H3 no ve URLs de localhost)
|
||||
const imagePath = path.join(PROJECTS_DIR, slug, 'capitulos', capDirName, 'imagenes', `${planoId}.png`);
|
||||
let imageDataUrl: string;
|
||||
try {
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const base64Image = imageBuffer.toString('base64');
|
||||
imageDataUrl = `data:image/png;base64,${base64Image}`;
|
||||
console.log(`[Videos API] Image loaded from disk: ${imagePath.length} chars (base64)`);
|
||||
imageDataUrl = `data:image/png;base64,${imageBuffer.toString('base64')}`;
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'No se pudo leer la imagen del plano' }, { status: 500 });
|
||||
}
|
||||
|
||||
console.log(`[Videos API] Generating video for plano ${planoId} with prompt: "${videoPrompt}"`);
|
||||
// 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 [width, height] = aspect === '9:16' ? [768, 1344] : [1344, 768];
|
||||
|
||||
// Generate video using Seedance 1.5 Pro with base64 image
|
||||
// generateAudio=true with prompt instruction for natural sounds only (no music)
|
||||
const result = await generateVideo(
|
||||
imageDataUrl,
|
||||
videoPrompt,
|
||||
plano.tiempoSegundos || 5,
|
||||
false, // cameraFixed
|
||||
true // generateAudio (natural sounds only via prompt)
|
||||
);
|
||||
console.log(`[Videos API] Encolando vídeo H3 para plano ${planoId} (${aspect})`);
|
||||
|
||||
console.log(`[Videos API] Video generated in ${result.generationTimeSeconds}s, cost: $${result.costUsd}`);
|
||||
let jobId: string;
|
||||
let fullPrompt: string;
|
||||
try {
|
||||
({ jobId, fullPrompt } = await submitVideoJob(imageDataUrl, videoPrompt, {
|
||||
duration: plano.tiempoSegundos || 5,
|
||||
width,
|
||||
height,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e.message || 'Error encolando el vídeo' }, { status: 502 });
|
||||
}
|
||||
|
||||
// Download video and save to disk
|
||||
const videoBuffer = await downloadVideo(result.videoUrl);
|
||||
const videosDir = path.join(PROJECTS_DIR, slug, 'capitulos', capDirName, 'videos');
|
||||
await fs.mkdir(videosDir, { recursive: true });
|
||||
|
||||
// Generate unique filename for this variation
|
||||
// Persistir la variación en estado 'queued' (sin vídeo aún). NO se auto-selecciona hasta
|
||||
// que esté 'ready' (ver sync-jobs).
|
||||
const variationId = randomUUID();
|
||||
const videoFileName = `${planoId}-${variationId}.mp4`;
|
||||
const videoPath = path.join(videosDir, videoFileName);
|
||||
await fs.writeFile(videoPath, videoBuffer);
|
||||
|
||||
console.log(`[Videos API] Video saved to: ${videoPath}`);
|
||||
|
||||
// Get existing variations to determine if this should be selected
|
||||
const existingVariations = getVideoVariations(slug, num, planoId);
|
||||
const isFirstVariation = existingVariations.length === 0;
|
||||
|
||||
// Save variation to DB
|
||||
const videoUrl = `/api/projects/${slug}/capitulos/${capDirName}/videos/${videoFileName.replace('.mp4', '')}`;
|
||||
saveVideoVariation(slug, num, {
|
||||
id: variationId,
|
||||
planoId,
|
||||
videoUrl,
|
||||
videoPath,
|
||||
promptVideo: result.fullPrompt,
|
||||
generationTimeSeconds: result.generationTimeSeconds,
|
||||
costUsd: result.costUsd,
|
||||
isSelected: isFirstVariation,
|
||||
videoUrl: '',
|
||||
promptVideo: fullPrompt,
|
||||
costUsd: H3_COST_USD,
|
||||
isSelected: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'queued',
|
||||
h3JobId: jobId,
|
||||
});
|
||||
|
||||
// Update legacy fields if this is the first variation
|
||||
if (isFirstVariation) {
|
||||
updatePromptPlano(slug, num, planoId, { videoUrl, promptVideo: result.fullPrompt });
|
||||
}
|
||||
|
||||
// Return updated variations
|
||||
const variations = getVideoVariations(slug, num, planoId);
|
||||
const selectedVariation = variations.find(v => v.isSelected);
|
||||
|
||||
return NextResponse.json({
|
||||
plano: {
|
||||
...plano,
|
||||
@@ -170,6 +154,75 @@ export async function POST(
|
||||
});
|
||||
}
|
||||
|
||||
// Action: sync-jobs — avanza los jobs de vídeo pendientes del capítulo (queued/processing).
|
||||
// Lo llama el cliente periódicamente mientras haya variaciones pendientes. Sin lógica de
|
||||
// ventana ni pings: una sola consulta por job; si 'done' descarga el mp4 y marca 'ready'.
|
||||
if (action === 'sync-jobs') {
|
||||
const pending = getPendingVideoVariations(slug, num);
|
||||
const videosDir = path.join(PROJECTS_DIR, slug, 'capitulos', capDirName, 'videos');
|
||||
|
||||
for (const v of pending) {
|
||||
if (!v.h3JobId) continue;
|
||||
try {
|
||||
const job = await getJobStatus(v.h3JobId);
|
||||
|
||||
if (job.status === 'processing' && v.status !== 'processing') {
|
||||
updateVideoVariationProgress(v.id, { status: 'processing' });
|
||||
} else if (job.status === 'error') {
|
||||
updateVideoVariationProgress(v.id, { status: 'error', error: job.error || 'Error en el render H3' });
|
||||
} else if (job.status === 'done') {
|
||||
if (!job.videoUrl) {
|
||||
updateVideoVariationProgress(v.id, { status: 'error', error: 'H3 done sin video_url' });
|
||||
continue;
|
||||
}
|
||||
// Descargar y guardar el mp4 en disco.
|
||||
const videoBuffer = await downloadVideo(job.videoUrl);
|
||||
await fs.mkdir(videosDir, { recursive: true });
|
||||
const videoFileName = `${v.planoId}-${v.id}.mp4`;
|
||||
const videoPath = path.join(videosDir, videoFileName);
|
||||
await fs.writeFile(videoPath, videoBuffer);
|
||||
|
||||
const localUrl = `/api/projects/${slug}/capitulos/${capDirName}/videos/${videoFileName.replace('.mp4', '')}`;
|
||||
updateVideoVariationProgress(v.id, {
|
||||
status: 'ready',
|
||||
videoUrl: localUrl,
|
||||
videoPath,
|
||||
generationTimeSeconds: job.generationTimeSeconds,
|
||||
});
|
||||
|
||||
// Si el plano no tiene ninguna variación 'ready' seleccionada, seleccionar ésta.
|
||||
const siblings = getVideoVariations(slug, num, v.planoId);
|
||||
const hasSelectedReady = siblings.some(s => s.isSelected && s.status === 'ready' && s.id !== v.id);
|
||||
if (!hasSelectedReady) {
|
||||
selectVideoVariation(slug, num, v.planoId, v.id);
|
||||
updatePromptPlano(slug, num, v.planoId, { videoUrl: localUrl, promptVideo: v.promptVideo });
|
||||
}
|
||||
}
|
||||
// 'queued' → sin cambios
|
||||
} catch (e: any) {
|
||||
// Fallo transitorio consultando/descargando: dejar la variación como está y reintentar
|
||||
// en el próximo sync. No la marcamos 'error' por un fallo de red puntual.
|
||||
console.warn(`[Videos API] sync-jobs: fallo con job ${v.h3JobId}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Devolver el estado actualizado de todos los planos del capítulo.
|
||||
const allPlanos = getPromptsPlanos(slug, num).map(p => {
|
||||
const variations = getVideoVariations(slug, num, p.id);
|
||||
const selectedVariation = variations.find(vv => vv.isSelected);
|
||||
return {
|
||||
...p,
|
||||
videoVariations: variations,
|
||||
videoUrl: selectedVariation?.videoUrl || p.videoUrl,
|
||||
promptVideo: selectedVariation?.promptVideo || p.promptVideo,
|
||||
};
|
||||
});
|
||||
const generated = allPlanos.filter(p => p.videoVariations.some(vv => vv.status === 'ready')).length;
|
||||
const stillPending = getPendingVideoVariations(slug, num).length;
|
||||
|
||||
return NextResponse.json({ planos: allPlanos, generated, pending: stillPending });
|
||||
}
|
||||
|
||||
// Action: select-variation (select a specific variation)
|
||||
if (action === 'select-variation') {
|
||||
const { variationId } = body;
|
||||
|
||||
@@ -674,47 +674,43 @@ export default function CapituloPage({
|
||||
};
|
||||
|
||||
const handleGeneratePlanoVideo = async (planoId: string, customPrompt?: string) => {
|
||||
// El render es async (cola nocturna de H3): encolamos y devolvemos al instante; el estado
|
||||
// real (queued → processing → ready) lo refresca el polling de syncVideoJobs.
|
||||
setGeneratingVideo(planoId);
|
||||
startProgress('Generando Video', 0, `Generando video para plano ${planoId}...`);
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'generate', planoId, promptVideo: customPrompt }),
|
||||
signal: abortRef.current?.signal,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { showToast(data.error || 'Error generando video', 'error'); closeProgress(); return; }
|
||||
if (!res.ok) { showToast(data.error || 'Error encolando el vídeo', 'error'); return; }
|
||||
if (videosData) {
|
||||
const updatedPlanos = videosData.planos.map(p => p.id === planoId ? data.plano : p);
|
||||
const generated = updatedPlanos.filter(p => p.videoVariations && p.videoVariations.length > 0).length;
|
||||
setVideosData({ ...videosData, planos: updatedPlanos, generated });
|
||||
setVideosData({ ...videosData, planos: updatedPlanos });
|
||||
}
|
||||
addProgressLog(`Video generado para ${planoId}`, 'success');
|
||||
finishProgress(1, 1);
|
||||
showToast(`Video generado para plano ${planoId}`, 'success');
|
||||
} catch (error: any) {
|
||||
if (error.name !== 'AbortError') { showToast('Error al generar video', 'error'); }
|
||||
closeProgress();
|
||||
showToast('Vídeo en cola — se generará en la ventana nocturna', 'success');
|
||||
} catch {
|
||||
showToast('Error al encolar el vídeo', 'error');
|
||||
} finally { setGeneratingVideo(null); }
|
||||
};
|
||||
|
||||
const handleGenerateAllVideos = async (onlyPending = false) => {
|
||||
if (!videosData) return;
|
||||
const pending = onlyPending
|
||||
? videosData.planos.filter(p => p.imagenUrl && (!p.videoVariations || p.videoVariations.length === 0))
|
||||
const targets = onlyPending
|
||||
? videosData.planos.filter(p => p.imagenUrl && !(p.videoVariations || []).some(v => v.status === 'ready' || v.status === 'queued' || v.status === 'processing'))
|
||||
: videosData.planos.filter(p => p.imagenUrl);
|
||||
if (pending.length === 0) return;
|
||||
if (targets.length === 0) return;
|
||||
|
||||
startProgress('Generando Videos', pending.length, `Video 1/${pending.length}...`);
|
||||
startProgress('Encolando vídeos', targets.length, `Encolando 1/${targets.length}...`);
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
let localPlanos = [...videosData.planos];
|
||||
|
||||
for (let i = 0; i < pending.length; i++) {
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
if (abortRef.current?.signal.aborted) break;
|
||||
const plano = pending[i];
|
||||
updateProgress(i, pending.length, `Video ${i + 1}/${pending.length} — ${plano.numeroPlano || plano.id}`);
|
||||
const plano = targets[i];
|
||||
updateProgress(i, targets.length, `Encolando ${i + 1}/${targets.length} — ${plano.numeroPlano || plano.id}`);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
|
||||
@@ -726,8 +722,7 @@ export default function CapituloPage({
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
localPlanos = localPlanos.map(p => p.id === plano.id ? data.plano : p);
|
||||
const generated = localPlanos.filter(p => p.videoVariations && p.videoVariations.length > 0).length;
|
||||
setVideosData({ ...videosData, planos: localPlanos, generated });
|
||||
setVideosData({ ...videosData, planos: localPlanos });
|
||||
addProgressLog(`${plano.numeroPlano || plano.id}`, 'success');
|
||||
successCount++;
|
||||
} else {
|
||||
@@ -741,14 +736,43 @@ export default function CapituloPage({
|
||||
}
|
||||
}
|
||||
|
||||
finishProgress(pending.length, pending.length);
|
||||
finishProgress(targets.length, targets.length);
|
||||
if (errorCount > 0) {
|
||||
showToast(`${successCount} variaciones generadas, ${errorCount} con error`, 'error');
|
||||
showToast(`${successCount} vídeos encolados, ${errorCount} con error`, 'error');
|
||||
} else {
|
||||
showToast(`${successCount} variaciones generadas correctamente`, 'success');
|
||||
showToast(`${successCount} vídeos en cola — se generarán en la ventana nocturna`, 'success');
|
||||
}
|
||||
};
|
||||
|
||||
// Refresca el estado de los jobs de vídeo pendientes (queued/processing) del capítulo.
|
||||
const syncVideoJobs = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'sync-jobs' }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.planos) {
|
||||
setVideosData(prev => prev ? { ...prev, planos: data.planos, generated: data.generated } : prev);
|
||||
}
|
||||
} catch {
|
||||
// fallo transitorio de red: reintenta en el próximo tick
|
||||
}
|
||||
}, [slug, numero]);
|
||||
|
||||
// Polling mientras haya variaciones de vídeo pendientes. Sin pings agresivos: cada 45s.
|
||||
const hasPendingVideos = !!videosData?.planos.some(
|
||||
p => (p.videoVariations || []).some(v => v.status === 'queued' || v.status === 'processing')
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!hasPendingVideos) return;
|
||||
syncVideoJobs(); // refresco inmediato al detectar pendientes
|
||||
const id = setInterval(syncVideoJobs, 45000);
|
||||
return () => clearInterval(id);
|
||||
}, [hasPendingVideos, syncVideoJobs]);
|
||||
|
||||
const handleSaveVideoPrompt = async (planoId: string, promptVideo: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${slug}/capitulos/${numero}/videos`, {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Video, Edit3, Save, Trash2, Check } from 'lucide-react';
|
||||
import { Video, Edit3, Trash2, Check, Loader2 } from 'lucide-react';
|
||||
import type { PlanoConPrompt, VideoVariation } from '@/types/project';
|
||||
|
||||
interface VideoVariationsCardProps {
|
||||
@@ -167,6 +167,8 @@ export function VideoVariationsCard({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{variations.map((variation) => {
|
||||
const isSelected = variation.isSelected;
|
||||
const isReady = variation.status === 'ready';
|
||||
const isError = variation.status === 'error';
|
||||
return (
|
||||
<div
|
||||
key={variation.id}
|
||||
@@ -174,6 +176,7 @@ export function VideoVariationsCard({
|
||||
isSelected ? 'border-indigo-500 ring-2 ring-indigo-200' : 'border-stone-200 hover:border-stone-300'
|
||||
}`}
|
||||
>
|
||||
{isReady ? (
|
||||
<video
|
||||
src={variation.videoUrl}
|
||||
className="w-full cursor-pointer"
|
||||
@@ -186,7 +189,27 @@ export function VideoVariationsCard({
|
||||
vid.currentTime = 0;
|
||||
}}
|
||||
/>
|
||||
{isSelected && (
|
||||
) : (
|
||||
<div
|
||||
className={`w-full flex flex-col items-center justify-center gap-1 text-center px-2 ${
|
||||
isError ? 'bg-red-50 text-red-500' : 'bg-stone-50 text-stone-400'
|
||||
}`}
|
||||
style={{ aspectRatio: '16/9' }}
|
||||
title={variation.error || undefined}
|
||||
>
|
||||
{isError ? (
|
||||
<span className="text-[10px] font-medium">Error</span>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-[9px] font-medium">
|
||||
{variation.status === 'processing' ? 'Generando…' : 'En cola'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isSelected && isReady && (
|
||||
<div className="absolute top-1 left-1 bg-indigo-500 text-white rounded-full p-0.5">
|
||||
<Check className="w-3 h-3" />
|
||||
</div>
|
||||
@@ -198,17 +221,16 @@ export function VideoVariationsCard({
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
{isReady && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent px-1.5 py-1">
|
||||
<div className="flex items-center justify-between text-[8px] text-white">
|
||||
{variation.generationTimeSeconds && (
|
||||
<span>{variation.generationTimeSeconds.toFixed(1)}s</span>
|
||||
)}
|
||||
{variation.costUsd !== undefined && (
|
||||
<span>${variation.costUsd.toFixed(3)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -836,13 +836,17 @@ export function getVideoVariations(slug: string, numero: number, planoId: string
|
||||
|
||||
const rows = getDb().prepare(`
|
||||
SELECT id, plano_id, capitulo_id, video_url, video_path, prompt_video,
|
||||
generation_time_seconds, cost_usd, is_selected, created_at
|
||||
generation_time_seconds, cost_usd, is_selected, created_at, status, h3_job_id, error
|
||||
FROM video_variations
|
||||
WHERE capitulo_id = ? AND plano_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`).all(capId, planoId) as any[];
|
||||
|
||||
return rows.map(r => ({
|
||||
return rows.map(mapVideoVariationRow);
|
||||
}
|
||||
|
||||
function mapVideoVariationRow(r: any): VideoVariation {
|
||||
return {
|
||||
id: r.id,
|
||||
planoId: r.plano_id,
|
||||
capituloId: r.capitulo_id,
|
||||
@@ -850,10 +854,51 @@ export function getVideoVariations(slug: string, numero: number, planoId: string
|
||||
videoPath: r.video_path || undefined,
|
||||
promptVideo: r.prompt_video,
|
||||
generationTimeSeconds: r.generation_time_seconds || undefined,
|
||||
costUsd: r.cost_usd || undefined,
|
||||
costUsd: r.cost_usd ?? undefined,
|
||||
isSelected: r.is_selected === 1,
|
||||
createdAt: r.created_at,
|
||||
}));
|
||||
status: (r.status || 'ready') as VideoVariation['status'],
|
||||
h3JobId: r.h3_job_id || undefined,
|
||||
error: r.error || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get non-terminal video variations (queued|processing) across a whole chapter.
|
||||
* Used by the async sync endpoint to advance jobs.
|
||||
*/
|
||||
export function getPendingVideoVariations(slug: string, numero: number): VideoVariation[] {
|
||||
const capId = resolveCapituloId(slug, numero);
|
||||
if (!capId) return [];
|
||||
const rows = getDb().prepare(`
|
||||
SELECT id, plano_id, capitulo_id, video_url, video_path, prompt_video,
|
||||
generation_time_seconds, cost_usd, is_selected, created_at, status, h3_job_id, error
|
||||
FROM video_variations
|
||||
WHERE capitulo_id = ? AND status IN ('queued', 'processing')
|
||||
ORDER BY created_at ASC
|
||||
`).all(capId) as any[];
|
||||
return rows.map(mapVideoVariationRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the progress/result of a video variation (async H3 lifecycle).
|
||||
* Only overwrites the fields provided.
|
||||
*/
|
||||
export function updateVideoVariationProgress(
|
||||
variationId: string,
|
||||
patch: Partial<Pick<VideoVariation, 'status' | 'videoUrl' | 'videoPath' | 'generationTimeSeconds' | 'costUsd' | 'error'>>
|
||||
): void {
|
||||
const sets: string[] = [];
|
||||
const vals: any[] = [];
|
||||
if (patch.status !== undefined) { sets.push('status = ?'); vals.push(patch.status); }
|
||||
if (patch.videoUrl !== undefined) { sets.push('video_url = ?'); vals.push(patch.videoUrl); }
|
||||
if (patch.videoPath !== undefined) { sets.push('video_path = ?'); vals.push(patch.videoPath); }
|
||||
if (patch.generationTimeSeconds !== undefined) { sets.push('generation_time_seconds = ?'); vals.push(patch.generationTimeSeconds); }
|
||||
if (patch.costUsd !== undefined) { sets.push('cost_usd = ?'); vals.push(patch.costUsd); }
|
||||
if (patch.error !== undefined) { sets.push('error = ?'); vals.push(patch.error); }
|
||||
if (sets.length === 0) return;
|
||||
vals.push(variationId);
|
||||
getDb().prepare(`UPDATE video_variations SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -871,19 +916,22 @@ export function saveVideoVariation(
|
||||
db.prepare(`
|
||||
INSERT INTO video_variations
|
||||
(id, plano_id, capitulo_id, video_url, video_path, prompt_video,
|
||||
generation_time_seconds, cost_usd, is_selected, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
generation_time_seconds, cost_usd, is_selected, created_at, status, h3_job_id, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
variation.id,
|
||||
variation.planoId,
|
||||
capId,
|
||||
variation.videoUrl,
|
||||
variation.videoUrl || '',
|
||||
variation.videoPath || null,
|
||||
variation.promptVideo,
|
||||
variation.generationTimeSeconds || null,
|
||||
variation.costUsd || null,
|
||||
variation.generationTimeSeconds ?? null,
|
||||
variation.costUsd ?? null,
|
||||
variation.isSelected ? 1 : 0,
|
||||
variation.createdAt
|
||||
variation.createdAt,
|
||||
variation.status || 'ready',
|
||||
variation.h3JobId || null,
|
||||
variation.error || null
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -155,13 +155,17 @@ function initSchema(db: Database.Database): void {
|
||||
id TEXT PRIMARY KEY,
|
||||
plano_id TEXT NOT NULL,
|
||||
capitulo_id TEXT NOT NULL REFERENCES capitulos(id) ON DELETE CASCADE,
|
||||
video_url TEXT NOT NULL,
|
||||
video_url TEXT NOT NULL DEFAULT '',
|
||||
video_path TEXT,
|
||||
prompt_video TEXT NOT NULL,
|
||||
generation_time_seconds REAL,
|
||||
cost_usd REAL,
|
||||
is_selected INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
-- Generación async (H3): job remoto y estado del ciclo de vida.
|
||||
status TEXT NOT NULL DEFAULT 'ready',
|
||||
h3_job_id TEXT,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
-- Create indexes if they don't exist
|
||||
@@ -202,4 +206,32 @@ function runMigrations(db: Database.Database): void {
|
||||
console.warn('[Migration] ALTER prompts_planos falló (posiblemente ya aplicada):', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: async video generation (H3) columns on video_variations.
|
||||
const vvCols = (db.pragma('table_info(video_variations)') as Array<{ name: string }>).map(c => c.name);
|
||||
if (!vvCols.includes('status')) {
|
||||
try {
|
||||
// Filas existentes ya tienen vídeo → 'ready'.
|
||||
db.exec("ALTER TABLE video_variations ADD COLUMN status TEXT NOT NULL DEFAULT 'ready'");
|
||||
console.log('[Migration] Added status column to video_variations');
|
||||
} catch (e) {
|
||||
// Column already exists or other error - safe to ignore
|
||||
}
|
||||
}
|
||||
if (!vvCols.includes('h3_job_id')) {
|
||||
try {
|
||||
db.exec('ALTER TABLE video_variations ADD COLUMN h3_job_id TEXT');
|
||||
console.log('[Migration] Added h3_job_id column to video_variations');
|
||||
} catch (e) {
|
||||
// Column already exists or other error - safe to ignore
|
||||
}
|
||||
}
|
||||
if (!vvCols.includes('error')) {
|
||||
try {
|
||||
db.exec('ALTER TABLE video_variations ADD COLUMN error TEXT');
|
||||
console.log('[Migration] Added error column to video_variations');
|
||||
} catch (e) {
|
||||
// Column already exists or other error - safe to ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,184 +1,185 @@
|
||||
/**
|
||||
* Video generation service using BytePlus Ark API with Seedance 1.5 Pro
|
||||
* Video generation service — H3 async video API (h3api.essia.coop).
|
||||
*
|
||||
* Sustituye a BytePlus Ark / Seedance. Contrato definido por essia-server (2026-08):
|
||||
* POST /v1/jobs (multipart o json) -> 202 { job_id, status:"queued" }
|
||||
* GET /v1/jobs/{id} -> { status, video_url?, generation_time_seconds?, error? }
|
||||
* GET /v1/jobs/{id}/video -> binario mp4 (con audio nativo)
|
||||
* Auth: header X-API-Key. Coste 0 (GPU self-hosted, sin tarifa por clip).
|
||||
*
|
||||
* La cola + worker viven SERVER-SIDE (h3api): `submitVideoJob` devuelve un job_id al instante
|
||||
* (status=queued) y el server lo drena en la ventana nocturna. Aquí NO se bloquea la request:
|
||||
* el ciclo submit → poll → descarga lo orquesta el route de vídeos de forma async (persistiendo
|
||||
* el job_id en `video_variations` y refrescando estado bajo demanda desde el cliente).
|
||||
*
|
||||
* ESTADO: dormante hasta que essia-server confirme "live" + provea H3API_KEY. Sin esas env vars,
|
||||
* submitVideoJob() lanza error solo si se invoca (no rompe el arranque).
|
||||
*/
|
||||
|
||||
const ARK_API_KEY = process.env.ARK_API_KEY || '';
|
||||
const ARK_BASE_URL = 'https://ark.ap-southeast.bytepluses.com/api/v3';
|
||||
const MODEL = 'seedance-1-5-pro-251215';
|
||||
// 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 || '';
|
||||
|
||||
// Pricing - Update based on BytePlus Ark actual pricing
|
||||
// Placeholder: $0.10 per video generation (adjust as needed)
|
||||
const COST_PER_VIDEO_USD = 0.10;
|
||||
// Modo i2v del MVP: ref2va (imagen->vídeo, validado). fl2va (primer frame exacto, más fiel a
|
||||
// Seedance) se activará pasando mode:'fl2va' cuando essia-server lo valide — mismo contrato.
|
||||
const DEFAULT_I2V_MODE = 'ref2va';
|
||||
|
||||
interface ArkTaskResponse {
|
||||
id: string;
|
||||
model: string;
|
||||
status: 'pending' | 'processing' | 'succeeded' | 'failed';
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
content?: Array<{
|
||||
type: string;
|
||||
video_url?: { url: string };
|
||||
}> | {
|
||||
video_url?: { url: string };
|
||||
};
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
// GPU propia self-hosted: sin tarifa por clip.
|
||||
const COST_PER_VIDEO_USD = 0;
|
||||
|
||||
export type H3JobStatus = 'queued' | 'processing' | 'done' | 'error';
|
||||
|
||||
export interface H3JobStatusResult {
|
||||
status: H3JobStatus;
|
||||
videoUrl?: string;
|
||||
generationTimeSeconds?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface VideoGenerationResult {
|
||||
videoUrl: string;
|
||||
generationTimeSeconds: number;
|
||||
costUsd: number;
|
||||
fullPrompt: string;
|
||||
interface H3JobResponse {
|
||||
job_id: string;
|
||||
status: H3JobStatus;
|
||||
video_url?: string;
|
||||
generation_time_seconds?: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface SubmitVideoOptions {
|
||||
duration?: number; // segundos, 4-15 (default 5)
|
||||
cameraFixed?: boolean; // añade indicación de movimiento mínimo al prompt
|
||||
width?: number; // se ajusta a la rejilla de 32 px de H3 (default 832)
|
||||
height?: number; // ídem (default 480); tope H3: width*height <= 1.032.192 px
|
||||
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;
|
||||
|
||||
function ensureConfigured(): void {
|
||||
if (!H3_API_KEY) {
|
||||
throw new Error('H3API_KEY no configurada en variables de entorno');
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
return { 'X-API-Key': H3_API_KEY, ...(extra || {}) };
|
||||
}
|
||||
|
||||
/** Convierte un data URL (data:image/png;base64,...) a Blob para multipart. */
|
||||
function dataUrlToBlob(dataUrl: string): Blob {
|
||||
const match = /^data:([^;]+);base64,([\s\S]*)$/.exec(dataUrl);
|
||||
if (!match) {
|
||||
throw new Error('imageUrl no es un data URL base64 válido');
|
||||
}
|
||||
const [, mime, b64] = match;
|
||||
return new Blob([Buffer.from(b64, 'base64')], { type: mime });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate video from image using Seedance 1.5 Pro
|
||||
* @param imageUrl - URL or data URL (base64) of the reference image
|
||||
* @param prompt - Text prompt describing the action/movement
|
||||
* @param duration - Video duration in seconds (default 5)
|
||||
* @param cameraFixed - Whether camera should be fixed (default false)
|
||||
* @param generateAudio - Whether to generate audio (default true)
|
||||
* @returns Video generation result with URL, time, and cost
|
||||
* Encola un job de image-to-video en la H3 API y devuelve su id al instante (NO espera al render).
|
||||
*
|
||||
* @param imageUrl data URL base64 de la imagen del plano, o URL https accesible por el server.
|
||||
* @param prompt Prompt de movimiento (SOLO positivo; se recomienda movimiento mínimo).
|
||||
* @returns { jobId, fullPrompt } — persiste jobId y consulta el estado con getJobStatus().
|
||||
*/
|
||||
export async function generateVideo(
|
||||
export async function submitVideoJob(
|
||||
imageUrl: string,
|
||||
prompt: string,
|
||||
duration: number = 5,
|
||||
cameraFixed: boolean = false,
|
||||
generateAudio: boolean = true
|
||||
): Promise<VideoGenerationResult> {
|
||||
const startTime = Date.now();
|
||||
if (!ARK_API_KEY) {
|
||||
throw new Error('ARK_API_KEY no configurada en variables de entorno');
|
||||
options: SubmitVideoOptions = {}
|
||||
): Promise<{ jobId: string; fullPrompt: string }> {
|
||||
ensureConfigured();
|
||||
|
||||
const dur = Math.min(15, Math.max(4, options.duration || 5));
|
||||
const requestedW = options.width ?? 832;
|
||||
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 fullPrompt = options.cameraFixed
|
||||
? `${prompt} Minimal camera movement, subtle motion only.`
|
||||
: prompt;
|
||||
|
||||
const form = new FormData();
|
||||
form.append('mode', mode);
|
||||
form.append('prompt', fullPrompt);
|
||||
form.append('width', String(width));
|
||||
form.append('height', String(height));
|
||||
form.append('duration', String(dur));
|
||||
if (imageUrl.startsWith('data:')) {
|
||||
form.append('image', dataUrlToBlob(imageUrl), 'plano.png');
|
||||
} else {
|
||||
form.append('image_url', imageUrl);
|
||||
}
|
||||
|
||||
// Enhance prompt to request only natural sounds, no background music
|
||||
const audioInstruction = generateAudio
|
||||
? 'Natural ambient sounds and action sounds only, no background music.'
|
||||
: '';
|
||||
|
||||
// Compose the text prompt with audio instructions
|
||||
const textPrompt = audioInstruction
|
||||
? `${prompt} ${audioInstruction} --duration ${duration} --camerafixed ${cameraFixed}`
|
||||
: `${prompt} --duration ${duration} --camerafixed ${cameraFixed}`;
|
||||
|
||||
// Create video generation task
|
||||
const createResponse = await fetch(`${ARK_BASE_URL}/contents/generations/tasks`, {
|
||||
const res = await fetch(`${H3_BASE_URL}/v1/jobs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${ARK_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: textPrompt,
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: imageUrl,
|
||||
},
|
||||
},
|
||||
],
|
||||
// Additional parameters for video generation
|
||||
duration,
|
||||
resolution: '720p',
|
||||
aspect_ratio: '16:9',
|
||||
fps: 24,
|
||||
camera_fixed: cameraFixed,
|
||||
generate_audio: generateAudio,
|
||||
}),
|
||||
headers: authHeaders(), // no fijar Content-Type: fetch pone el boundary del multipart
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!createResponse.ok) {
|
||||
const errorData = await createResponse.json().catch(() => ({}));
|
||||
throw new Error(`Error creando tarea de video: ${createResponse.status} ${JSON.stringify(errorData)}`);
|
||||
if (!res.ok) {
|
||||
const errorData = await res.text().catch(() => '');
|
||||
throw new Error(`Error creando job de vídeo H3: ${res.status} ${errorData}`);
|
||||
}
|
||||
|
||||
const createData: ArkTaskResponse = await createResponse.json();
|
||||
const taskId = createData.id;
|
||||
|
||||
console.log(`[Video Service] Task created: ${taskId}`);
|
||||
|
||||
// Poll task status until completed (max 5 minutes)
|
||||
const maxAttempts = 300; // 5 minutes with 1s intervals
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
|
||||
|
||||
const statusResponse = await fetch(`${ARK_BASE_URL}/contents/generations/tasks/${taskId}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${ARK_API_KEY}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!statusResponse.ok) {
|
||||
throw new Error(`Error consultando estado de tarea: ${statusResponse.status}`);
|
||||
const data = (await res.json()) as H3JobResponse;
|
||||
if (!data.job_id) {
|
||||
throw new Error(`Respuesta de H3 sin job_id: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
const statusData: ArkTaskResponse = await statusResponse.json();
|
||||
console.log(`[Video Service] Task ${taskId} status: ${statusData.status}`);
|
||||
|
||||
if (statusData.status === 'succeeded') {
|
||||
// Log the full response to understand its structure
|
||||
console.log('[Video Service] Success response:', JSON.stringify(statusData, null, 2));
|
||||
|
||||
// Extract video URL from response
|
||||
// Handle both array and object formats
|
||||
let videoUrl: string | undefined;
|
||||
|
||||
if (Array.isArray(statusData.content)) {
|
||||
// Array format: [{ type: 'video_url', video_url: { url: '...' } }]
|
||||
const videoContent = statusData.content.find(c => c.type === 'video_url' && c.video_url);
|
||||
videoUrl = videoContent?.video_url?.url;
|
||||
} else if (statusData.content?.video_url) {
|
||||
// Object format: { video_url: "https://..." } (direct string)
|
||||
const vurl = (statusData.content as any).video_url;
|
||||
videoUrl = typeof vurl === 'string' ? vurl : vurl?.url;
|
||||
}
|
||||
|
||||
if (!videoUrl) {
|
||||
throw new Error(`Video URL no encontrada en respuesta exitosa. Response: ${JSON.stringify(statusData)}`);
|
||||
}
|
||||
|
||||
console.log(`[Video Service] Task ${taskId} succeeded: ${videoUrl}`);
|
||||
|
||||
const endTime = Date.now();
|
||||
const generationTimeSeconds = (endTime - startTime) / 1000;
|
||||
|
||||
return {
|
||||
videoUrl,
|
||||
generationTimeSeconds,
|
||||
costUsd: COST_PER_VIDEO_USD,
|
||||
fullPrompt: textPrompt,
|
||||
};
|
||||
} else if (statusData.status === 'failed') {
|
||||
const errorMsg = statusData.error?.message || 'Error desconocido';
|
||||
throw new Error(`Generación de video falló: ${errorMsg}`);
|
||||
}
|
||||
|
||||
attempts++;
|
||||
}
|
||||
|
||||
throw new Error('Timeout esperando generación de video (5 minutos)');
|
||||
console.log(`[Video Service/H3] Job encolado: ${data.job_id} (status ${data.status})`);
|
||||
return { jobId: data.job_id, fullPrompt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Download video from URL and return as Buffer
|
||||
* Consulta el estado de un job H3 (una sola llamada, sin polling). El polling lo hace el cliente
|
||||
* llamando al route de sync bajo demanda (no bloquea ninguna request de servidor).
|
||||
*/
|
||||
export async function getJobStatus(jobId: string): Promise<H3JobStatusResult> {
|
||||
ensureConfigured();
|
||||
const res = await fetch(`${H3_BASE_URL}/v1/jobs/${jobId}`, { headers: authHeaders() });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Error consultando job H3 ${jobId}: ${res.status}`);
|
||||
}
|
||||
const job = (await res.json()) as H3JobResponse;
|
||||
return {
|
||||
status: job.status,
|
||||
videoUrl: job.video_url,
|
||||
generationTimeSeconds: job.generation_time_seconds,
|
||||
error: job.error || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Descarga el vídeo generado. El video_url de H3 requiere la X-API-Key.
|
||||
*/
|
||||
export async function downloadVideo(url: string): Promise<Buffer> {
|
||||
const response = await fetch(url);
|
||||
const isH3 = url.startsWith(H3_BASE_URL);
|
||||
const response = await fetch(url, isH3 ? { headers: authHeaders() } : undefined);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error descargando video: ${response.status}`);
|
||||
throw new Error(`Error descargando vídeo: ${response.status}`);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
|
||||
@@ -216,6 +216,8 @@ export interface ReferenceAsset {
|
||||
emotionUrl?: string;
|
||||
}
|
||||
|
||||
export type VideoVariationStatus = 'queued' | 'processing' | 'ready' | 'error';
|
||||
|
||||
export interface VideoVariation {
|
||||
id: string;
|
||||
planoId: string;
|
||||
@@ -227,6 +229,10 @@ export interface VideoVariation {
|
||||
costUsd?: number;
|
||||
isSelected: boolean;
|
||||
createdAt: string;
|
||||
// Generación async (H3): estado del job y su id remoto.
|
||||
status: VideoVariationStatus;
|
||||
h3JobId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PlanoConPrompt extends PlanoEscaleta {
|
||||
|
||||
Reference in New Issue
Block a user