From 2c08d47a0fb266f98f176e0f0b16e52fb55cc32f Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Wed, 12 Aug 2026 18:15:22 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20bugs=20y=20mejoras=20de=20la=20revisi?= =?UTF-8?q?=C3=B3n=20de=20c=C3=B3digo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../capitulos/[numero]/escaleta/route.ts | 14 +++- .../[numero]/fondos/[fondoId]/route.ts | 5 +- .../capitulos/[numero]/imagenes/route.ts | 7 +- .../[numero]/videos/[planoId]/route.ts | 13 ++-- .../api/projects/[slug]/capitulos/route.ts | 6 +- .../[slug]/personajes/[id]/assets/route.ts | 13 ++-- .../chapter-data/[slug]/[numero]/route.ts | 14 +++- src/app/api/render/route.ts | 38 ++++++---- src/lib/db-service.ts | 75 ++++++++++++++++++- src/lib/db.ts | 6 +- src/lib/image-service.ts | 14 +++- src/lib/llm-service.ts | 18 ++++- 12 files changed, 170 insertions(+), 53 deletions(-) diff --git a/src/app/api/projects/[slug]/capitulos/[numero]/escaleta/route.ts b/src/app/api/projects/[slug]/capitulos/[numero]/escaleta/route.ts index 1e28e57..7c67948 100644 --- a/src/app/api/projects/[slug]/capitulos/[numero]/escaleta/route.ts +++ b/src/app/api/projects/[slug]/capitulos/[numero]/escaleta/route.ts @@ -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); diff --git a/src/app/api/projects/[slug]/capitulos/[numero]/fondos/[fondoId]/route.ts b/src/app/api/projects/[slug]/capitulos/[numero]/fondos/[fondoId]/route.ts index 9bc2ef9..ba483c7 100644 --- a/src/app/api/projects/[slug]/capitulos/[numero]/fondos/[fondoId]/route.ts +++ b/src/app/api/projects/[slug]/capitulos/[numero]/fondos/[fondoId]/route.ts @@ -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); diff --git a/src/app/api/projects/[slug]/capitulos/[numero]/imagenes/route.ts b/src/app/api/projects/[slug]/capitulos/[numero]/imagenes/route.ts index e61a101..3533b11 100644 --- a/src/app/api/projects/[slug]/capitulos/[numero]/imagenes/route.ts +++ b/src/app/api/projects/[slug]/capitulos/[numero]/imagenes/route.ts @@ -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); diff --git a/src/app/api/projects/[slug]/capitulos/[numero]/videos/[planoId]/route.ts b/src/app/api/projects/[slug]/capitulos/[numero]/videos/[planoId]/route.ts index 38fe61d..1c70805 100644 --- a/src/app/api/projects/[slug]/capitulos/[numero]/videos/[planoId]/route.ts +++ b/src/app/api/projects/[slug]/capitulos/[numero]/videos/[planoId]/route.ts @@ -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', }, diff --git a/src/app/api/projects/[slug]/capitulos/route.ts b/src/app/api/projects/[slug]/capitulos/route.ts index e14708e..91dc102 100644 --- a/src/app/api/projects/[slug]/capitulos/route.ts +++ b/src/app/api/projects/[slug]/capitulos/route.ts @@ -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); } } diff --git a/src/app/api/projects/[slug]/personajes/[id]/assets/route.ts b/src/app/api/projects/[slug]/personajes/[id]/assets/route.ts index 2d18798..0161c4d 100644 --- a/src/app/api/projects/[slug]/personajes/[id]/assets/route.ts +++ b/src/app/api/projects/[slug]/personajes/[id]/assets/route.ts @@ -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); diff --git a/src/app/api/remotion/chapter-data/[slug]/[numero]/route.ts b/src/app/api/remotion/chapter-data/[slug]/[numero]/route.ts index dab26de..c131b0d 100644 --- a/src/app/api/remotion/chapter-data/[slug]/[numero]/route.ts +++ b/src/app/api/remotion/chapter-data/[slug]/[numero]/route.ts @@ -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', }, diff --git a/src/app/api/render/route.ts b/src/app/api/render/route.ts index 368c252..a82bfa2 100644 --- a/src/app/api/render/route.ts +++ b/src/app/api/render/route.ts @@ -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(); +// 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(); +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); } diff --git a/src/lib/db-service.ts b/src/lib/db-service.ts index 8271905..3cf1e2a 100644 --- a/src/lib/db-service.ts +++ b/src/lib/db-service.ts @@ -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> +): 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( @@ -834,12 +903,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(); } diff --git a/src/lib/db.ts b/src/lib/db.ts index 08721ca..011482b 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -188,7 +188,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); } } @@ -197,7 +198,8 @@ function runMigrations(db: Database.Database): void { db.exec('ALTER TABLE prompts_planos ADD COLUMN video_url TEXT'); console.log('[Migration] Added video_url 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); } } } diff --git a/src/lib/image-service.ts b/src/lib/image-service.ts index 24d8c64..7393121 100644 --- a/src/lib/image-service.ts +++ b/src/lib/image-service.ts @@ -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 { @@ -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 { diff --git a/src/lib/llm-service.ts b/src/lib/llm-service.ts index cded3c2..f929ed4 100644 --- a/src/lib/llm-service.ts +++ b/src/lib/llm-service.ts @@ -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(); + 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 { 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 { @@ -83,7 +97,7 @@ export async function refinePersonaje(personaje: Personaje, feedback: string): P export async function improvePersonajes(personajes: Personaje[], feedback: string): Promise { 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(