Compare commits

...

3 Commits

Author SHA1 Message Date
Carlos Narro
a698c6b225 feat(video): generación de vídeo H3 async (no bloqueante) con estados queued→ready
En vez de esperar el render dentro de la request (imposible con la cola nocturna de H3),
ahora el ciclo es async y la cola/worker viven server-side en h3api:

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,51 @@ 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: 16:9 (default) o 9:16 según body.aspect.
const aspect = body.aspect === '9:16' ? '9:16' : '16:9';
const [width, height] = aspect === '9:16' ? [480, 832] : [832, 480];
// 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 +153,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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 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`, {

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,184 +1,158 @@
/**
* 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';
const H3_BASE_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; // múltiplo de 32 (default 832 = 16:9)
height?: number; // múltiplo de 32 (default 480 = 16:9)
mode?: string; // 'ref2va' (default) | 'fl2va' | 't2va'
}
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 width = options.width ?? 832;
const height = options.height ?? 480;
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);

View File

@@ -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 {