|
<!DOCTYPE html> |
|
<html lang="fr"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Générateur de Manga IA</title> |
|
<style> |
|
|
|
* { |
|
margin: 0; |
|
padding: 0; |
|
box-sizing: border-box; |
|
} |
|
body { |
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
min-height: 100vh; |
|
color: #333; |
|
line-height: 1.6; |
|
} |
|
.container { |
|
max-width: 1200px; |
|
margin: 0 auto; |
|
padding: 20px; |
|
} |
|
|
|
|
|
.header { |
|
text-align: center; |
|
margin-bottom: 40px; |
|
} |
|
.header h1 { |
|
color: white; |
|
font-size: 3em; |
|
margin-bottom: 10px; |
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.3); |
|
} |
|
.header p { |
|
color: rgba(255,255,255,0.9); |
|
font-size: 1.2em; |
|
} |
|
|
|
|
|
.main-content { |
|
display: grid; |
|
grid-template-columns: 1fr 1fr; |
|
gap: 30px; |
|
margin-bottom: 30px; |
|
} |
|
|
|
|
|
.card { |
|
background: rgba(255, 255, 255, 0.98); |
|
padding: 30px; |
|
border-radius: 15px; |
|
box-shadow: 0 10px 30px rgba(0,0,0,0.2); |
|
} |
|
.card h2 { |
|
color: #4a5568; |
|
margin-bottom: 20px; |
|
border-bottom: 3px solid #667eea; |
|
padding-bottom: 10px; |
|
} |
|
|
|
|
|
.json-input { |
|
width: 100%; |
|
height: 400px; |
|
padding: 15px; |
|
border: 2px solid #e2e8f0; |
|
border-radius: 10px; |
|
font-family: 'Courier New', monospace; |
|
font-size: 14px; |
|
resize: vertical; |
|
background: #f8fafc; |
|
} |
|
.json-input:focus { |
|
outline: none; |
|
border-color: #667eea; |
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); |
|
} |
|
|
|
|
|
.btn { |
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
color: white; |
|
border: none; |
|
padding: 15px 30px; |
|
border-radius: 10px; |
|
cursor: pointer; |
|
font-size: 16px; |
|
font-weight: bold; |
|
transition: all 0.3s ease; |
|
width: 100%; |
|
margin-top: 20px; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
gap: 10px; |
|
} |
|
.btn:hover { |
|
transform: translateY(-2px); |
|
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3); |
|
} |
|
.btn:disabled { |
|
background: #a0aec0; |
|
cursor: not-allowed; |
|
transform: none; |
|
box-shadow: none; |
|
} |
|
.download-btn { |
|
background: linear-gradient(135deg, #48bb78 0%, #38a169 100%); |
|
color: white; |
|
text-decoration: none; |
|
display: inline-block; |
|
margin-top: 15px; |
|
padding: 12px 24px; |
|
} |
|
.download-btn:hover { |
|
box-shadow: 0 5px 15px rgba(72, 187, 120, 0.3); |
|
} |
|
|
|
|
|
.status-card-display { |
|
background: #f7fafc; |
|
border: 2px solid #e2e8f0; |
|
border-radius: 10px; |
|
padding: 20px; |
|
margin: 15px 0; |
|
transition: all 0.3s ease; |
|
} |
|
.status-card-display.generating { border-color: #f6ad55; background: #fffaf0; } |
|
.status-card-display.completed { border-color: #68d391; background: #f0fff4; } |
|
.status-card-display.error { border-color: #fc8181; background: #fffafa; } |
|
|
|
.progress-bar { |
|
background: #e2e8f0; |
|
border-radius: 10px; |
|
height: 20px; |
|
margin: 15px 0; |
|
overflow: hidden; |
|
} |
|
.progress-fill { |
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
height: 100%; |
|
transition: width 0.5s ease; |
|
border-radius: 10px; |
|
} |
|
|
|
|
|
.preview-section { |
|
display: none; |
|
} |
|
.image-grid { |
|
display: grid; |
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); |
|
gap: 20px; |
|
margin-top: 20px; |
|
} |
|
.image-grid img { |
|
width: 100%; |
|
height: auto; |
|
border-radius: 10px; |
|
box-shadow: 0 5px 15px rgba(0,0,0,0.1); |
|
object-fit: cover; |
|
aspect-ratio: 2 / 3; |
|
background-color: #f0f0f0; |
|
} |
|
|
|
|
|
.spinner { |
|
display: inline-block; |
|
width: 20px; |
|
height: 20px; |
|
border: 3px solid rgba(255,255,255,.3); |
|
border-radius: 50%; |
|
border-top-color: #fff; |
|
animation: spin 1s ease-in-out infinite; |
|
} |
|
@keyframes spin { to { transform: rotate(360deg); } } |
|
.alert { |
|
padding: 15px; |
|
border-radius: 10px; |
|
margin: 15px 0; |
|
font-weight: bold; |
|
} |
|
.alert-error { background: #fed7d7; color: #c53030; border: 2px solid #fc8181; } |
|
.alert-success { background: #c6f6d5; color: #2f855a; border: 2px solid #68d391; } |
|
|
|
|
|
.example-json { |
|
background: #2d3748; |
|
color: #e2e8f0; |
|
padding: 20px; |
|
border-radius: 10px; |
|
font-family: 'Courier New', monospace; |
|
font-size: 14px; |
|
overflow-x: auto; |
|
white-space: pre-wrap; |
|
} |
|
|
|
|
|
@media (max-width: 900px) { |
|
.main-content { grid-template-columns: 1fr; } |
|
.header h1 { font-size: 2.5em; } |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<div class="container"> |
|
|
|
<header class="header"> |
|
<h1>🎨 Générateur de Manga IA</h1> |
|
<p>Créez votre manga personnalisé avec l'IA Gemini. Décrivez chaque page et laissez la magie opérer !</p> |
|
</header> |
|
|
|
<main class="main-content"> |
|
<section id="input-section" class="card"> |
|
<h2>📝 Scénario du Manga</h2> |
|
<textarea id="jsonInput" class="json-input" placeholder="Collez votre JSON de configuration ici...">{ |
|
"partie-1": "Page de titre, style manga épique. Un jeune épéiste aux cheveux argentés se tient sur une falaise, regardant une ville futuriste sous une lune brisée. Titre : 'CHRONIQUES DE NÉO-KYOTO'.", |
|
"partie-2": "Style manga noir et blanc. L'épéiste, Kai, marche dans une ruelle sombre de Néo-Kyoto, éclairée par des néons. Il a l'air méfiant.", |
|
"partie-3": "Action intense. Kai esquive une attaque laser d'un drone robotique. Traits de vitesse et effets d'impact. Style dynamique.", |
|
"partie-4": "Gros plan sur le visage déterminé de Kai alors qu'il active son épée énergétique, qui brille d'une lumière bleue. Il se prépare à contre-attaquer." |
|
}</textarea> |
|
<button id="generateBtn" class="btn"> |
|
<span id="btnIcon">🚀</span> |
|
<span id="btnText">Générer le Manga</span> |
|
</button> |
|
</section> |
|
|
|
<section id="status-section" class="card"> |
|
<h2>📊 Suivi de Génération</h2> |
|
<div id="statusContainer"> |
|
<p style="color: #718096; text-align: center; padding: 40px;"> |
|
Prêt à démarrer.<br> |
|
Décrivez votre scénario et cliquez sur "Générer" pour commencer. |
|
</p> |
|
</div> |
|
</section> |
|
</main> |
|
|
|
<section id="previewSection" class="card preview-section"> |
|
<h2>🖼️ Prévisualisation des Pages</h2> |
|
<div id="imageGrid" class="image-grid"> |
|
|
|
</div> |
|
</section> |
|
|
|
<section class="card"> |
|
<h2>📋 Format du Scénario (JSON)</h2> |
|
<p style="margin-bottom: 20px;"> |
|
Utilisez des clés "partie-X" (où X est un numéro) avec une description détaillée pour chaque page : |
|
</p> |
|
<div class="example-json">{ |
|
"partie-1": "Description détaillée de la première page...", |
|
"partie-2": "Description détaillée de la deuxième page...", |
|
"partie-N": "Continuez avec autant de parties que nécessaire..." |
|
}</div> |
|
<p style="margin-top: 15px; color: #4a5568;"> |
|
<strong>Conseils :</strong> Soyez précis ! Mentionnez le style (noir et blanc, couleur, shonen, etc.), les personnages, l'action, l'ambiance et l'angle de caméra pour de meilleurs résultats. |
|
</p> |
|
</section> |
|
|
|
</div> |
|
|
|
<script> |
|
|
|
let currentTaskId = null; |
|
let statusInterval = null; |
|
|
|
|
|
const generateBtn = document.getElementById('generateBtn'); |
|
const jsonInput = document.getElementById('jsonInput'); |
|
const statusContainer = document.getElementById('statusContainer'); |
|
const previewSection = document.getElementById('previewSection'); |
|
const imageGrid = document.getElementById('imageGrid'); |
|
const btnIcon = document.getElementById('btnIcon'); |
|
const btnText = document.getElementById('btnText'); |
|
|
|
|
|
generateBtn.addEventListener('click', startGeneration); |
|
jsonInput.addEventListener('blur', autoFormatJSON); |
|
window.addEventListener('beforeunload', () => clearInterval(statusInterval)); |
|
|
|
|
|
async function startGeneration() { |
|
|
|
resetUI(); |
|
|
|
|
|
let jsonData; |
|
try { |
|
const jsonText = jsonInput.value.trim(); |
|
if (!jsonText) throw new Error("Veuillez saisir un scénario JSON."); |
|
jsonData = JSON.parse(jsonText); |
|
if (Object.keys(jsonData).filter(k => k.startsWith('partie-')).length === 0) { |
|
throw new Error('Aucune "partie-X" trouvée dans le JSON.'); |
|
} |
|
} catch (error) { |
|
showAlert(`Erreur de format JSON : ${error.message}`, 'error'); |
|
return; |
|
} |
|
|
|
|
|
try { |
|
setButtonState(true, 'Démarrage...'); |
|
const response = await fetch('/generate', { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify(jsonData) |
|
}); |
|
const result = await response.json(); |
|
|
|
if (!response.ok) throw new Error(result.error || 'Erreur inconnue du serveur.'); |
|
|
|
|
|
currentTaskId = result.task_id; |
|
startStatusPolling(); |
|
showAlert(`Génération démarrée ! Tâche ID : ${currentTaskId}`, 'success'); |
|
|
|
} catch (error) { |
|
showAlert(`Erreur de démarrage : ${error.message}`, 'error'); |
|
setButtonState(false); |
|
} |
|
} |
|
|
|
function startStatusPolling() { |
|
clearInterval(statusInterval); |
|
statusInterval = setInterval(async () => { |
|
if (!currentTaskId) return; |
|
try { |
|
const response = await fetch(`/status/${currentTaskId}`); |
|
const status = await response.json(); |
|
if (response.ok) { |
|
updateStatusDisplay(status); |
|
if (['completed', 'error'].includes(status.status)) { |
|
clearInterval(statusInterval); |
|
setButtonState(false); |
|
} |
|
} else { |
|
console.error('Erreur de statut:', status.error); |
|
} |
|
} catch (error) { |
|
console.error('Erreur réseau:', error); |
|
} |
|
}, 3000); |
|
} |
|
|
|
function updateStatusDisplay(status) { |
|
|
|
const statusInfo = getStatusInfo(status.status); |
|
const progressHtml = getProgressHtml(status); |
|
const downloadHtml = status.status === 'completed' ? getDownloadButtonHtml() : ''; |
|
const errorHtml = status.error ? `<div class="alert alert-error">${status.error}</div>` : ''; |
|
|
|
statusContainer.innerHTML = ` |
|
<div class="status-card-display ${statusInfo.class}"> |
|
<h3>${statusInfo.icon} ${statusInfo.text}</h3> |
|
<p><small><strong>ID :</strong> ${currentTaskId}</small></p> |
|
${progressHtml} |
|
${errorHtml} |
|
${downloadHtml} |
|
</div>`; |
|
|
|
|
|
if (status.image_urls && status.image_urls.length > 0) { |
|
previewSection.style.display = 'block'; |
|
status.image_urls.forEach(url => { |
|
if (!document.querySelector(`img[src="${url}"]`)) { |
|
const img = document.createElement('img'); |
|
img.src = url; |
|
img.alt = `Page générée pour la tâche ${currentTaskId}`; |
|
img.style.opacity = 0; |
|
img.onload = () => { |
|
img.style.transition = 'opacity 0.5s ease-in-out'; |
|
img.style.opacity = 1; |
|
}; |
|
imageGrid.appendChild(img); |
|
} |
|
}); |
|
} |
|
} |
|
|
|
|
|
function resetUI() { |
|
imageGrid.innerHTML = ''; |
|
previewSection.style.display = 'none'; |
|
if(statusContainer.querySelector('.alert')) { |
|
statusContainer.querySelector('.alert').remove(); |
|
} |
|
} |
|
|
|
function setButtonState(isLoading, text = 'Générer le Manga') { |
|
generateBtn.disabled = isLoading; |
|
btnText.textContent = text; |
|
btnIcon.innerHTML = isLoading ? '<span class="spinner"></span>' : '🚀'; |
|
} |
|
|
|
function getStatusInfo(status) { |
|
const map = { |
|
'queued': { icon: '⏳', text: 'En file d\'attente', class: 'generating' }, |
|
'generating': { icon: '🎨', text: 'Génération en cours...', class: 'generating' }, |
|
'creating_zip': { icon: '🗜️', text: 'Création de l\'archive ZIP...', class: 'generating' }, |
|
'completed': { icon: '✅', text: 'Terminé !', class: 'completed' }, |
|
'error': { icon: '❌', text: 'Erreur', class: 'error' } |
|
}; |
|
return map[status] || { icon: '❔', text: 'Inconnu', class: '' }; |
|
} |
|
|
|
function getProgressHtml(status) { |
|
if (!status.total_pages || status.status === 'completed') return ''; |
|
const progress = (status.current_page / status.total_pages) * 100; |
|
return ` |
|
<p style="text-align: center; margin-top: 10px;"> |
|
Page ${status.current_page || 0} sur ${status.total_pages} |
|
</p> |
|
<div class="progress-bar"> |
|
<div class="progress-fill" style="width: ${progress}%"></div> |
|
</div>`; |
|
} |
|
|
|
function getDownloadButtonHtml() { |
|
return `<a href="/download/${currentTaskId}" class="btn download-btn">📥 Télécharger l'archive ZIP</a>`; |
|
} |
|
|
|
function showAlert(message, type) { |
|
const alertClass = type === 'error' ? 'alert-error' : 'alert-success'; |
|
const alertHtml = `<div class="alert ${alertClass}">${message}</div>`; |
|
statusContainer.innerHTML = alertHtml; |
|
} |
|
|
|
function autoFormatJSON() { |
|
try { |
|
const parsed = JSON.parse(jsonInput.value); |
|
jsonInput.value = JSON.stringify(parsed, null, 2); |
|
} catch (e) { |
|
|
|
} |
|
} |
|
</script> |
|
</body> |
|
</html> |