diff --git "a/app_enhanced.py" "b/app_enhanced.py" --- "a/app_enhanced.py" +++ "b/app_enhanced.py" @@ -2,18 +2,13 @@ import os import webbrowser import time import threading -from flask import Flask, render_template, request, jsonify, send_from_directory, send_file -from pathlib import Path -import cv2 -import numpy as np -from PIL import Image -import srt -import json +import uuid import shutil -from typing import List +import json import traceback +from typing import List from concurrent.futures import ThreadPoolExecutor - +from flask import Flask, render_template, request, jsonify, send_from_directory, send_file, session # --- ROBUST IMPORTS WITH FALLBACKS --- try: @@ -42,7 +37,6 @@ except Exception as e: def batch_enhance(self, *args, **kwargs): print("-> Skipping quality color enhancement (module not loaded).") def enhance_single(self, *args, **kwargs): print("-> Skipping quality color enhancement (module not loaded).") - try: from backend.class_def import bubble, panel, Page print("โœ… Core class definitions (bubble, panel, Page) loaded.") @@ -64,8 +58,12 @@ try: except Exception as e: print(f"โš ๏ธ Could not load a core utility module: {e}") +# --- FLASK APP SETUP --- app = Flask(__name__) +app.secret_key = "REPLACE_THIS_WITH_A_SECRET_KEY_IN_PRODUCTION" +BASE_USER_DIR = "userdata" +# --- MERGED HTML: UPLOAD UI + EDITOR UI --- INDEX_HTML = ''' @@ -73,18 +71,31 @@ INDEX_HTML = ''' Movie to Comic Generator + + + + + -
-
+ +
+

๐ŸŽฌ Movie to Comic Generator

