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>
This commit is contained in:
@@ -35,6 +35,38 @@ function wrap(fn: (args: Args) => Promise<unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).');
|
||||
|
||||
@@ -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)
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user