diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index 60f0bdf..3e36115 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -35,6 +35,38 @@ function wrap(fn: (args: Args) => Promise) { }; } +/** + * 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; + 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 = { 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).'); @@ -384,10 +416,15 @@ server.registerTool('story_generate_all_images', { server.registerTool('story_get_videos', { title: 'Ver vídeos de plano', - description: 'Devuelve los planos del capítulo con sus variaciones de vídeo (estado, seleccionada) y contadores.', - inputSchema: { slug: slugField, numero: numeroField }, + 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 }) => apiGet(`${capBase(slug as string, numero as number)}/videos`))); +}, 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', @@ -405,6 +442,18 @@ server.registerTool('story_generate_plano_video', { 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) // ============================================================ diff --git a/src/app/api/projects/[slug]/capitulos/[numero]/videos/route.ts b/src/app/api/projects/[slug]/capitulos/[numero]/videos/route.ts index e481bac..24acdc2 100644 --- a/src/app/api/projects/[slug]/capitulos/[numero]/videos/route.ts +++ b/src/app/api/projects/[slug]/capitulos/[numero]/videos/route.ts @@ -108,9 +108,10 @@ export async function POST( 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 [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})`); diff --git a/src/lib/video-service.ts b/src/lib/video-service.ts index 9b034d2..aed6326 100644 --- a/src/lib/video-service.ts +++ b/src/lib/video-service.ts @@ -47,11 +47,33 @@ interface H3JobResponse { export interface SubmitVideoOptions { duration?: number; // segundos, 4-15 (default 5) cameraFixed?: boolean; // añade indicación de movimiento mínimo al prompt - width?: number; // múltiplo de 32 (default 832 = 16:9) - height?: number; // múltiplo de 32 (default 480 = 16:9) + 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 { @@ -89,8 +111,12 @@ export async function submitVideoJob( ensureConfigured(); const dur = Math.min(15, Math.max(4, options.duration || 5)); - const width = options.width ?? 832; - const height = options.height ?? 480; + 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.`