| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Generated Comic - Interactive Editor</title> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script> |
| <style> |
| body { margin: 0; padding: 20px; background: #f0f0f0; font-family: Arial, sans-serif; } |
| .comic-container { max-width: 1200px; margin: 0 auto; } |
| .comic-page { |
| background: white; width: 600px; height: 400px; |
| box-shadow: 0 0 10px rgba(0,0,0,0.1); box-sizing: content-box; |
| position: relative; overflow: hidden; border: 1px solid #333; |
| padding: 10px; |
| } |
| .comic-grid { |
| display: grid; |
| grid-template-columns: 285px 285px; |
| grid-template-rows: 185px 185px; |
| gap: 10px; |
| width: 100%; height: 100%; |
| } |
| .page-wrapper { margin: 30px auto; width: 622px; display: flex; flex-direction: column; align-items: center; } |
| .page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; } |
| .panel { |
| position: relative; overflow: hidden; width: 100%; height: 100%; |
| box-sizing: border-box; cursor: pointer; border: 1px solid #333; |
| } |
| .panel.selected { outline: 3px solid #2196F3; outline-offset: -3px; } |
| .panel img { width: 100%; height: 100%; object-fit: cover; object-position: center; } |
| .speech-bubble { |
| position: absolute; display: flex; justify-content: center; align-items: center; |
| width: auto; height: auto; |
| min-width: 50px; max-width: 220px; min-height: 30px; |
| box-sizing: border-box; padding: 8px; |
| box-shadow: 2px 2px 5px rgba(0,0,0,0.3); z-index: 10; |
| cursor: move; overflow: visible; font-size: 13px; font-weight: bold; text-align: center; |
| } |
| .bubble-text { padding: 2px; word-wrap: break-word; } |
| .speech-bubble.selected { outline: 2px dashed #4CAF50; } |
| .speech-bubble textarea { |
| position: absolute; top: 0; left: 0; width: 100%; height: 100%; box-sizing: border-box; |
| border: 1px solid #4CAF50; background: rgba(255,255,255,0.95); |
| font: inherit; text-align: center; resize: none; padding: 8px; z-index: 102; |
| } |
| |
| .speech-bubble.speech { background: white; border: 2px solid #333; color: #333; border-radius: 15px; } |
| .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; } |
| .speech-bubble.reaction { background: #FFD700; border: 3px solid #E53935; color: #D32F2F; font-weight: 900; text-transform: uppercase; width: 180px; clip-path: polygon(0% 25%, 17% 21%, 17% 0%, 31% 16%, 50% 4%, 69% 16%, 83% 0%, 83% 21%, 100% 25%, 85% 45%, 95% 62%, 82% 79%, 100% 97%, 79% 89%, 60% 98%, 46% 82%, 27% 95%, 15% 78%, 5% 62%, 15% 45%); } |
| .speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; } |
| .speech-bubble.idea { background: linear-gradient(180deg,#FFFDD0 0%, #FFF8B5 100%); border: 2px solid #FFA500; color: #6a4b00; border-radius: 40% 60% 40% 60% / 60% 40% 60% 40%; } |
| |
| .speech-bubble.speech::after, .speech-bubble.idea::after { content: ''; position: absolute; width: 0; height: 0; border-left: 10px solid transparent; border-right: 10px solid transparent; } |
| .speech-bubble.speech::after { border-top: 10px solid #333; bottom: -9px; left: 20px; } |
| .speech-bubble.idea::after { border-top: 10px solid #FFA500; bottom: -9px; left: 20px; } |
| .speech-bubble.thought::after { display: none; } |
| .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; } |
| .thought-dot-1 { width: 20px; height: 20px; bottom: -20px; left: 15px; } |
| .thought-dot-2 { width: 12px; height: 12px; bottom: -32px; left: 5px; } |
| |
| .speech-bubble.flipped.speech::after, .speech-bubble.flipped.idea::after { left: auto; right: 20px; } |
| .speech-bubble.flipped.thought .thought-dot-1 { left: auto; right: 15px; } |
| .speech-bubble.flipped.thought .thought-dot-2 { left: auto; right: 5px; } |
| |
| .speech-bubble.flipped-vertical.speech::after, .speech-bubble.flipped-vertical.idea::after { bottom: auto; top: -9px; transform: rotate(180deg); } |
| .speech-bubble.flipped-vertical.thought .thought-dot-1 { bottom: auto; top: -20px; } |
| .speech-bubble.flipped-vertical.thought .thought-dot-2 { bottom: auto; top: -32px; } |
| .edit-controls { |
| position: fixed; bottom: 20px; right: 20px; background: rgba(44, 62, 80, 0.9); |
| color: white; padding: 10px 15px; border-radius: 8px; font-size: 13px; |
| z-index: 1000; box-shadow: 0 4px 12px rgba(0,0,0,0.3); width: 220px; |
| } |
| .edit-controls h4 { margin: 0 0 10px 0; color: #26a69a; text-align: center; } |
| .edit-controls button, .edit-controls select { margin-top: 5px; padding: 6px 8px; font-size: 12px; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; width: 100%; box-sizing: border-box; } |
| .edit-controls .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; } |
| .edit-controls .reset-button { background-color: #e74c3c; } |
| .edit-controls .action-button { background-color: #4CAF50; } |
| .edit-controls .secondary-button { background-color: #f39c12; } |
| </style> |
| </head> |
| <body> |
| <div class="comic-container"> |
| <h1 class="comic-title">🎬 Generated Comic</h1> |
| <div id="comic-pages"><div class="loading">Loading comic...</div></div> |
| </div> |
| <input type="file" id="image-uploader" style="display: none;" accept="image/*"> |
| |
| <div class="edit-controls"> |
| <h4>✏️ Interactive Editor</h4> |
| <div class="control-group"> |
| <label for="bubble-type-select">Change Selected Bubble Type:</label> |
| <select id="bubble-type-select" onchange="changeBubbleType(this.value)"> |
| <option value="speech">Speech</option> |
| <option value="thought">Thought</option> |
| <option value="reaction">Reaction</option> |
| <option value="narration">Narration</option> |
| <option value="idea">Idea</option> |
| </select> |
| <button onclick="rotateBubbleTail()" class="secondary-button">🔄 Rotate Tail</button> |
| </div> |
| <div class="control-group"> |
| <button onclick="replacePanelImage()" class="action-button">🖼️ Replace Panel Image</button> |
| <button onclick="regenerateFrame()" class="action-button">🔄 Regenerate Frame</button> |
| <button onclick="exportPagesToPNG()" class="action-button" style="background-color: #2196F3;">🖨️ Export Pages to PNG</button> |
| </div> |
| <div class="control-group"> |
| <button onclick="clearSavedState()" class="reset-button">🔄 Clear Edits & Reset</button> |
| </div> |
| </div> |
| <script> |
| document.addEventListener('DOMContentLoaded', () => { |
| fetch('/output/pages.json') |
| .then(res => res.ok ? res.json() : Promise.reject(new Error('Failed to load pages.json'))) |
| .then(data => { renderComic(data); initializeEditor(); }) |
| .catch(err => { document.getElementById('comic-pages').innerHTML = `<div class="loading">Error: ${err.message}</div>`; }); |
| }); |
| |
| function renderComic(data) { |
| const container = document.getElementById('comic-pages'); |
| container.innerHTML = ''; |
| if (!data || data.length === 0) return; |
| data.forEach((pageData, pageIndex) => { |
| if (!pageData.panels || pageData.panels.length === 0) return; |
| const pageWrapper = document.createElement('div'); |
| pageWrapper.className = 'page-wrapper'; |
| const pageTitleEl = document.createElement('h2'); |
| pageTitleEl.className = 'page-title'; |
| pageTitleEl.textContent = `Page ${pageIndex + 1}`; |
| pageWrapper.appendChild(pageTitleEl); |
| const pageDiv = document.createElement('div'); |
| pageDiv.className = 'comic-page'; |
| const grid = document.createElement('div'); |
| grid.className = 'comic-grid'; |
| pageData.panels.forEach((panelData, panelIndex) => { |
| const panelDiv = document.createElement('div'); |
| panelDiv.className = 'panel'; |
| const img = document.createElement('img'); |
| img.src = '/frames/final/' + panelData.image; |
| panelDiv.appendChild(img); |
| if (pageData.bubbles && pageData.bubbles[panelIndex]) { |
| const bubbleData = pageData.bubbles[panelIndex]; |
| const bubbleDiv = createBubbleElement({ |
| id: `initial-${pageIndex}-${panelIndex}`, |
| text: bubbleData.dialog || '', |
| left: `${bubbleData.bubble_offset_x ?? 50}px`, |
| top: `${bubbleData.bubble_offset_y ?? 20}px`, |
| }); |
| panelDiv.appendChild(bubbleDiv); |
| } |
| grid.appendChild(panelDiv); |
| }); |
| pageDiv.appendChild(grid); |
| pageWrapper.appendChild(pageDiv); |
| container.appendChild(pageWrapper); |
| }); |
| } |
| |
| let currentlyEditing = null, draggedBubble = null, offset = {x: 0, y: 0}; |
| let currentlySelectedBubble = null; |
| let currentlySelectedPanel = null; |
| |
| function initializeEditor() { |
| document.querySelectorAll('.panel').forEach(p => p.addEventListener('click', e => selectPanel(e.currentTarget))); |
| document.querySelectorAll('.speech-bubble').forEach(b => initializeBubbleEvents(b)); |
| document.addEventListener('mousemove', e => { if (draggedBubble) drag(e); }); |
| document.addEventListener('mouseup', () => { if (draggedBubble) stopDrag(); }); |
| } |
| |
| function initializeBubbleEvents(bubble) { |
| bubble.addEventListener('dblclick', e => { e.stopPropagation(); editBubbleText(bubble); }); |
| bubble.addEventListener('mousedown', e => startDrag(e)); |
| bubble.addEventListener('click', e => { e.stopPropagation(); selectBubble(bubble); }); |
| bubble.addEventListener('wheel', e => { |
| e.preventDefault(); |
| const currentWidth = parseFloat(bubble.style.width) || bubble.offsetWidth; |
| const newWidth = currentWidth - (e.deltaY > 0 ? 10 : -10); |
| if (newWidth >= 60) { |
| bubble.style.width = `${newWidth}px`; |
| bubble.style.height = 'auto'; |
| } |
| }, { passive: false }); |
| } |
| |
| function createBubbleElement(data) { |
| const bubbleDiv = document.createElement('div'); |
| bubbleDiv.dataset.id = data.id; |
| const textSpan = document.createElement('span'); |
| textSpan.className = 'bubble-text'; |
| textSpan.textContent = data.text; |
| bubbleDiv.appendChild(textSpan); |
| bubbleDiv.style.left = data.left; |
| bubbleDiv.style.top = data.top; |
| applyBubbleType(bubbleDiv, 'speech'); |
| return bubbleDiv; |
| } |
| |
| function applyBubbleType(bubble, type) { |
| bubble.querySelectorAll('.thought-dot').forEach(el => el.remove()); |
| let classesToKeep = 'speech-bubble'; |
| if (bubble.classList.contains('selected')) classesToKeep += ' selected'; |
| if (bubble.classList.contains('flipped')) classesToKeep += ' flipped'; |
| if (bubble.classList.contains('flipped-vertical')) classesToKeep += ' flipped-vertical'; |
| bubble.className = classesToKeep; |
| bubble.classList.add(type); |
| bubble.dataset.type = type; |
| if (type === 'thought') { |
| for (let i = 1; i <= 2; i++) { |
| const dot = document.createElement('div'); |
| dot.className = `thought-dot thought-dot-${i}`; |
| bubble.appendChild(dot); |
| } |
| } |
| } |
| |
| function changeBubbleType(type) { |
| if (!currentlySelectedBubble) return; |
| applyBubbleType(currentlySelectedBubble, type); |
| } |
| |
| function rotateBubbleTail() { |
| if (!currentlySelectedBubble) return alert("Please select a bubble to rotate."); |
| const isFlippedH = currentlySelectedBubble.classList.contains('flipped'); |
| const isFlippedV = currentlySelectedBubble.classList.contains('flipped-vertical'); |
| if (!isFlippedH && !isFlippedV) { |
| currentlySelectedBubble.classList.add('flipped'); |
| } else if (isFlippedH && !isFlippedV) { |
| currentlySelectedBubble.classList.add('flipped-vertical'); |
| } else if (isFlippedH && isFlippedV) { |
| currentlySelectedBubble.classList.remove('flipped'); |
| } else { |
| currentlySelectedBubble.classList.remove('flipped-vertical'); |
| } |
| } |
| |
| function selectPanel(panel) { |
| document.querySelectorAll('.panel.selected').forEach(p => p.classList.remove('selected')); |
| panel.classList.add('selected'); |
| currentlySelectedPanel = panel; |
| selectBubble(null); |
| } |
| |
| function selectBubble(bubble) { |
| if (currentlySelectedBubble) currentlySelectedBubble.classList.remove('selected'); |
| currentlySelectedBubble = bubble; |
| if (currentlySelectedBubble) { |
| currentlySelectedBubble.classList.add('selected'); |
| document.querySelectorAll('.panel.selected').forEach(p => p.classList.remove('selected')); |
| document.getElementById('bubble-type-select').value = currentlySelectedBubble.dataset.type || 'speech'; |
| } |
| } |
| |
| function editBubbleText(bubble) { |
| if (currentlyEditing) return; |
| currentlyEditing = bubble; |
| const textSpan = bubble.querySelector('.bubble-text'); |
| const currentText = textSpan.textContent; |
| textSpan.style.display = 'none'; |
| bubble.style.height = 'auto'; |
| const textarea = document.createElement('textarea'); |
| textarea.value = currentText; |
| bubble.appendChild(textarea); |
| textarea.focus(); |
| const finishEditing = () => { |
| textSpan.textContent = textarea.value; |
| bubble.removeChild(textarea); |
| textSpan.style.display = ''; |
| currentlyEditing = null; |
| bubble.style.height = 'auto'; |
| }; |
| textarea.addEventListener('blur', finishEditing, { once: true }); |
| textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); }}); |
| } |
| |
| function startDrag(e) { |
| const bubble = e.target.closest('.speech-bubble'); |
| if (!bubble || currentlyEditing) return; |
| draggedBubble = bubble; |
| selectBubble(bubble); |
| const rect = bubble.getBoundingClientRect(); |
| offset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; |
| } |
| |
| function drag(e) { |
| const parentRect = draggedBubble.parentElement.getBoundingClientRect(); |
| let x = e.clientX - parentRect.left - offset.x; |
| let y = e.clientY - parentRect.top - offset.y; |
| draggedBubble.style.left = `${x}px`; |
| draggedBubble.style.top = `${y}px`; |
| } |
| |
| function stopDrag() { |
| draggedBubble = null; |
| } |
| |
| function clearSavedState() { |
| if (confirm("Reset all edits to the original AI-generated comic?")) { |
| localStorage.removeItem('comicEditorState'); |
| window.location.reload(); |
| } |
| } |
| |
| async function exportPagesToPNG() { |
| const pages = document.querySelectorAll('.comic-page'); |
| if (pages.length === 0) return alert("No pages found."); |
| alert(`Starting export of ${pages.length} page(s).`); |
| for (let i = 0; i < pages.length; i++) { |
| try { |
| const canvas = await html2canvas(pages[i], { scale: 2 }); |
| const link = document.createElement('a'); |
| link.download = `comic-page-${i + 1}.png`; |
| link.href = canvas.toDataURL('image/png'); |
| link.click(); |
| } catch (err) { |
| alert(`Failed to export page ${i + 1}.`); |
| } |
| } |
| } |
| |
| function replacePanelImage() { |
| if (!currentlySelectedPanel) { |
| alert("Please select a panel first."); |
| return; |
| } |
| const img = currentlySelectedPanel.querySelector('img'); |
| const uploader = document.getElementById('image-uploader'); |
| const oneTimeListener = (event) => { |
| const file = event.target.files[0]; |
| if (!file) return; |
| const formData = new FormData(); |
| formData.append('image', file); |
| img.style.opacity = '0.5'; |
| fetch('/replace_panel', { method: 'POST', body: formData }) |
| .then(response => response.json()) |
| .then(data => { |
| if (data.success) { |
| img.src = `/frames/final/${data.new_filename}?t=${new Date().getTime()}`; |
| } else { |
| alert('Error replacing image: ' + data.error); |
| } |
| img.style.opacity = '1'; |
| }) |
| .catch(error => { |
| alert('An error occurred during the upload.'); |
| img.style.opacity = '1'; |
| }); |
| uploader.removeEventListener('change', oneTimeListener); |
| uploader.value = ''; |
| }; |
| uploader.addEventListener('change', oneTimeListener, { once: true }); |
| uploader.click(); |
| } |
| |
| function regenerateFrame() { |
| if (!currentlySelectedPanel) { |
| alert("Please select a panel first."); |
| return; |
| } |
| const img = currentlySelectedPanel.querySelector('img'); |
| const currentSrc = img.src; |
| |
| let filename = currentSrc.substring(currentSrc.lastIndexOf('/') + 1); |
| if (filename.includes('?')) { |
| filename = filename.split('?')[0]; |
| } |
| |
| if (!confirm(`Regenerate frame "${filename}" with a better version?`)) { |
| return; |
| } |
| img.style.opacity = '0.5'; |
| fetch('/regenerate_frame', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ filename: filename }) |
| }) |
| .then(response => response.json()) |
| .then(data => { |
| if (data.success) { |
| img.src = `/frames/final/${filename}?t=${new Date().getTime()}`; |
| alert(data.message); |
| } else { |
| alert('Error: ' + data.message); |
| } |
| img.style.opacity = '1'; |
| }) |
| .catch(error => { |
| alert('An error occurred during regeneration.'); |
| img.style.opacity = '1'; |
| }); |
| } |
| </script> |
| </body> |
| </html> |