import React, { useState, useCallback, useEffect } from 'react';
import { Loader2, Zap, Image, FileUp, X, Sparkles, Download, MessageSquare, Brush, Undo2, Maximize2 } from 'lucide-react';
// Constantes para la configuración de la API
const API_KEY = "";
// Modelos para Imagen a Imagen (I2I)
const IMAGE_MODEL_NAME = "gemini-2.5-flash-image-preview";
const IMAGE_API_URL = `https://generativelanguage.googleapis.com/v1beta/models/${IMAGE_MODEL_NAME}:generateContent?key=${API_KEY}`;
// Modelo para Texto a Imagen (T2I)
const T2I_MODEL_NAME = "imagen-4.0-generate-001";
const T2I_API_URL = `https://generativelanguage.googleapis.com/v1beta/models/${T2I_MODEL_NAME}:predict?key=${API_KEY}`;
const TEXT_MODEL_NAME = "gemini-2.5-flash-preview-09-2025";
const TEXT_API_URL = `https://generativelanguage.googleapis.com/v1beta/models/${TEXT_MODEL_NAME}:generateContent?key=${API_KEY}`;
const MAX_RETRIES = 3;
// DIMENSIONES SOLICITADAS POR EL USUARIO (1920x1080)
const TARGET_WIDTH = 1920;
const TARGET_HEIGHT = 1080;
// Lista de autores de tebeos y el nuevo estilo
const COMIC_AUTHORS = [
{ id: 'vazquez', name: 'Manuel Vázquez Gallego', description: 'Línea suelta, humor absurdo (El botones Sacarino).' },
{ id: 'ibanez', name: 'Francisco Ibáñez', description: 'Línea clara, expresividad exagerada (Mortadelo y Filemón).' },
{ id: 'escobar', name: 'José Escobar', description: 'Diseño de personajes sencillo, gestos marcados (Zipi y Zape).' },
{ id: 'sanchis', name: 'José Sanchis', description: 'Dibujo detallado y estético (El Capitán Trueno).' },
{ id: 'ceras', name: 'Dibujo a Ceras (Crayón)', description: 'Estilo de ilustración con textura de cera, contornos difusos.' },
];
/**
* Función de utilidad para convertir un archivo de imagen a Base64.
* @param {File} file - El objeto File a convertir.
* @returns {Promise} La cadena Base64 (sin el prefijo 'data:image/...').
*/
const fileToBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
// Extrae solo la parte Base64 (después de la coma)
const base64String = reader.result.split(',')[1];
resolve(base64String);
};
reader.onerror = (error) => reject(error);
});
};
// Componente principal de la aplicación
const App = () => {
// Estado para la pestaña/modo actual: 'comic' o 'repair'
const [mode, setMode] = useState('comic');
// Estados compartidos
const [inputImageFile, setInputImageFile] = useState(null);
const [inputImageBase64, setInputImageBase64] = useState(null);
const [generatedImageUrl, setGeneratedImageUrl] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
// Estados específicos del modo 'comic'
const [selectedStyle, setSelectedStyle] = useState(COMIC_AUTHORS[1].id);
const [promptText, setPromptText] = useState('');
const [dialogueContext, setDialogueContext] = useState('');
const [generatedDialogue, setGeneratedDialogue] = useState('');
const [isDialogueLoading, setIsDialogueLoading] = useState(false);
const currentAuthor = COMIC_AUTHORS.find(a => a.id === selectedStyle);
// Efecto para limpiar la imagen generada y errores al cambiar de modo
useEffect(() => {
setGeneratedImageUrl(null);
setGeneratedDialogue('');
setPromptText('');
setDialogueContext('');
setError(null);
setIsLoading(false);
setIsDialogueLoading(false);
}, [mode]);
const handleFileChange = async (event) => {
setError(null);
const file = event.target.files[0];
if (file) {
if (!file.type.startsWith('image/')) {
setError('Por favor, sube un archivo de imagen (JPEG, PNG).');
return;
}
try {
const base64 = await fileToBase64(file);
setInputImageBase64(base64);
setInputImageFile(file);
setGeneratedImageUrl(null); // Borrar resultado anterior al subir nueva imagen
} catch (e) {
setError('Error al procesar la imagen.');
console.error("Error al procesar el archivo:", e);
}
} else {
setInputImageBase64(null);
setInputImageFile(null);
setGeneratedImageUrl(null);
}
};
const removeImage = () => {
setInputImageFile(null);
setInputImageBase64(null);
setGeneratedImageUrl(null);
setError(null);
document.getElementById('file-upload-input').value = '';
};
/**
* Función unificada para manejar las llamadas a la API de Imagen.
* @param {string} finalPrompt - El prompt final para el modelo.
* @param {boolean} isImageProvided - Indica si se ha proporcionado una imagen de entrada (I2I) o no (T2I).
*/
const callImageApi = async (finalPrompt, isImageProvided) => {
// Definir la configuración de resolución para ambos modelos
const imageConfig = { width: TARGET_WIDTH, height: TARGET_HEIGHT };
for (let i = 0; i < MAX_RETRIES; i++) {
try {
let response, result, base64Data;
if (isImageProvided) {
// --- Image-to-Image (I2I) usando gemini-2.5-flash-image-preview ---
const payload = {
contents: [
{
role: "user",
parts: [
{ text: finalPrompt },
{ inlineData: { mimeType: inputImageFile.type, data: inputImageBase64 } }
]
}
],
generationConfig: { responseModalities: ['TEXT', 'IMAGE'] },
config: imageConfig,
};
response = await fetch(IMAGE_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) { throw new Error(`Error HTTP! Estado: ${response.status}`); }
result = await response.json();
base64Data = result?.candidates?.[0]?.content?.parts?.find(p => p.inlineData)?.inlineData?.data;
if (base64Data) {
setGeneratedImageUrl(`data:image/png;base64,${base64Data}`);
return; // Éxito I2I
} else {
throw new Error("Respuesta del modelo incompleta o sin imagen (I2I).");
}
} else {
// --- Text-to-Image (T2I) usando imagen-4.0-generate-001 ---
const payload = {
instances: [{ prompt: finalPrompt }],
parameters: {
sampleCount: 1,
outputMimeType: "image/png",
config: imageConfig
}
};
response = await fetch(T2I_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) { throw new Error(`Error HTTP! Estado: ${response.status}`); }
result = await response.json();
base64Data = result.predictions && result.predictions.length > 0 && result.predictions[0].bytesBase64Encoded;
if (base64Data) {
// CORRECCIÓN: Se cambió 'base664' a 'base64' para que el navegador pueda decodificar la imagen correctamente.
setGeneratedImageUrl(`data:image/png;base64,${base64Data}`);
return; // Éxito T2I
} else {
throw new Error("Respuesta del modelo incompleta o sin imagen (T2I).");
}
}
} catch (e) {
console.error(`Error en el intento ${i + 1}:`, e);
if (i < MAX_RETRIES - 1) {
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000)); // Backoff exponencial
} else {
throw new Error(`Error al generar la imagen. Inténtalo de nuevo. (${e.message})`);
}
}
}
};
/**
* Generación de Imagen para el modo CÓMIC.
*/
const handleGenerateComicImage = useCallback(async () => {
const isImageProvided = !!inputImageBase64;
if (!isImageProvided && !promptText) {
setError('Para generar una imagen sin subir una foto, debes proporcionar una descripción en el paso 3.');
return;
}
if (!currentAuthor) {
setError('Por favor, selecciona un estilo de autor.');
return;
}
setIsLoading(true);
setError(null);
setGeneratedImageUrl(null);
let styleInstruction = '';
// Instrucciones que difieren si es I2I o T2I
const i2iInstruction = `Transforma radicalmente la imagen proporcionada en una ILUSTRACIÓN, un DIBUJO HECHO A MANO y PURO. Prohíbido cualquier rastro de fotorealismo, texturas fotográficas, iluminación natural, o cualquier elemento que sugiera una foto procesada o un filtro. Reinterpreta todos los elementos (personas, objetos, fondos) como si hubieran sido dibujados desde cero.`;
const t2iInstruction = `Genera un DIBUJO HECHO A MANO y PURO, basado en la siguiente descripción. Prohíbido cualquier rastro de fotorealismo, texturas fotográficas, iluminación natural, o cualquier elemento que sugiera una foto procesada.`;
const generalInstruction = isImageProvided ? i2iInstruction : t2iInstruction;
const textProhibition = `La imagen NUNCA debe incluir NINGÚN TIPO de texto, rótulo, letrero, palabra escrita, firma, marca de agua o globo de diálogo (speech bubble). El dibujo debe estar completamente limpio de elementos textuales.`;
if (selectedStyle === 'ceras') {
styleInstruction = `${generalInstruction}
**ESTILO DE DIBUJO CON CERAS (CRAYÓN):**
1. Textura granulada y áspera, simulando cera.
2. Contornos gruesos. Colores brillantes, aplicados con trazos visibles.
3. Apariencia de boceto vigoroso y no pulido.`;
} else {
styleInstruction = `${generalInstruction} al estilo artístico inconfundible de ${currentAuthor.name} (${currentAuthor.description}).
**ESTILO DE TEBEO CLÁSICO (TINTA):**
1. Líneas de contorno gruesas (Line Art).
2. Colores planos, saturados y limitados, sin degradados.
3. Exageración en gestos y expresiones (humorístico).`;
}
const userPromptText = promptText
? `Además, incluye estas instrucciones: "${promptText}".`
: (isImageProvided ? 'Asegúrate de que la transformación al estilo sea fiel al ambiente y composición de la foto original.' : '');
// Se añade el requisito de dimensión en el prompt como refuerzo
const dimensionConstraint = `El resultado debe ser una imagen de alta calidad, específicamente con una relación de aspecto de ${TARGET_WIDTH}:${TARGET_HEIGHT}.`;
const finalPrompt = `${styleInstruction} ${textProhibition} ${userPromptText} ${dimensionConstraint}`;
try {
await callImageApi(finalPrompt, isImageProvided);
} catch (e) {
setError(e.message);
} finally {
setIsLoading(false);
}
}, [inputImageBase64, inputImageFile, selectedStyle, promptText, currentAuthor]);
/**
* Generación de Imagen para el modo REPARAR y COLORIZAR.
*/
const handleRepairImage = useCallback(async () => {
// El modo reparación SIEMPRE requiere una imagen
if (!inputImageBase64) {
setError('Por favor, primero sube una foto antigua para repararla.');
return;
}
setIsLoading(true);
setError(null);
setGeneratedImageUrl(null);
// Se añade el requisito de dimensión en el prompt como refuerzo
const dimensionConstraint = `El resultado debe ser una imagen de alta calidad, específicamente con una relación de aspecto de ${TARGET_WIDTH}:${TARGET_HEIGHT}.`;
const repairPrompt = `RESTORE AND COLORIZE: Act as a professional photo restorer. Analyze the historical image provided, remove all signs of damage, degradation, scratches, mold, or dust. Improve clarity and sharpness while maintaining historical authenticity. After restoration, colorize the image using natural, realistic colors, avoiding any stylized, artistic, or comic effects. The final output must be a fully restored, realistic, and colorized photo. ${dimensionConstraint}`;
try {
// Usamos true porque el modo Reparar siempre es I2I
await callImageApi(repairPrompt, true);
} catch (e) {
setError(e.message);
} finally {
setIsLoading(false);
}
}, [inputImageBase64, inputImageFile]);
/**
* Genera un diálogo basado en el contexto y el autor seleccionado.
*/
const handleGenerateDialogue = useCallback(async () => {
if (!dialogueContext || !currentAuthor) {
console.warn('Faltan datos para generar el diálogo.');
return;
}
setIsDialogueLoading(true);
setGeneratedDialogue('');
const systemInstruction = {
parts: [{
text: `Actúa como un guionista experto de cómics españoles de la Escuela Bruguera, imitando el tono cómico y absurdo de ${currentAuthor.name}. Genera una línea de diálogo o una frase de pie de viñeta basada en la situación que se te proporciona. La respuesta debe ser fiel al estilo del autor, breve y punzante. Responde SÓLO con el texto del diálogo generado, sin explicaciones, títulos, comillas externas o encabezados.`
}],
};
const userQuery = `Situación: "${dialogueContext}". Genera un diálogo al estilo de ${currentAuthor.name}.`;
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: systemInstruction,
};
// Lógica de llamada a API de texto (gemini-2.5-flash)
for (let i = 0; i < MAX_RETRIES; i++) {
try {
const response = await fetch(TEXT_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`Error HTTP! Estado: ${response.status}`);
}
const result = await response.json();
const text = result.candidates?.[0]?.content?.parts?.[0]?.text;
if (text) {
setGeneratedDialogue(text.trim());
return; // Éxito
} else {
throw new Error("Respuesta del modelo incompleta o sin texto.");
}
} catch (e) {
console.error(`Error en el intento ${i + 1} (Texto):`, e);
if (i < MAX_RETRIES - 1) {
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
} else {
setError(`Error al generar el diálogo. Inténtalo de nuevo. (${e.message})`);
}
}
}
setIsDialogueLoading(false);
}, [dialogueContext, currentAuthor]);
const copyDialogue = () => {
if (generatedDialogue) {
const el = document.createElement('textarea');
el.value = generatedDialogue;
document.body.appendChild(el);
el.select();
document.execCommand('copy');
console.log('Diálogo copiado al portapapeles.');
}
};
/**
* Componente para el panel de configuración del Modo CÓMIC.
*/
const ComicConfigPanel = () => (
<>
{/* 2. Elige Estilo de Autor */}
2. Elige un Estilo de Dibujo
{COMIC_AUTHORS.map((author) => (
))}
{/* 3. Añade Personajes/Instrucciones (para la Imagen) */}
3. Descripción del Dibujo (Necesaria si no hay foto)
{/* 4. Guioniza de Diálogos */}
4. ✨ Guioniza tu Viñeta
Describe la situación y Gemini generará un diálogo cómico al estilo de {currentAuthor.name}.
>
);
/**
* Componente para el panel de configuración del Modo REPARAR.
*/
const RepairConfigPanel = () => (
2. Restaurar y Colorear
Esta herramienta elimina el daño (arañazos, manchas, polvo) y añade color de forma realista a tus fotos antiguas en blanco y negro.
Instrucciones del proceso:
El sistema intentará primero reparar cualquier imperfección (rasguños, polvo).
Luego, aplicará coloración fotorrealista y natural.
No se aplicará ningún estilo artístico o de tebeo.
);
return (
Estudio Digital de Arte
Transforma tus fotos: de la nostalgia a la viñeta cómica.
{/* Selector de Modo (Pestañas) */}
{error && (
Error:
{error}
)}
{/* Columna de Configuración */}
{/* 1. Sube la Foto (Común a ambos modos) */}
1. Sube tu Foto
{mode === 'comic'
? 'Para el modo **Creador de Tebeos**, subir una foto es opcional. Puedes saltarte este paso y describir la escena en el paso 3.'
: 'Para el modo **Reparar y Colorear**, subir una foto es obligatorio.'
}
{inputImageFile ? (
// Mostrar Nombre del Archivo y Botón de Eliminar
{inputImageFile.name}
) : (
// Mostrar área de carga
)}
{inputImageBase64 && (
Vista Previa de la Foto Original:
)}
{/* Renderizar panel de configuración según el modo */}
{mode === 'comic' ? : }
{/* Columna de Resultado (Común a ambos modos) */}
No hay comentarios:
Publicar un comentario