Compare commits
2 Commits
a698c6b225
...
acaf678567
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acaf678567 | ||
|
|
b066b5049e |
@@ -58,19 +58,13 @@ export async function GET(
|
|||||||
return NextResponse.json(null);
|
return NextResponse.json(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrar SOLO si hay datos legacy reales que convertir (personajesEnPlano sin personajesDetalle).
|
// Migrate legacy planos if needed
|
||||||
// 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 || [];
|
let planos = escaleta.planos || [];
|
||||||
const needsMigration = planos.some((p: any) =>
|
if (planos.length > 0 && planos.some((p: any) => !p.personajesDetalle || p.personajesDetalle.length === 0)) {
|
||||||
(!Array.isArray(p.personajesDetalle) || p.personajesDetalle.length === 0) &&
|
|
||||||
Array.isArray(p.personajesEnPlano) && p.personajesEnPlano.length > 0
|
|
||||||
);
|
|
||||||
if (needsMigration) {
|
|
||||||
const personajes = getPersonajes(slug);
|
const personajes = getPersonajes(slug);
|
||||||
planos = migrateLegacyPlanos(planos, personajes);
|
planos = migrateLegacyPlanos(planos, personajes);
|
||||||
saveEscaleta(slug, parseInt(numero, 10), { ...escaleta, planos });
|
// Persist migration
|
||||||
return NextResponse.json({ ...escaleta, planos });
|
saveEscaleta(slug, parseInt(numero), { ...escaleta, planos });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(escaleta);
|
return NextResponse.json(escaleta);
|
||||||
|
|||||||
@@ -17,15 +17,14 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { slug, numero, fondoId } = await params;
|
const { slug, numero, fondoId } = await params;
|
||||||
const capitulosDir = path.join(PROJECTS_DIR, path.basename(slug), 'capitulos');
|
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
|
||||||
const capDir = await findCapDir(capitulosDir, numero);
|
const capDir = await findCapDir(capitulosDir, numero);
|
||||||
|
|
||||||
if (!capDir) {
|
if (!capDir) {
|
||||||
return NextResponse.json({ error: 'Capítulo no encontrado' }, { status: 404 });
|
return NextResponse.json({ error: 'Capítulo no encontrado' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// path.basename evita traversal por el id (p.ej. "../../..").
|
const imagePath = path.join(capitulosDir, capDir.name, 'fondos', `${fondoId}.png`);
|
||||||
const imagePath = path.join(capitulosDir, capDir.name, 'fondos', `${path.basename(fondoId)}.png`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const imageBuffer = await fs.readFile(imagePath);
|
const imageBuffer = await fs.readFile(imagePath);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import fs from 'fs/promises';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { loadImage, generateSceneImage, savePlanoImage, getPlanoImagePath } from '@/lib/image-service';
|
import { loadImage, generateSceneImage, savePlanoImage, getPlanoImagePath } from '@/lib/image-service';
|
||||||
import {
|
import {
|
||||||
getPromptsPlanos, getEscaleta,
|
getPromptsPlanos, savePromptsPlanos, getEscaleta,
|
||||||
getCapDirName, savePromptComposition, updatePromptPlano, getFondos,
|
getCapDirName, savePromptComposition, updatePromptPlano, getFondos,
|
||||||
getPersonajes, getCharacterAssetsMap, getIdeaMotriz,
|
getPersonajes, getCharacterAssetsMap, getIdeaMotriz,
|
||||||
} from '@/lib/db-service';
|
} from '@/lib/db-service';
|
||||||
@@ -189,8 +189,7 @@ export async function POST(
|
|||||||
|
|
||||||
const imagenUrl = await savePlanoImage(slug, capDirName, plano.id, imageBuffer);
|
const imagenUrl = await savePlanoImage(slug, capDirName, plano.id, imageBuffer);
|
||||||
planos[planoIdx] = { ...plano, imagenUrl };
|
planos[planoIdx] = { ...plano, imagenUrl };
|
||||||
// Update por fila (no reescribir todo el set) para no pisar generaciones concurrentes.
|
savePromptsPlanos(slug, num, planos);
|
||||||
updatePromptPlano(slug, num, plano.id, { imagenUrl });
|
|
||||||
|
|
||||||
return NextResponse.json({ plano: { ...planos[planoIdx], referenceAssets: resolvedRefs } });
|
return NextResponse.json({ plano: { ...planos[planoIdx], referenceAssets: resolvedRefs } });
|
||||||
}
|
}
|
||||||
@@ -216,7 +215,7 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
const imagenUrl = await savePlanoImage(slug, capDirName, plano.id, imageBuffer);
|
const imagenUrl = await savePlanoImage(slug, capDirName, plano.id, imageBuffer);
|
||||||
planos[i] = { ...plano, imagenUrl };
|
planos[i] = { ...plano, imagenUrl };
|
||||||
updatePromptPlano(slug, num, plano.id, { imagenUrl });
|
savePromptsPlanos(slug, num, planos);
|
||||||
results.push({ id: plano.id, success: true });
|
results.push({ id: plano.id, success: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`Error generando imagen para ${plano.id}:`, err);
|
console.error(`Error generando imagen para ${plano.id}:`, err);
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import fs from 'fs/promises';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const PROJECTS_DIR = path.join(process.cwd(), 'projects');
|
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) {
|
async function findCapDir(capitulosDir: string, numero: string) {
|
||||||
const dirs = await fs.readdir(capitulosDir, { withFileTypes: true });
|
const dirs = await fs.readdir(capitulosDir, { withFileTypes: true });
|
||||||
@@ -19,15 +17,14 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { slug, numero, planoId } = await params;
|
const { slug, numero, planoId } = await params;
|
||||||
const capitulosDir = path.join(PROJECTS_DIR, path.basename(slug), 'capitulos');
|
const capitulosDir = path.join(PROJECTS_DIR, slug, 'capitulos');
|
||||||
const capDir = await findCapDir(capitulosDir, numero);
|
const capDir = await findCapDir(capitulosDir, numero);
|
||||||
|
|
||||||
if (!capDir) {
|
if (!capDir) {
|
||||||
return NextResponse.json({ error: 'Capítulo no encontrado' }, { status: 404 });
|
return NextResponse.json({ error: 'Capítulo no encontrado' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// path.basename evita traversal por el id.
|
const videoPath = path.join(PROJECTS_DIR, slug, 'capitulos', capDir.name, 'videos', `${planoId}.mp4`);
|
||||||
const videoPath = path.join(PROJECTS_DIR, path.basename(slug), 'capitulos', capDir.name, 'videos', `${path.basename(planoId)}.mp4`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const videoBuffer = await fs.readFile(videoPath);
|
const videoBuffer = await fs.readFile(videoPath);
|
||||||
@@ -35,7 +32,7 @@ export async function GET(
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'video/mp4',
|
'Content-Type': 'video/mp4',
|
||||||
'Cache-Control': 'no-store, must-revalidate',
|
'Cache-Control': 'no-store, must-revalidate',
|
||||||
'Access-Control-Allow-Origin': STUDIO_ORIGIN,
|
'Access-Control-Allow-Origin': 'http://localhost:3001',
|
||||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
'Access-Control-Allow-Headers': 'Content-Type',
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
},
|
},
|
||||||
@@ -46,7 +43,7 @@ export async function GET(
|
|||||||
{
|
{
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: {
|
headers: {
|
||||||
'Access-Control-Allow-Origin': STUDIO_ORIGIN,
|
'Access-Control-Allow-Origin': 'http://localhost:3001',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -61,7 +58,7 @@ export async function OPTIONS() {
|
|||||||
return new NextResponse(null, {
|
return new NextResponse(null, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Access-Control-Allow-Origin': STUDIO_ORIGIN,
|
'Access-Control-Allow-Origin': 'http://localhost:3001',
|
||||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
'Access-Control-Allow-Headers': 'Content-Type',
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getCapitulos, createCapitulo, getIdeaMotriz, getPersonajes, saveGuionConductor } from '@/lib/db-service';
|
import { getCapitulos, createCapitulo, getIdeaMotriz, getPersonajes } from '@/lib/db-service';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -32,9 +32,7 @@ export async function POST(
|
|||||||
|
|
||||||
if (ideaMotriz && personajes.length > 0) {
|
if (ideaMotriz && personajes.length > 0) {
|
||||||
const { generateGuionConductor } = await import('@/lib/llm-service');
|
const { generateGuionConductor } = await import('@/lib/llm-service');
|
||||||
// Antes se descartaba el resultado: se pagaba la llamada a GPT-4o y el guión se perdía.
|
await generateGuionConductor(ideaMotriz, personajes, numero, virtudSugerida);
|
||||||
const guion = await generateGuionConductor(ideaMotriz, personajes, numero, virtudSugerida);
|
|
||||||
saveGuionConductor(slug, numero, guion);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getPersonajes, getCharacterAssetsData, saveCharacterAssetsData, updateCharacterAsset, getIdeaMotriz, getVariationTemplates } from '@/lib/db-service';
|
import { getPersonajes, getCharacterAssetsData, saveCharacterAssetsData, getIdeaMotriz, getVariationTemplates } from '@/lib/db-service';
|
||||||
import { generateCharacterAssetPrompts } from '@/lib/llm-service';
|
import { generateCharacterAssetPrompts } from '@/lib/llm-service';
|
||||||
import { generateImage, generateVariation, saveImage, loadImage } from '@/lib/image-service';
|
import { generateImage, generateVariation, saveImage, loadImage } from '@/lib/image-service';
|
||||||
import { resolveEstilo, withEstilo } from '@/lib/prompt-utils';
|
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 });
|
return NextResponse.json({ error: 'Imagen base no encontrada en disco' }, { status: 400 });
|
||||||
}
|
}
|
||||||
targetAsset.status = 'generating';
|
targetAsset.status = 'generating';
|
||||||
updateCharacterAsset(slug, id, targetAsset.id, { status: 'generating' });
|
saveCharacterAssetsData(slug, id, data);
|
||||||
|
|
||||||
const templates = getVariationTemplates(slug);
|
const templates = getVariationTemplates(slug);
|
||||||
const templateKey = `${targetAsset.type}-${targetAsset.variant}`;
|
const templateKey = `${targetAsset.type}-${targetAsset.variant}`;
|
||||||
@@ -158,8 +158,7 @@ export async function POST(
|
|||||||
const imageUrl = await saveImage(slug, id, targetAsset.id, imageBuffer);
|
const imageUrl = await saveImage(slug, id, targetAsset.id, imageBuffer);
|
||||||
targetAsset.imageUrl = imageUrl;
|
targetAsset.imageUrl = imageUrl;
|
||||||
targetAsset.status = 'generated';
|
targetAsset.status = 'generated';
|
||||||
// Update por fila (no reescribir todo el set) para no pisar generaciones concurrentes.
|
saveCharacterAssetsData(slug, id, data);
|
||||||
updateCharacterAsset(slug, id, targetAsset.id, { imageUrl, status: 'generated' });
|
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,18 +182,18 @@ export async function POST(
|
|||||||
for (const asset of pending) {
|
for (const asset of pending) {
|
||||||
try {
|
try {
|
||||||
asset.status = 'generating';
|
asset.status = 'generating';
|
||||||
updateCharacterAsset(slug, id, asset.id, { status: 'generating' });
|
saveCharacterAssetsData(slug, id, data);
|
||||||
const templateKey = `${asset.type}-${asset.variant}`;
|
const templateKey = `${asset.type}-${asset.variant}`;
|
||||||
const variationPrompt = templates[templateKey] || asset.prompt;
|
const variationPrompt = templates[templateKey] || asset.prompt;
|
||||||
const imageBuffer = await generateVariation(refImage, withEstilo(variationPrompt, estilo));
|
const imageBuffer = await generateVariation(refImage, withEstilo(variationPrompt, estilo));
|
||||||
const imageUrl = await saveImage(slug, id, asset.id, imageBuffer);
|
const imageUrl = await saveImage(slug, id, asset.id, imageBuffer);
|
||||||
asset.imageUrl = imageUrl;
|
asset.imageUrl = imageUrl;
|
||||||
asset.status = 'generated';
|
asset.status = 'generated';
|
||||||
updateCharacterAsset(slug, id, asset.id, { imageUrl, status: 'generated' });
|
saveCharacterAssetsData(slug, id, data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`Error generando variación ${asset.id}:`, err);
|
console.error(`Error generando variación ${asset.id}:`, err);
|
||||||
asset.status = 'prompt_ready';
|
asset.status = 'prompt_ready';
|
||||||
updateCharacterAsset(slug, id, asset.id, { status: 'prompt_ready' });
|
saveCharacterAssetsData(slug, id, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getPromptsPlanos, getVideoVariations } from '@/lib/db-service';
|
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 {
|
interface PlanoVideoData {
|
||||||
id: string;
|
id: string;
|
||||||
numeroPlano: string;
|
numeroPlano: string;
|
||||||
@@ -44,7 +38,7 @@ export async function GET(
|
|||||||
|
|
||||||
// Convert file path to HTTP URL for browser playback
|
// Convert file path to HTTP URL for browser playback
|
||||||
// Format: /api/projects/{slug}/capitulos/{numero}/videos/{planoId}-{variationId}
|
// Format: /api/projects/{slug}/capitulos/{numero}/videos/{planoId}-{variationId}
|
||||||
const videoUrl = `${MEDIA_ORIGIN}/api/projects/${slug}/capitulos/${String(capituloNumero).padStart(2, '0')}/videos/${plano.id}-${selected.id}`;
|
const videoUrl = `http://localhost:3000/api/projects/${slug}/capitulos/${String(capituloNumero).padStart(2, '0')}/videos/${plano.id}-${selected.id}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: plano.id,
|
id: plano.id,
|
||||||
@@ -69,7 +63,7 @@ export async function GET(
|
|||||||
// Return with CORS headers for Remotion Studio
|
// Return with CORS headers for Remotion Studio
|
||||||
return NextResponse.json(chapterData, {
|
return NextResponse.json(chapterData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Access-Control-Allow-Origin': STUDIO_ORIGIN,
|
'Access-Control-Allow-Origin': 'http://localhost:3001',
|
||||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
'Access-Control-Allow-Headers': 'Content-Type',
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
},
|
},
|
||||||
@@ -81,7 +75,7 @@ export async function GET(
|
|||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: {
|
headers: {
|
||||||
'Access-Control-Allow-Origin': STUDIO_ORIGIN,
|
'Access-Control-Allow-Origin': 'http://localhost:3001',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -93,7 +87,7 @@ export async function OPTIONS() {
|
|||||||
return new NextResponse(null, {
|
return new NextResponse(null, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Access-Control-Allow-Origin': STUDIO_ORIGIN,
|
'Access-Control-Allow-Origin': 'http://localhost:3001',
|
||||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
'Access-Control-Allow-Headers': 'Content-Type',
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,21 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { renderChapterVideo, RenderProgress } from '@/lib/remotion-service';
|
import { renderChapterVideo, RenderProgress } from '@/lib/remotion-service';
|
||||||
|
|
||||||
// Cola en memoria (para producción real convendría Redis/DB). Guardamos updatedAt para poder
|
// Simple in-memory queue (in production, use Redis or DB)
|
||||||
// purgar entradas viejas y no crecer sin límite / dejar jobs zombies tras un reinicio.
|
const renderQueue = new Map<string, RenderProgress>();
|
||||||
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -29,20 +16,27 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pruneJobs();
|
|
||||||
const jobId = `${projectSlug}-${capituloNumero}-${Date.now()}`;
|
const jobId = `${projectSlug}-${capituloNumero}-${Date.now()}`;
|
||||||
|
|
||||||
// Initialize job in queue
|
// Initialize job in queue
|
||||||
setJob(jobId, { phase: 'loading', progress: 0, message: 'Iniciando...' });
|
renderQueue.set(jobId, {
|
||||||
|
phase: 'loading',
|
||||||
|
progress: 0,
|
||||||
|
message: 'Iniciando...',
|
||||||
|
});
|
||||||
|
|
||||||
// Start render in background (non-blocking)
|
// Start render in background (non-blocking)
|
||||||
renderChapterVideo(
|
renderChapterVideo(
|
||||||
{ projectSlug, capituloNumero, ...options },
|
{ projectSlug, capituloNumero, ...options },
|
||||||
(progress) => {
|
(progress) => {
|
||||||
setJob(jobId, progress);
|
renderQueue.set(jobId, progress);
|
||||||
}
|
}
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
setJob(jobId, { phase: 'error', progress: 0, message: error.message });
|
renderQueue.set(jobId, {
|
||||||
|
phase: 'error',
|
||||||
|
progress: 0,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ jobId });
|
return NextResponse.json({ jobId });
|
||||||
@@ -61,11 +55,11 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing jobId' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing jobId' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = renderQueue.get(jobId);
|
const progress = renderQueue.get(jobId);
|
||||||
|
|
||||||
if (!entry) {
|
if (!progress) {
|
||||||
return NextResponse.json({ error: 'Job not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Job not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(entry.progress);
|
return NextResponse.json(progress);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ export function getProject(slug: string): Project | null {
|
|||||||
|
|
||||||
export function createProject(name: string): Project {
|
export function createProject(name: string): Project {
|
||||||
const slug = slugify(name);
|
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);
|
const existing = getProject(slug);
|
||||||
if (existing) throw new Error(`El proyecto "${name}" ya existe`);
|
if (existing) throw new Error(`El proyecto "${name}" ya existe`);
|
||||||
|
|
||||||
@@ -178,7 +177,6 @@ export function savePersonajes(slug: string, personajes: Personaje[]): void {
|
|||||||
if (!projectId) throw new Error('Proyecto no encontrado');
|
if (!projectId) throw new Error('Proyecto no encontrado');
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const removedIds: string[] = [];
|
|
||||||
const tx = db.transaction(() => {
|
const tx = db.transaction(() => {
|
||||||
const existingIds = db.prepare('SELECT id FROM personajes WHERE project_id = ?')
|
const existingIds = db.prepare('SELECT id FROM personajes WHERE project_id = ?')
|
||||||
.all(projectId).map((r: any) => r.id);
|
.all(projectId).map((r: any) => r.id);
|
||||||
@@ -189,7 +187,6 @@ export function savePersonajes(slug: string, personajes: Personaje[]): void {
|
|||||||
for (const id of existingIds) {
|
for (const id of existingIds) {
|
||||||
if (!newIds.has(id)) {
|
if (!newIds.has(id)) {
|
||||||
db.prepare('DELETE FROM personajes WHERE id = ?').run(id);
|
db.prepare('DELETE FROM personajes WHERE id = ?').run(id);
|
||||||
removedIds.push(id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,12 +204,6 @@ export function savePersonajes(slug: string, personajes: Personaje[]): void {
|
|||||||
});
|
});
|
||||||
tx();
|
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();
|
const now = new Date().toISOString();
|
||||||
db.prepare('UPDATE projects SET updated_at = ? WHERE id = ?').run(now, projectId);
|
db.prepare('UPDATE projects SET updated_at = ? WHERE id = ?').run(now, projectId);
|
||||||
}
|
}
|
||||||
@@ -289,32 +280,6 @@ export function saveCharacterAssetsData(slug: string, personajeId: string, data:
|
|||||||
tx();
|
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
|
// VARIATION TEMPLATES
|
||||||
// =====================
|
// =====================
|
||||||
@@ -659,12 +624,6 @@ export function savePromptsPlanos(slug: string, numero: number, planos: PlanoCon
|
|||||||
if (!capId) throw new Error('Capítulo no encontrado');
|
if (!capId) throw new Error('Capítulo no encontrado');
|
||||||
|
|
||||||
const db = getDb();
|
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(() => {
|
const tx = db.transaction(() => {
|
||||||
// Delete existing planos for this chapter
|
// Delete existing planos for this chapter
|
||||||
db.prepare('DELETE FROM prompts_planos WHERE capitulo_id = ?').run(capId);
|
db.prepare('DELETE FROM prompts_planos WHERE capitulo_id = ?').run(capId);
|
||||||
@@ -691,18 +650,6 @@ export function savePromptsPlanos(slug: string, numero: number, planos: PlanoCon
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
tx();
|
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(
|
export function updatePromptPlano(
|
||||||
@@ -771,11 +718,6 @@ export function saveFondos(slug: string, numero: number, fondos: FondoReferencia
|
|||||||
if (!capId) throw new Error('Capítulo no encontrado');
|
if (!capId) throw new Error('Capítulo no encontrado');
|
||||||
|
|
||||||
const db = getDb();
|
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(() => {
|
const tx = db.transaction(() => {
|
||||||
db.prepare('DELETE FROM fondos WHERE capitulo_id = ?').run(capId);
|
db.prepare('DELETE FROM fondos WHERE capitulo_id = ?').run(capId);
|
||||||
|
|
||||||
@@ -789,17 +731,6 @@ export function saveFondos(slug: string, numero: number, fondos: FondoReferencia
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
tx();
|
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(
|
export function updateFondo(
|
||||||
@@ -951,12 +882,12 @@ export function selectVideoVariation(slug: string, numero: number, planoId: stri
|
|||||||
WHERE capitulo_id = ? AND plano_id = ?
|
WHERE capitulo_id = ? AND plano_id = ?
|
||||||
`).run(capId, planoId);
|
`).run(capId, planoId);
|
||||||
|
|
||||||
// Select the target variation (validando que pertenezca a este plano/capítulo).
|
// Select the target variation
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE video_variations
|
UPDATE video_variations
|
||||||
SET is_selected = 1
|
SET is_selected = 1
|
||||||
WHERE id = ? AND capitulo_id = ? AND plano_id = ?
|
WHERE id = ?
|
||||||
`).run(variationId, capId, planoId);
|
`).run(variationId);
|
||||||
});
|
});
|
||||||
tx();
|
tx();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,8 +192,7 @@ function runMigrations(db: Database.Database): void {
|
|||||||
db.exec('ALTER TABLE prompts_planos ADD COLUMN prompt_video TEXT');
|
db.exec('ALTER TABLE prompts_planos ADD COLUMN prompt_video TEXT');
|
||||||
console.log('[Migration] Added prompt_video column to prompts_planos');
|
console.log('[Migration] Added prompt_video column to prompts_planos');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Normalmente "duplicate column" (ya migrada). Logueamos por si es otro error de esquema.
|
// Column already exists or other error - safe to ignore
|
||||||
console.warn('[Migration] ALTER prompts_planos falló (posiblemente ya aplicada):', e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,8 +201,7 @@ function runMigrations(db: Database.Database): void {
|
|||||||
db.exec('ALTER TABLE prompts_planos ADD COLUMN video_url TEXT');
|
db.exec('ALTER TABLE prompts_planos ADD COLUMN video_url TEXT');
|
||||||
console.log('[Migration] Added video_url column to prompts_planos');
|
console.log('[Migration] Added video_url column to prompts_planos');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Normalmente "duplicate column" (ya migrada). Logueamos por si es otro error de esquema.
|
// Column already exists or other error - safe to ignore
|
||||||
console.warn('[Migration] ALTER prompts_planos falló (posiblemente ya aplicada):', e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,18 +20,12 @@ function getGenAI(): GoogleGenAI {
|
|||||||
// IMAGE STORAGE
|
// 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 {
|
function getImagesDir(slug: string, personajeId: string): string {
|
||||||
return path.join(PROJECTS_DIR, safeSeg(slug), 'personajes', safeSeg(personajeId), 'images');
|
return path.join(PROJECTS_DIR, slug, 'personajes', personajeId, 'images');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getImagePath(slug: string, personajeId: string, assetId: string): string {
|
export function getImagePath(slug: string, personajeId: string, assetId: string): string {
|
||||||
return path.join(getImagesDir(slug, personajeId), `${safeSeg(assetId)}.png`);
|
return path.join(getImagesDir(slug, personajeId), `${assetId}.png`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveImage(slug: string, personajeId: string, assetId: string, imageBuffer: Buffer): Promise<string> {
|
export async function saveImage(slug: string, personajeId: string, assetId: string, imageBuffer: Buffer): Promise<string> {
|
||||||
@@ -196,11 +190,11 @@ export async function generateSceneImage(
|
|||||||
// ---------------------
|
// ---------------------
|
||||||
|
|
||||||
function getPlanoImagesDir(slug: string, capDir: string): string {
|
function getPlanoImagesDir(slug: string, capDir: string): string {
|
||||||
return path.join(PROJECTS_DIR, safeSeg(slug), 'capitulos', safeSeg(capDir), 'imagenes');
|
return path.join(PROJECTS_DIR, slug, 'capitulos', capDir, 'imagenes');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPlanoImagePath(slug: string, capDir: string, planoId: string): string {
|
export function getPlanoImagePath(slug: string, capDir: string, planoId: string): string {
|
||||||
return path.join(getPlanoImagesDir(slug, capDir), `${safeSeg(planoId)}.png`);
|
return path.join(getPlanoImagesDir(slug, capDir), `${planoId}.png`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function savePlanoImage(slug: string, capDir: string, planoId: string, imageBuffer: Buffer): Promise<string> {
|
export async function savePlanoImage(slug: string, capDir: string, planoId: string, imageBuffer: Buffer): Promise<string> {
|
||||||
|
|||||||
@@ -69,24 +69,10 @@ export async function refineIdeaMotriz(current: IdeaMotriz, feedback: string): P
|
|||||||
// PERSONAJES
|
// 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[]> {
|
export async function generatePersonajes(ideaMotriz: IdeaMotriz): Promise<Personaje[]> {
|
||||||
const content = await callLLM(getPersonajesGeneratePrompt(ideaMotriz));
|
const content = await callLLM(getPersonajesGeneratePrompt(ideaMotriz));
|
||||||
const result = JSON.parse(content);
|
const result = JSON.parse(content);
|
||||||
return ensurePersonajeIds(result.personajes as Personaje[]);
|
return result.personajes as Personaje[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refinePersonaje(personaje: Personaje, feedback: string): Promise<Personaje> {
|
export async function refinePersonaje(personaje: Personaje, feedback: string): Promise<Personaje> {
|
||||||
@@ -97,7 +83,7 @@ export async function refinePersonaje(personaje: Personaje, feedback: string): P
|
|||||||
export async function improvePersonajes(personajes: Personaje[], feedback: string): Promise<Personaje[]> {
|
export async function improvePersonajes(personajes: Personaje[], feedback: string): Promise<Personaje[]> {
|
||||||
const content = await callLLM(getPersonajesImprovePrompt(personajes), feedback, { temperature: 0.7 });
|
const content = await callLLM(getPersonajesImprovePrompt(personajes), feedback, { temperature: 0.7 });
|
||||||
const result = JSON.parse(content);
|
const result = JSON.parse(content);
|
||||||
return ensurePersonajeIds(result.personajes as Personaje[]);
|
return result.personajes as Personaje[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateNewPersonaje(
|
export async function generateNewPersonaje(
|
||||||
|
|||||||
Reference in New Issue
Block a user