@@ -187,14 +241,76 @@ INDEX_HTML = '''
-
+

Starting...

-

โœ… Generation Complete! Opening your comic...

+ + +
+
+

๐ŸŽฌ Generated Comic

+
Loading comic...
+
+ + + +
+

โœ๏ธ Interactive Editor

+
+ + +
+
+
+
+ + +
+ +
+ + +
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+ +
+
+
+ - - -''' -os.makedirs('video', exist_ok=True) -os.makedirs('frames/final', exist_ok=True) -os.makedirs('output', exist_ok=True) + // --- EDITOR LOGIC --- + function loadComicData() { + fetch('output/pages.json') + .then(res => res.ok ? res.json() : Promise.reject(new Error('Failed'))) + .then(data => { renderComic(data); initializeEditor(); }) + .catch(err => { document.getElementById('comic-pages').innerHTML = `
Error: ${err.message}
`; }); + } -def update_status(message, progress): - status_file = os.path.join('output', 'status.json') - with open(status_file, 'w') as f: - json.dump({'message': message, 'progress': progress}, f) + function renderComic(data) { + const container = document.getElementById('comic-pages'); + container.innerHTML = ''; + data.forEach((pageData, pageIndex) => { + const pageWrapper = document.createElement('div'); + pageWrapper.className = 'page-wrapper'; + const pageTitleEl = document.createElement('h2'); + 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/' + panelData.image; + panelDiv.appendChild(img); + if (pageData.bubbles && pageData.bubbles[panelIndex] && pageData.bubbles[panelIndex].dialog) { + const bubbleDiv = createBubbleElement({ + id: `initial-${pageIndex}-${panelIndex}`, + text: pageData.bubbles[panelIndex].dialog || '', + left: `${pageData.bubbles[panelIndex].bubble_offset_x ?? 50}px`, + top: `${pageData.bubbles[panelIndex].bubble_offset_y ?? 20}px`, + }); + panelDiv.appendChild(bubbleDiv); + } + grid.appendChild(panelDiv); + }); + pageDiv.appendChild(grid); + pageWrapper.appendChild(pageDiv); + container.appendChild(pageWrapper); + }); + } -class EnhancedComicGenerator: - def __init__(self): - self.video_path = 'video/uploaded.mp4' - self.frames_dir = 'frames/final' - self.output_dir = 'output' - self.apply_comic_style = False - self.video_fps = None + 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'); + initializeBubbleEvents(bubbleDiv); + return bubbleDiv; + } - def cleanup_generated(self): - print("๐Ÿงน Performing full cleanup...") - if os.path.isdir(self.frames_dir): shutil.rmtree(self.frames_dir) - if os.path.isdir(self.output_dir): shutil.rmtree(self.output_dir) - if os.path.isdir('temp'): shutil.rmtree('temp') - if os.path.exists('test1.srt'): os.remove('test1.srt') - os.makedirs(self.frames_dir, exist_ok=True) - os.makedirs(self.output_dir, exist_ok=True) - print("โœ… Cleanup complete.") + function applyBubbleType(bubble, type) { + bubble.className = 'speech-bubble ' + type; + if(type === 'speech') bubble.classList.add('tail-bottom'); + if(type === 'thought') { + bubble.querySelectorAll('.thought-dot').forEach(e=>e.remove()); + for(let i=1; i<=2; i++) { + const d = document.createElement('div'); + d.className = `thought-dot thought-dot-${i}`; + bubble.appendChild(d); + } + } + bubble.dataset.type = type; + } - def regenerate_frame(self, frame_filename, direction): - try: - if not self.video_fps: - return {"success": False, "message": "Video FPS not found."} - metadata_path = 'frames/frame_metadata.json' - if not os.path.exists(metadata_path): - return {"success": False, "message": "Frame metadata missing."} - with open(metadata_path, 'r') as f: - frame_to_time = json.load(f) - if frame_filename not in frame_to_time: - return {"success": False, "message": "Panel not linked to video."} - current_time = frame_to_time[frame_filename]['time'] if isinstance(frame_to_time[frame_filename], dict) else frame_to_time[frame_filename] - frame_duration = 1.0 / self.video_fps - target_time = current_time + frame_duration if direction == 'forward' else current_time - frame_duration - target_time = max(0, target_time) - cap = cv2.VideoCapture(self.video_path) - if not cap.isOpened(): return {"success": False, "message": "Cannot open video."} - cap.set(cv2.CAP_PROP_POS_MSEC, target_time * 1000) - ret, frame = cap.read() - cap.release() - if not ret or frame is None: - return {"success": False, "message": f"No frame at {target_time:.2f}s."} - - new_path = os.path.join(self.frames_dir, frame_filename) - cv2.imwrite(new_path, frame) + function initializeEditor() { + document.querySelectorAll('.panel').forEach(panel => { + panel.addEventListener('click', () => selectPanel(panel)); + panel.querySelector('img')?.addEventListener('mousedown', startPan); + }); + document.querySelectorAll('.speech-bubble').forEach(initializeBubbleEvents); + document.getElementById('zoom-slider').addEventListener('input', handleZoom); + document.getElementById('tail-slider').addEventListener('input', (e) => slideTail(e.target.value)); - print(f"๐ŸŽจ Applying enhancements to the new frame: {frame_filename}") - self._enhance_all_images(single_image_path=new_path) - self._enhance_quality_colors(single_image_path=new_path) + // Color pickers + document.getElementById('bubble-text-color').addEventListener('input', (e) => { + if(currentlySelectedBubble) currentlySelectedBubble.style.setProperty('--bubble-text-color', e.target.value); + }); + document.getElementById('bubble-fill-color').addEventListener('input', (e) => { + if(currentlySelectedBubble) currentlySelectedBubble.style.setProperty('--bubble-fill-color', e.target.value); + }); - if isinstance(frame_to_time[frame_filename], dict): - frame_to_time[frame_filename]['time'] = target_time - else: - frame_to_time[frame_filename] = target_time - with open(metadata_path, 'w') as f: json.dump(frame_to_time, f, indent=2) - message = f"Adjusted {direction} to {target_time:.3f}s" - print(f"โœ… {message}") - return {"success": True, "message": message, "new_filename": frame_filename} - except Exception as e: - traceback.print_exc() - return {"success": False, "message": str(e)} + document.addEventListener('mousemove', e => { if (isPanning) panImage(e); if (draggedBubble) drag(e); if(isResizing) resizeBubble(e); }); + document.addEventListener('mouseup', e => { if (isPanning) stopPan(e); if (draggedBubble) stopDrag(e); if(isResizing) stopResize(e);}); + document.addEventListener('mouseleave', e => { if (isPanning) stopPan(e); if (draggedBubble) stopDrag(e); if(isResizing) stopResize(e);}); + } - def get_frame_at_timestamp(self, frame_filename, timestamp_seconds): - try: - metadata_path = 'frames/frame_metadata.json' - if not os.path.exists(metadata_path): return {"success": False, "message": "Frame metadata missing."} - cap = cv2.VideoCapture(self.video_path) - if not cap.isOpened(): return {"success": False, "message": "Cannot open video."} - fps = cap.get(cv2.CAP_PROP_FPS) - if fps == 0: fps = 25 - duration = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) / fps - if timestamp_seconds < 0 or timestamp_seconds > duration: - cap.release() - return {"success": False, "message": f"Timestamp must be between 0 and {duration:.2f}s."} - cap.set(cv2.CAP_PROP_POS_MSEC, timestamp_seconds * 1000) - ret, frame = cap.read() - cap.release() - if not ret or frame is None: return {"success": False, "message": f"Could not retrieve frame at {timestamp_seconds:.2f}s."} - - new_path = os.path.join(self.frames_dir, frame_filename) - cv2.imwrite(new_path, frame) + function initializeBubbleEvents(bubble) { + bubble.addEventListener('mousedown', (e) => { e.stopPropagation(); startDrag(e); }); + bubble.addEventListener('click', (e) => { e.stopPropagation(); selectBubble(bubble); }); + bubble.addEventListener('dblclick', (e) => { e.stopPropagation(); editBubbleText(bubble); }); + ['nw', 'ne', 'sw', 'se'].forEach(dir => { + const h = document.createElement('div'); h.className = `resize-handle ${dir}`; + bubble.appendChild(h); + h.addEventListener('mousedown', (e) => startResize(e, dir)); + }); + } - print(f"๐ŸŽจ Applying enhancements to the new frame from timestamp: {frame_filename}") - self._enhance_all_images(single_image_path=new_path) - self._enhance_quality_colors(single_image_path=new_path) + function selectPanel(panel) { + document.querySelectorAll('.panel.selected').forEach(p => p.classList.remove('selected')); + panel.classList.add('selected'); + currentlySelectedPanel = panel; + selectBubble(null); + resetPanelTransform(); // Optional reset or load state + } - with open(metadata_path, 'r') as f: frame_to_time = json.load(f) - if frame_filename in frame_to_time: - if isinstance(frame_to_time[frame_filename], dict): - frame_to_time[frame_filename]['time'] = timestamp_seconds - else: - frame_to_time[frame_filename] = timestamp_seconds - with open(metadata_path, 'w') as f: json.dump(frame_to_time, f, indent=2) - message = f"Jumped to timestamp {timestamp_seconds:.3f}s" - print(f"โœ… {message}") - return { "success": True, "message": message } - except Exception as e: + function selectBubble(bubble) { + if(currentlySelectedBubble) currentlySelectedBubble.classList.remove('selected'); + currentlySelectedBubble = bubble; + const controls = ['bubble-text-color', 'bubble-fill-color', 'bubble-type-select', 'zoom-slider']; + const tailControls = document.getElementById('tail-controls'); + + if(bubble) { + bubble.classList.add('selected'); + document.getElementById('bubble-text-color').disabled = false; + document.getElementById('bubble-fill-color').disabled = false; + document.getElementById('bubble-type-select').disabled = false; + document.getElementById('zoom-slider').disabled = true; + + if(bubble.dataset.type === 'speech') tailControls.style.display = 'block'; + else tailControls.style.display = 'none'; + } else { + controls.forEach(id => { if(document.getElementById(id)) document.getElementById(id).disabled = true; }); + document.getElementById('zoom-slider').disabled = false; + tailControls.style.display = 'none'; + } + } + + // --- Drag/Resize/Tail Logic (Same as before) --- + function startDrag(e) { + draggedBubble = e.target.closest('.speech-bubble'); + selectBubble(draggedBubble); + const rect = draggedBubble.getBoundingClientRect(); + offset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + function drag(e) { + if(!draggedBubble) return; + e.preventDefault(); + const parent = draggedBubble.parentElement.getBoundingClientRect(); + draggedBubble.style.left = (e.clientX - parent.left - offset.x) + 'px'; + draggedBubble.style.top = (e.clientY - parent.top - offset.y) + 'px'; + } + function stopDrag() { draggedBubble = null; } + + function startResize(e, dir) { + e.preventDefault(); e.stopPropagation(); + isResizing = true; resizeHandle = dir; + const b = currentlySelectedBubble; + const r = b.getBoundingClientRect(); + originalWidth = r.width; originalHeight = r.height; + originalX = b.offsetLeft; originalY = b.offsetTop; + originalMouseX = e.clientX; originalMouseY = e.clientY; + } + function resizeBubble(e) { + if(!isResizing || !currentlySelectedBubble) return; + const dx = e.clientX - originalMouseX; + const dy = e.clientY - originalMouseY; + const b = currentlySelectedBubble; + if(resizeHandle.includes('e')) b.style.width = (originalWidth + dx) + 'px'; + if(resizeHandle.includes('w')) { b.style.width = (originalWidth - dx) + 'px'; b.style.left = (originalX + dx) + 'px'; } + if(resizeHandle.includes('s')) b.style.height = (originalHeight + dy) + 'px'; + if(resizeHandle.includes('n')) { b.style.height = (originalHeight - dy) + 'px'; b.style.top = (originalY + dy) + 'px'; } + } + function stopResize() { isResizing = false; } + + function slideTail(val) { + if(currentlySelectedBubble) currentlySelectedBubble.style.setProperty('--tail-pos', val + '%'); + } + function rotateBubbleTail() { + if(!currentlySelectedBubble) return; + const b = currentlySelectedBubble; + if(b.classList.contains('tail-bottom')) b.classList.replace('tail-bottom', 'tail-left'); + else if(b.classList.contains('tail-left')) b.classList.replace('tail-left', 'tail-top'); + else if(b.classList.contains('tail-top')) b.classList.replace('tail-top', 'tail-right'); + else b.className = b.className.replace(/tail-\w+/, 'tail-bottom'); + } + function changeBubbleType(val) { + if(!currentlySelectedBubble) return; + applyBubbleType(currentlySelectedBubble, val); + selectBubble(currentlySelectedBubble); + } + function editBubbleText(bubble) { + const span = bubble.querySelector('.bubble-text'); + const txt = document.createElement('textarea'); + txt.value = span.textContent; + bubble.appendChild(txt); + span.style.display = 'none'; + txt.focus(); + txt.onblur = () => { span.textContent = txt.value; txt.remove(); span.style.display = 'block'; }; + } + function addBubbleToPanel() { + if(!currentlySelectedPanel) return alert("Select panel first"); + const b = createBubbleElement({id: Date.now(), text: "New Text", left: "20px", top: "20px"}); + currentlySelectedPanel.appendChild(b); + selectBubble(b); + } + function deleteBubble() { + if(currentlySelectedBubble) { currentlySelectedBubble.remove(); selectBubble(null); } + } + + // --- Panel Image Manipulation --- + function startPan(e) { + if(e.button !== 0) return; + const img = e.target; + if((parseFloat(img.dataset.zoom)||100) <= 100) return; + e.preventDefault(); isPanning = true; + img.classList.add('panning'); + panStartX = e.clientX; panStartY = e.clientY; + panStartTranslateX = parseFloat(img.dataset.translateX || 0); + panStartTranslateY = parseFloat(img.dataset.translateY || 0); + } + function panImage(e) { + if(!isPanning || !currentlySelectedPanel) return; + const img = currentlySelectedPanel.querySelector('img'); + img.dataset.translateX = panStartTranslateX + (e.clientX - panStartX); + img.dataset.translateY = panStartTranslateY + (e.clientY - panStartY); + updateImageTransform(img); + } + function stopPan() { + if(!isPanning) return; + isPanning = false; + currentlySelectedPanel?.querySelector('img')?.classList.remove('panning'); + } + function handleZoom(e) { + if(!currentlySelectedPanel) return; + const img = currentlySelectedPanel.querySelector('img'); + img.dataset.zoom = e.target.value; + updateImageTransform(img); + } + function updateImageTransform(img) { + const z = (img.dataset.zoom || 100)/100; + const x = img.dataset.translateX || 0; + const y = img.dataset.translateY || 0; + img.style.transform = `translateX(${x}px) translateY(${y}px) scale(${z})`; + img.classList.toggle('pannable', z>1); + } + function resetPanelTransform() { + if(!currentlySelectedPanel) return; + const img = currentlySelectedPanel.querySelector('img'); + img.dataset.zoom=100; img.dataset.translateX=0; img.dataset.translateY=0; + document.getElementById('zoom-slider').value = 100; + updateImageTransform(img); + } + + // --- API Calls --- + function replacePanelImage() { + if (!currentlySelectedPanel) return alert("Select a panel first."); + const img = currentlySelectedPanel.querySelector('img'); + const uploader = document.getElementById('image-uploader'); + uploader.onchange = (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(res => res.json()) + .then(data => { + if (data.success) { + img.src = `frames/${data.new_filename}?t=${Date.now()}`; + resetPanelTransform(); + } else alert('Error: ' + data.error); + img.style.opacity = '1'; + }); + uploader.value = ''; + }; + uploader.click(); + } + + function adjustFrame(dir) { + if (!currentlySelectedPanel) return alert("Select a panel."); + const img = currentlySelectedPanel.querySelector('img'); + let filename = img.src.substring(img.src.lastIndexOf('/')+1).split('?')[0]; + fetch('/regenerate_frame', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ filename, direction: dir }) + }).then(r=>r.json()).then(d=>{ + if(d.success) img.src = `frames/${filename}?t=${Date.now()}`; + else alert(d.message); + }); + } + + function gotoTimestamp() { + if (!currentlySelectedPanel) return alert("Select a panel."); + const input = document.getElementById('timestamp-input'); + let val = input.value.trim(); + if(!val) return; + if(val.includes(':')) { + let parts = val.split(':'); + val = parseInt(parts[0])*60 + parseFloat(parts[1]); + } else val = parseFloat(val); + + const img = currentlySelectedPanel.querySelector('img'); + let filename = img.src.substring(img.src.lastIndexOf('/')+1).split('?')[0]; + fetch('/goto_timestamp', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ filename, timestamp: val }) + }).then(r=>r.json()).then(d=>{ + if(d.success) { img.src = `frames/${filename}?t=${Date.now()}`; input.value=''; resetPanelTransform(); } + else alert(d.message); + }); + } + + async function exportPagesToPNG() { + const pages = document.querySelectorAll('.comic-page'); + for(let i=0; i + + +''' + +class EnhancedComicGenerator: + def __init__(self, sid): + # --- PER-USER SESSION DIRECTORIES --- + self.sid = sid + self.user_dir = os.path.join(BASE_USER_DIR, sid) + self.video_path = os.path.join(self.user_dir, 'uploaded.mp4') + self.frames_dir = os.path.join(self.user_dir, 'frames') + self.output_dir = os.path.join(self.user_dir, 'output') + self.status_file = os.path.join(self.output_dir, 'status.json') + + # Create directories if they don't exist + os.makedirs(self.frames_dir, exist_ok=True) + os.makedirs(self.output_dir, exist_ok=True) + + self.video_fps = None + self.frame_metadata = {} + + def update_status(self, message, progress): + try: + with open(self.status_file, 'w') as f: + json.dump({'message': message, 'progress': progress}, f) + except Exception as e: + print(f"Error updating status: {e}") + + def cleanup_generated(self): + print(f"[{self.sid}] ๐Ÿงน Performing cleanup...") + if os.path.isdir(self.frames_dir): + for file in os.listdir(self.frames_dir): + os.remove(os.path.join(self.frames_dir, file)) + if os.path.isdir(self.output_dir): + for file in os.listdir(self.output_dir): + if file != 'status.json': # Keep status file briefly + try: + os.remove(os.path.join(self.output_dir, file)) + except: pass + # Clean temp srt for this user (could be better handled with tempfile) + srt_file = os.path.join(self.user_dir, 'subs.srt') + if os.path.exists(srt_file): os.remove(srt_file) + print(f"[{self.sid}] โœ… Cleanup complete.") + + def regenerate_frame(self, frame_filename, direction): + try: + if not self.video_fps: + # Re-open video to get FPS if lost (unlikely but safe) + cap = cv2.VideoCapture(self.video_path) + self.video_fps = cap.get(cv2.CAP_PROP_FPS) + cap.release() + + metadata_path = os.path.join(self.frames_dir, 'frame_metadata.json') + if not os.path.exists(metadata_path): + return {"success": False, "message": "Frame metadata missing."} + with open(metadata_path, 'r') as f: + frame_to_time = json.load(f) + + if frame_filename not in frame_to_time: + return {"success": False, "message": "Panel not linked to video."} + + current_data = frame_to_time[frame_filename] + current_time = current_data['time'] if isinstance(current_data, dict) else current_data + + frame_duration = 1.0 / self.video_fps + target_time = current_time + frame_duration if direction == 'forward' else current_time - frame_duration + target_time = max(0, target_time) + + cap = cv2.VideoCapture(self.video_path) + if not cap.isOpened(): return {"success": False, "message": "Cannot open video."} + cap.set(cv2.CAP_PROP_POS_MSEC, target_time * 1000) + ret, frame = cap.read() + cap.release() + + if not ret or frame is None: + return {"success": False, "message": f"No frame at {target_time:.2f}s."} + + new_path = os.path.join(self.frames_dir, frame_filename) + cv2.imwrite(new_path, frame) + + self._enhance_all_images(single_image_path=new_path) + self._enhance_quality_colors(single_image_path=new_path) + + # Update metadata + if isinstance(frame_to_time[frame_filename], dict): + frame_to_time[frame_filename]['time'] = target_time + else: + frame_to_time[frame_filename] = target_time + + with open(metadata_path, 'w') as f: json.dump(frame_to_time, f, indent=2) + + return {"success": True, "message": f"Adjusted to {target_time:.3f}s", "new_filename": frame_filename} + except Exception as e: traceback.print_exc() return {"success": False, "message": str(e)} + def get_frame_at_timestamp(self, frame_filename, timestamp_seconds): + try: + metadata_path = os.path.join(self.frames_dir, 'frame_metadata.json') + if not os.path.exists(metadata_path): return {"success": False, "message": "Metadata missing."} + + cap = cv2.VideoCapture(self.video_path) + if not cap.isOpened(): return {"success": False, "message": "Cannot open video."} + fps = cap.get(cv2.CAP_PROP_FPS) + if fps == 0: fps = 25 + duration = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) / fps + + if timestamp_seconds < 0 or timestamp_seconds > duration: + cap.release() + return {"success": False, "message": f"Timestamp invalid (0-{duration:.2f}s)."} + + cap.set(cv2.CAP_PROP_POS_MSEC, timestamp_seconds * 1000) + ret, frame = cap.read() + cap.release() + + if not ret or frame is None: return {"success": False, "message": "Could not retrieve frame."} + + new_path = os.path.join(self.frames_dir, frame_filename) + cv2.imwrite(new_path, frame) + + self._enhance_all_images(single_image_path=new_path) + self._enhance_quality_colors(single_image_path=new_path) + + with open(metadata_path, 'r') as f: frame_to_time = json.load(f) + if frame_filename in frame_to_time: + if isinstance(frame_to_time[frame_filename], dict): + frame_to_time[frame_filename]['time'] = timestamp_seconds + else: + frame_to_time[frame_filename] = timestamp_seconds + with open(metadata_path, 'w') as f: json.dump(frame_to_time, f, indent=2) + + return { "success": True, "message": f"Jumped to {timestamp_seconds:.3f}s" } + except Exception as e: + return {"success": False, "message": str(e)} + def generate_keyframes_from_moments(self, video_path, key_moments, max_frames=32): try: cap = cv2.VideoCapture(video_path) - if not cap.isOpened(): raise Exception("Cannot open video for keyframe extraction") + if not cap.isOpened(): raise Exception("Cannot open video") fps, total_frames = self.video_fps, int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) duration = total_frames / fps key_moments.sort(key=lambda x: x['start']) - if len(key_moments) > max_frames: pass # Simplified sampling + frame_metadata, frame_count = {}, 0 for i, moment in enumerate(key_moments): - update_status(f"Extracting frame {i+1}/{len(key_moments)}...", 25 + int(20 * (i / len(key_moments)))) + self.update_status(f"Extracting frame {i+1}/{len(key_moments)}...", 25 + int(20 * (i / len(key_moments)))) frame_time = (moment['start'] + moment['end']) / 2 if frame_time > duration: continue + frame_number = int(frame_time * fps) cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number) ret, frame = cap.read() @@ -388,52 +847,73 @@ class EnhancedComicGenerator: frame_metadata[frame_filename] = { 'time': frame_time, 'dialogue': moment['text'], 'start': moment['start'], 'end': moment['end'] } frame_count += 1 cap.release() - with open(os.path.join('frames', 'frame_metadata.json'), 'w') as f: + + with open(os.path.join(self.frames_dir, 'frame_metadata.json'), 'w') as f: json.dump(frame_metadata, f, indent=2) - print(f"โœ… Extracted {frame_count} keyframes from video") + print(f"[{self.sid}] โœ… Extracted {frame_count} keyframes") return True except Exception as e: - print(f"โŒ Error extracting keyframes: {e}") + print(f"[{self.sid}] โŒ Error extracting keyframes: {e}") return False def generate_comic(self): start_time = time.time() try: - update_status("Cleaning up...", 0) + self.update_status("Cleaning up...", 0) self.cleanup_generated() - update_status("Analyzing video...", 5) + + self.update_status("Analyzing video...", 5) cap = cv2.VideoCapture(self.video_path) if not cap.isOpened(): raise Exception("Cannot open video to get FPS.") self.video_fps = cap.get(cv2.CAP_PROP_FPS) if self.video_fps == 0: self.video_fps = 25 cap.release() - print(f"โœ… Video FPS detected: {self.video_fps:.2f}") - update_status("Generating subtitles (this may take a while)...", 10) - get_real_subtitles(self.video_path) - with open('test1.srt', 'r', encoding='utf-8') as f: + + self.update_status("Generating subtitles...", 10) + # IMPORTANT: Adapt get_real_subtitles to support output path if possible + # For now, assuming it generates 'test1.srt' in CWD, we move it + get_real_subtitles(self.video_path) + + # Move the generated SRT to user folder if it exists in root + if os.path.exists('test1.srt'): + user_srt = os.path.join(self.user_dir, 'subs.srt') + shutil.move('test1.srt', user_srt) + else: + user_srt = os.path.join(self.user_dir, 'subs.srt') # Or however your logic handles it + # If get_real_subtitles is hardcoded, this part needs care in your backend + + with open(user_srt, 'r', encoding='utf-8') as f: all_subs = list(srt.parse(f.read())) + key_moments = [{'index': s.index, 'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in all_subs] - if not self.generate_keyframes_from_moments(self.video_path, key_moments, max_frames=32): + + if not self.generate_keyframes_from_moments(self.video_path, key_moments): raise Exception("Keyframe extraction failed.") - update_status("Cropping black bars...", 45) - black_x, black_y, _, _ = black_bar_crop() - update_status("Enhancing images (in parallel)...", 50) + + self.update_status("Cropping black bars...", 45) + black_x, black_y, _, _ = black_bar_crop() # This might need adaptation to take an image path if it's global + + self.update_status("Enhancing images...", 50) self._enhance_all_images() self._enhance_quality_colors() - update_status("Placing speech bubbles (in parallel)...", 75) + + self.update_status("Placing speech bubbles...", 75) bubbles = self._create_ai_bubbles_from_moments(black_x, black_y) - update_status("Assembling comic pages...", 90) + + self.update_status("Assembling pages...", 90) pages = self._generate_pages(bubbles) - update_status("Saving final comic...", 95) + + self.update_status("Saving...", 95) self._save_results(pages) + execution_time = (time.time() - start_time) / 60 - print(f"โœ… Comic generation completed in {execution_time:.2f} minutes") - update_status("Complete!", 100) + print(f"[{self.sid}] โœ… Completed in {execution_time:.2f} min") + self.update_status("Complete!", 100) return True except Exception as e: - print(f"โŒ Comic generation failed: {e}") + print(f"[{self.sid}] โŒ Failed: {e}") traceback.print_exc() - update_status(f"Error: {e}", -1) + self.update_status(f"Error: {e}", -1) return False def _enhance_all_images(self, single_image_path=None): @@ -446,7 +926,7 @@ class EnhancedComicGenerator: with ThreadPoolExecutor() as executor: list(executor.map(enhancer.enhance_single, frame_paths)) except Exception as e: - print(f"โŒ Simple enhancement failed: {e}") + print(f"Simple enhancement error: {e}") def _enhance_quality_colors(self, single_image_path=None): try: @@ -458,7 +938,7 @@ class EnhancedComicGenerator: with ThreadPoolExecutor() as executor: list(executor.map(enhancer.enhance_single, frame_paths)) except Exception as e: - print(f"โš ๏ธ Quality enhancement failed: {e}") + print(f"Quality enhancement error: {e}") def _process_bubble_for_frame(self, frame_file): frame_path = os.path.join(self.frames_dir, frame_file) @@ -469,12 +949,12 @@ class EnhancedComicGenerator: bubble_x, bubble_y = ai_bubble_placer.place_bubble_ai(frame_path, (lip_x, lip_y)) return bubble(bubble_offset_x=bubble_x, bubble_offset_y=bubble_y, lip_x=lip_x, lip_y=lip_y, dialog=dialogue, emotion='normal') except Exception as e: - print(f"-> Could not place bubble for {frame_file}: {e}. Using default.") + # Fallback return bubble(bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, dialog=dialogue, emotion='normal') def _create_ai_bubbles_from_moments(self, black_x, black_y): frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')]) - metadata_path = 'frames/frame_metadata.json' + metadata_path = os.path.join(self.frames_dir, 'frame_metadata.json') if not os.path.exists(metadata_path): return [bubble(dialog="") for _ in frame_files] @@ -483,861 +963,116 @@ class EnhancedComicGenerator: with ThreadPoolExecutor() as executor: bubbles = list(executor.map(self._process_bubble_for_frame, frame_files)) - return bubbles def _generate_pages(self, bubbles): - try: - from backend.fixed_12_pages_800x1080 import generate_12_pages_800x1080 - return generate_12_pages_800x1080(sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')]), bubbles) - except ImportError: - pages, frame_files = [], sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')]) - num_pages = (len(frame_files) + 3) // 4 - for i in range(num_pages): - start, end = i*4, (i+1)*4 - page_panels = [panel(image=f) for f in frame_files[start:end]] - page_bubbles = bubbles[start:end] - if page_panels: pages.append(Page(panels=page_panels, bubbles=page_bubbles)) - return pages + # Using simple fallback generation logic for stability + pages, frame_files = [], sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')]) + num_pages = (len(frame_files) + 3) // 4 + for i in range(num_pages): + start, end = i*4, (i+1)*4 + page_panels = [panel(image=f) for f in frame_files[start:end]] + page_bubbles = bubbles[start:end] + if page_panels: pages.append(Page(panels=page_panels, bubbles=page_bubbles)) + return pages def _save_results(self, pages): try: - os.makedirs(self.output_dir, exist_ok=True) pages_data = [] for page in pages: panels = [p.__dict__ if hasattr(p, '__dict__') else p for p in page.panels] bubbles_data = [b.__dict__ if hasattr(b, '__dict__') else b for b in page.bubbles] pages_data.append({'panels': panels, 'bubbles': bubbles_data}) + with open(os.path.join(self.output_dir, 'pages.json'), 'w', encoding='utf-8') as f: json.dump(pages_data, f, indent=2) self._copy_template_files() - print("โœ… Results saved successfully!") except Exception as e: print(f"Save results failed: {e}") def _copy_template_files(self): try: - template_html = ''' - - - - Comic Editor - - - - - - - - -
-

๐ŸŽฌ Generated Comic

-
Loading comic...
-
- -
-

โœ๏ธ Interactive Editor

-
- - - -
-
- - -
-
- - -
-
- - -
- -
- - -
- - -
-
- - -
-
-
- -
- - -
-
-
- - -
-
- - -''' - # --- CORRECTED INDENTATION --- - with open(os.path.join(self.output_dir, 'page.html'), 'w', encoding='utf-8') as f: - f.write(template_html) - print("๐Ÿ“„ Template files copied successfully!") - except Exception as e: - print(f"Template copy failed: {e}") - -# --- Flask Routes --- -comic_generator = EnhancedComicGenerator() - -@app.route('/') -def index(): - return INDEX_HTML +@app.route('/') +def index(): + if 'sid' not in session: + session['sid'] = uuid.uuid4().hex + return INDEX_HTML @app.route('/uploader', methods=['POST']) def upload_file(): + if 'sid' not in session: return jsonify({'success': False, 'message': 'Session expired'}), 400 try: - if 'file' not in request.files or not request.files['file'].filename: - return jsonify({'success': False, 'message': 'No file selected'}), 400 f = request.files['file'] - if os.path.exists(comic_generator.video_path): os.remove(comic_generator.video_path) - f.save(comic_generator.video_path) - threading.Thread(target=comic_generator.generate_comic).start() - return jsonify({'success': True, 'message': 'Generation started.'}) + gen = EnhancedComicGenerator(session['sid']) + f.save(gen.video_path) + threading.Thread(target=gen.generate_comic).start() + return jsonify({'success': True}) except Exception as e: - traceback.print_exc() return jsonify({'success': False, 'message': str(e)}), 500 @app.route('/status') def status(): - status_file = os.path.join('output', 'status.json') + if 'sid' not in session: return jsonify({'message': 'Session expired', 'progress': -1}) + sid = session['sid'] + status_file = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json') if os.path.exists(status_file): - return send_from_directory('output', 'status.json') + with open(status_file, 'r') as f: return jsonify(json.load(f)) return jsonify({'message': 'Initializing...', 'progress': 0}) -@app.route('/handle_link', methods=['POST']) -def handle_link(): - # This route is disabled in the UI but remains functional - pass - -@app.route('/replace_panel', methods=['POST']) -def replace_panel(): - try: - if 'image' not in request.files: return jsonify({'success': False, 'error': 'No image provided.'}) - file = request.files['image'] - filename = f"replaced_panel_{int(time.time() * 1000)}.png" - file.save(os.path.join(comic_generator.frames_dir, filename)) - return jsonify({'success': True, 'new_filename': filename}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}) - -@app.route('/regenerate_frame', methods=['POST']) -def regenerate_frame_route(): - try: - data = request.get_json() - result = comic_generator.regenerate_frame(data['filename'], data['direction']) - return jsonify(result) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}) - -@app.route('/goto_timestamp', methods=['POST']) -def goto_timestamp_route(): - try: - data = request.get_json() - result = comic_generator.get_frame_at_timestamp(data['filename'], float(data['timestamp'])) - return jsonify(result) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}) - @app.route('/comic') def view_comic(): - return send_from_directory('output', 'page.html') + if 'sid' not in session: return "Session expired", 400 + # For /comic, we can serve the same INDEX_HTML but maybe trigger a load immediately? + # Or just serve the page.html if generated. + return send_from_directory(os.path.join(BASE_USER_DIR, session['sid'], 'output'), 'page.html') @app.route('/output/') def output_file(filename): - return send_from_directory('output', filename) + if 'sid' not in session: return "Session expired", 400 + return send_from_directory(os.path.join(BASE_USER_DIR, session['sid'], 'output'), filename) -@app.route('/frames/final/') +@app.route('/frames/') def frame_file(filename): - return send_from_directory('frames/final', filename) + if 'sid' not in session: return "Session expired", 400 + return send_from_directory(os.path.join(BASE_USER_DIR, session['sid'], 'frames'), filename) + +# Actions that modify state need the session ID to find the correct folders +@app.route('/regenerate_frame', methods=['POST']) +def regenerate_frame_route(): + if 'sid' not in session: return jsonify({'success': False, 'message': 'Session expired'}) + data = request.get_json() + gen = EnhancedComicGenerator(session['sid']) + return jsonify(gen.regenerate_frame(data['filename'], data['direction'])) + +@app.route('/replace_panel', methods=['POST']) +def replace_panel(): + if 'sid' not in session: return jsonify({'success': False, 'error': 'Session expired'}) + if 'image' not in request.files: return jsonify({'success': False, 'error': 'No image'}) + file = request.files['image'] + gen = EnhancedComicGenerator(session['sid']) + filename = f"replaced_{int(time.time())}.png" + file.save(os.path.join(gen.frames_dir, filename)) + return jsonify({'success': True, 'new_filename': filename}) + +@app.route('/goto_timestamp', methods=['POST']) +def goto_timestamp_route(): + if 'sid' not in session: return jsonify({'success': False, 'message': 'Session expired'}) + data = request.get_json() + gen = EnhancedComicGenerator(session['sid']) + return jsonify(gen.get_frame_at_timestamp(data['filename'], float(data['timestamp']))) if __name__ == '__main__': + # Ensure base userdata dir exists + os.makedirs(BASE_USER_DIR, exist_ok=True) port = int(os.getenv("PORT", 7860)) - print(f"๐Ÿš€ Starting Enhanced Comic Generator on host 0.0.0.0, port {port}") + print(f"๐Ÿš€ Starting Multi-User Comic Generator on port {port}") app.run(debug=False, host='0.0.0.0', port=port) \ No newline at end of file