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