RealComic / app_enhanced.py
tester343's picture
Update app_enhanced.py
79251e9 verified
import os
import time
import threading
import uuid
import shutil
import json
import traceback
import logging
import string
import random
from concurrent.futures import ThreadPoolExecutor
from flask import Flask, render_template, request, jsonify, send_from_directory, send_file
# --- HUGGING FACE ZEROGPU SETUP ---
try:
import spaces
print("✅ Hugging Face Spaces 'spaces' module detected.")
HAS_ZEROGPU = True
except ImportError:
print("⚠️ 'spaces' module not found. Running in standard mode (CPU/GPU without ZeroGPU allocator).")
HAS_ZEROGPU = False
# Conditional Decorator for ZeroGPU
def gpu_task(duration=120):
def decorator(func):
if HAS_ZEROGPU:
return spaces.GPU(duration=duration)(func)
return func
return decorator
# --- 0. CONFIG & LOGGING ---
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# --- HUGGING FACE FILESYSTEM SETUP ---
# Spaces are read-only in root. We must use /tmp or /data (persistent storage)
if os.access('/data', os.W_OK):
BASE_PATH = '/data'
print("✅ Using Persistent Storage at /data")
else:
BASE_PATH = '/tmp'
print("✅ Using Temporary Storage at /tmp")
BASE_USER_DIR = os.path.join(BASE_PATH, "userdata")
SAVED_COMICS_DIR = os.path.join(BASE_PATH, "saved_comics")
# Create directories
os.makedirs(BASE_USER_DIR, exist_ok=True)
os.makedirs(SAVED_COMICS_DIR, exist_ok=True)
# --- 1. CORE DEPENDENCY CHECKS ---
try:
import cv2
import numpy as np
from PIL import Image
import srt
except ImportError as e:
print(f"❌ CRITICAL ERROR: Missing python library. {e}")
cv2 = None
np = None
Image = None
srt = None
# --- 2. BACKEND IMPORTS WITH FALLBACKS ---
def dummy_func(*args, **kwargs):
return 0, 0, None, None
try:
from backend.keyframes.keyframes import black_bar_crop
print("✅ Black bar cropping module loaded.")
except Exception as e:
print(f"⚠️ Could not load black_bar_crop: {e}. Cropping will be SKIPPED.")
black_bar_crop = dummy_func
try:
from backend.simple_color_enhancer import SimpleColorEnhancer
print("✅ SimpleColorEnhancer loaded.")
except Exception as e:
print(f"⚠️ Could not load SimpleColorEnhancer: {e}.")
class SimpleColorEnhancer:
def enhance_batch(self, *args, **kwargs): pass
def enhance_single(self, *args, **kwargs): pass
try:
from backend.quality_color_enhancer import QualityColorEnhancer
print("✅ QualityColorEnhancer loaded.")
except Exception as e:
print(f"⚠️ Could not load QualityColorEnhancer: {e}.")
class QualityColorEnhancer:
def batch_enhance(self, *args, **kwargs): pass
def enhance_single(self, *args, **kwargs): pass
try:
from backend.class_def import bubble, panel, Page
print("✅ Core class definitions loaded.")
except Exception as e:
print(f"⚠️ Using fallback class definitions.")
def bubble(dialog="", bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, emotion='normal'):
return {
'dialog': dialog,
'bubble_offset_x': bubble_offset_x,
'bubble_offset_y': bubble_offset_y,
'lip_x': lip_x,
'lip_y': lip_y,
'emotion': emotion
}
def panel(image=""):
return {'image': image}
class Page:
def __init__(self, panels, bubbles):
self.panels = panels
self.bubbles = bubbles
try:
from backend.ai_enhanced_core import image_processor, comic_styler, face_detector, layout_optimizer
from backend.ai_bubble_placement import ai_bubble_placer
from backend.subtitles.subs_real import get_real_subtitles
from backend.keyframes.keyframes_simple import generate_keyframes_simple
print("✅ Core utility modules loaded.")
except Exception as e:
print(f"⚠️ Could not load utility modules: {e}")
def get_real_subtitles(v): pass
def generate_keyframes_simple(*args, **kwargs): pass
class DummyDetector:
def detect_faces(self, p): return []
def get_lip_position(self, p, f): return -1, -1
face_detector = DummyDetector()
class DummyPlacer:
def place_bubble_ai(self, p, l): return 50, 20
ai_bubble_placer = DummyPlacer()
# --- FLASK APP SETUP ---
app = Flask(__name__)
def generate_save_code(length=8):
"""Generate a unique save code"""
chars = string.ascii_uppercase + string.digits
while True:
code = ''.join(random.choices(chars, k=length))
if not os.path.exists(os.path.join(SAVED_COMICS_DIR, code)):
return code
# --- FULL HTML INTERFACE ---
INDEX_HTML = '''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Movie to Comic Generator</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Gloria+Hallelujah&family=Lato&display=swap" rel="stylesheet">
<style>
/* --- GLOBAL STYLES --- */
* { box-sizing: border-box; }
body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
/* --- UPLOAD VIEW --- */
#upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
.upload-box { max-width: 500px; width: 100%; padding: 40px; background: white; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.12); text-align: center; }
/* --- EDITOR VIEW --- */
#editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 100px; }
/* --- BUTTONS & INPUTS --- */
h1 { color: #2c3e50; margin-bottom: 20px; font-weight: 600; }
h3 { color: #34495e; margin: 20px 0 10px 0; font-size: 16px; }
.file-input { display: none; }
.file-label { display: block; padding: 15px; background: #2c3e50; color: white; border-radius: 8px; cursor: pointer; font-weight: bold; margin-bottom: 10px; transition:0.2s; }
.file-label:hover { background: #34495e; }
.submit-btn { width: 100%; padding: 15px; background: #e67e22; color: white; border: none; border-radius: 8px; font-size: 18px; font-weight: bold; cursor: pointer; transition: 0.2s; }
.submit-btn:hover { background: #d35400; }
.restore-btn { margin-top: 10px; background: #27ae60; color: white; padding: 12px; width: 100%; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
.restore-btn:hover { background: #219a52; }
.load-section { margin-top: 30px; padding-top: 20px; border-top: 2px solid #eee; }
.load-input-group { display: flex; gap: 10px; margin-top: 10px; }
.load-input-group input { flex: 1; padding: 12px; border: 2px solid #ddd; border-radius: 8px; font-size: 16px; text-transform: uppercase; letter-spacing: 2px; text-align: center; }
.load-input-group button { padding: 12px 20px; background: #3498db; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
.load-input-group button:hover { background: #2980b9; }
.loader { width: 120px; height: 20px; background: radial-gradient(circle 10px, #e67e22 100%, transparent 0); background-size: 20px 20px; animation: ball 1s infinite linear; margin: 20px auto; }
@keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
/* --- COMIC PAGE LAYOUT --- */
.comic-wrapper { max-width: 1000px; margin: 0 auto; }
.page-wrapper { margin: 30px auto; display: flex; flex-direction: column; align-items: center; }
.page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
.comic-page { background: white; width: 600px; height: 400px; box-shadow: 0 4px 10px rgba(0,0,0,0.1); position: relative; overflow: hidden; border: 2px solid #000; padding: 10px; }
.comic-grid { display: grid; grid-template-columns: 285px 285px; grid-template-rows: 185px 185px; gap: 10px; width: 100%; height: 100%; }
.panel { position: relative; overflow: hidden; border: 2px solid #000; background: #eee; cursor: pointer; }
.panel.selected { outline: 3px solid #2196F3; outline-offset: -3px; }
.panel img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.1s ease-out; transform-origin: center center; }
.panel img.pannable { cursor: grab; }
.panel img.panning { cursor: grabbing; }
/* --- SPEECH BUBBLE GENERAL --- */
.speech-bubble {
position: absolute; display: flex; justify-content: center; align-items: center;
width: 150px; height: 80px; min-width: 50px; min-height: 30px; box-sizing: border-box;
z-index: 10; cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold;
font-size: 13px; text-align: center; overflow: visible;
}
.bubble-text { padding: 0.8em; word-wrap: break-word; position: relative; z-index: 5; }
.speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
.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); text-align:center; padding:8px; z-index:102; resize:none; font: inherit; }
/* --- SPEECH BUBBLE CSS --- */
.speech-bubble.speech {
--b: 3em;
--h: 1.8em;
--t: 0.6;
--p: var(--tail-pos, 50%);
--r: 1.2em;
--c: var(--bubble-fill-color, #4ECDC4);
background: var(--c);
color: var(--bubble-text-color, #fff);
padding: 1em;
position: absolute;
border-radius: var(--r) var(--r) min(var(--r), calc(100% - var(--p) - (1 - var(--t)) * var(--b) / 2)) min(var(--r), calc(var(--p) - (1 - var(--t)) * var(--b) / 2)) / var(--r);
}
.speech-bubble.speech:before {
content: ""; position: absolute; width: var(--b); height: var(--h);
background: inherit;
border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
-webkit-mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
}
.speech-bubble.speech.tail-bottom:before { top: 100%; left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); }
.speech-bubble.speech.tail-top {
border-radius: min(var(--r), calc(var(--p) - (1 - var(--t)) * var(--b) / 2)) min(var(--r), calc(100% - var(--p) - (1 - var(--t)) * var(--b) / 2)) var(--r) var(--r) / var(--r);
}
.speech-bubble.speech.tail-top:before {
bottom: 100%; left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
transform: scaleY(-1);
}
.speech-bubble.speech.tail-left { border-radius: var(--r); }
.speech-bubble.speech.tail-left:before {
right: 100%; top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
transform: rotate(90deg); transform-origin: top right;
}
.speech-bubble.speech.tail-right { border-radius: var(--r); }
.speech-bubble.speech.tail-right:before {
left: 100%; top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
transform: rotate(-90deg); transform-origin: top left;
}
.speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
.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 .thought-dot-1 { left: auto; right: 15px; }
.speech-bubble.flipped .thought-dot-2 { left: auto; right: 5px; }
.speech-bubble.flipped-vertical .thought-dot-1 { bottom: auto; top: -20px; }
.speech-bubble.flipped-vertical .thought-dot-2 { bottom: auto; top: -32px; }
.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%;
}
.resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; z-index: 11; }
.speech-bubble.selected .resize-handle { display: block; }
.resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
.resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
.resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
.resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
/* FLOATING TOOLBAR */
.edit-controls {
position: fixed; bottom: 20px; right: 20px; width: 260px;
background: rgba(44, 62, 80, 0.95); color: white; padding: 15px; border-radius: 8px;
box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 900; font-size: 13px;
max-height: 90vh; overflow-y: auto;
}
.edit-controls h4 { margin: 0 0 10px 0; color: #4ECDC4; text-align: center; }
.control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
.control-group label { font-size: 11px; font-weight: bold; display: block; margin-bottom: 3px; }
button, input, select { width: 100%; margin-top: 5px; padding: 6px; border-radius: 4px; border: 1px solid #ddd; cursor: pointer; font-weight: bold; font-size: 12px; }
button:hover { background: #f5f5f5; }
.button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
.color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.color-grid div { text-align: center; }
.color-grid input[type="color"] { height: 30px; padding: 2px; }
.slider-container { display: flex; align-items: center; gap: 5px; margin-top: 5px; }
.slider-container label { min-width: 40px; font-size: 11px; }
.timestamp-controls { display: grid; grid-template-columns: 1fr auto; gap: 5px; }
.timestamp-controls input { color: #333; font-weight: normal; }
.action-btn { background: #4CAF50; color: white; }
.reset-btn { background: #e74c3c; color: white; }
.secondary-btn { background: #f39c12; color: white; }
.export-btn { background: #2196F3; color: white; }
.save-btn { background: #9b59b6; color: white; }
/* SAVE CODE DISPLAY */
.save-code-display {
background: #2ecc71; color: white; padding: 15px; border-radius: 8px;
text-align: center; margin-top: 10px; display: none;
}
.save-code-display .code {
font-size: 24px; font-weight: bold; letter-spacing: 3px;
background: white; color: #2ecc71; padding: 10px 20px;
border-radius: 4px; display: inline-block; margin: 10px 0;
font-family: monospace;
}
.save-code-display button {
background: white; color: #2ecc71; border: none;
padding: 8px 15px; border-radius: 4px; cursor: pointer;
font-weight: bold; margin-top: 5px;
}
/* MODAL */
.modal-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.7); display: none; justify-content: center;
align-items: center; z-index: 9999;
}
.modal-content {
background: white; padding: 30px; border-radius: 12px;
max-width: 400px; width: 90%; text-align: center;
}
.modal-content h2 { color: #2ecc71; margin-bottom: 20px; }
.modal-content .code {
font-size: 32px; font-weight: bold; letter-spacing: 4px;
background: #f0f0f0; padding: 15px 25px; border-radius: 8px;
display: inline-block; margin: 15px 0; font-family: monospace;
user-select: all;
}
.modal-content p { color: #666; margin: 10px 0; }
.modal-content button {
background: #3498db; color: white; border: none;
padding: 12px 30px; border-radius: 8px; cursor: pointer;
font-weight: bold; font-size: 14px; margin: 5px;
}
.modal-content button.close-btn { background: #95a5a6; }
</style>
</head>
<body>
<!-- 1. UPLOAD SCREEN -->
<div id="upload-container">
<div class="upload-box">
<h1>🎬 Comic Generator</h1>
<!-- New Comic Section -->
<input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
<label for="file-upload" class="file-label">📁 Choose Video File</label>
<span id="fn" style="margin-bottom:10px; display:block; color:#666;">No file selected</span>
<button class="submit-btn" onclick="upload()">🚀 Generate Comic</button>
<!-- Restore Draft Section -->
<button id="restore-draft-btn" class="restore-btn" style="display:none; margin-top:15px;" onclick="restoreDraft()">
📂 Restore Unsaved Draft
</button>
<!-- Load Saved Comic Section -->
<div class="load-section">
<h3>📥 Load Saved Comic</h3>
<p style="font-size:12px; color:#888; margin-bottom:10px;">Enter your save code to continue editing</p>
<div class="load-input-group">
<input type="text" id="load-code-input" placeholder="SAVE CODE" maxlength="8" style="text-transform:uppercase;">
<button onclick="loadSavedComic()">Load</button>
</div>
</div>
<div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
<div class="loader" style="margin:0 auto;"></div>
<p id="status-text" style="margin-top:10px;">Starting...</p>
</div>
</div>
</div>
<!-- 2. EDITOR SCREEN -->
<div id="editor-container">
<div class="comic-wrapper" id="comic-container"></div>
<input type="file" id="image-uploader" style="display: none;" accept="image/*">
<div class="edit-controls">
<h4>✏️ Interactive Editor</h4>
<!-- Save/Load Section -->
<div class="control-group">
<label>💾 Save & Load:</label>
<button onclick="saveComic()" class="save-btn">💾 Save Comic (Get Code)</button>
<div id="current-save-code" style="display:none; margin-top:8px; padding:8px; background:#2ecc71; border-radius:4px; text-align:center;">
<span style="font-size:11px;">Current Save Code:</span><br>
<span id="display-save-code" style="font-size:18px; font-weight:bold; letter-spacing:2px;"></span>
<button onclick="copyCode()" style="padding:4px 8px; margin-left:5px; font-size:10px;">📋 Copy</button>
</div>
</div>
<!-- Bubble Controls -->
<div class="control-group">
<label>💬 Bubble Tools:</label>
<select id="bubble-type-select" onchange="changeBubbleType(this.value)" disabled>
<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>
<select id="font-select" onchange="changeFont(this.value)" disabled>
<option value="'Comic Neue', cursive">Comic Neue</option>
<option value="'Bangers', cursive">Bangers</option>
<option value="'Gloria Hallelujah', cursive">Gloria</option>
<option value="'Lato', sans-serif">Lato</option>
</select>
<div class="color-grid">
<div>
<label>Text</label>
<input type="color" id="bubble-text-color" value="#ffffff" disabled>
</div>
<div>
<label>Fill</label>
<input type="color" id="bubble-fill-color" value="#4ECDC4" disabled>
</div>
</div>
<button onclick="addBubble()" class="action-btn">💬 Add Bubble</button>
<button onclick="deleteBubble()" class="reset-btn">🗑️ Delete Bubble</button>
</div>
<!-- Tail Controls -->
<div class="control-group" id="tail-controls" style="display:none;">
<label>📐 Tail Adjustment:</label>
<button onclick="rotateTail()" class="secondary-btn">🔄 Rotate Side</button>
<div class="slider-container">
<label>Pos:</label>
<input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)">
</div>
</div>
<!-- Panel Controls -->
<div class="control-group">
<label>🖼️ Panel Tools:</label>
<button onclick="replacePanelImage()" class="action-btn">🖼️ Replace Image</button>
<div class="button-grid">
<button onclick="adjustFrame('backward')" class="secondary-btn">⬅️ Prev</button>
<button onclick="adjustFrame('forward')" class="action-btn">Next ➡️</button>
</div>
<div class="timestamp-controls">
<input type="text" id="timestamp-input" placeholder="mm:ss or secs">
<button onclick="gotoTimestamp()" class="action-btn">Go</button>
</div>
</div>
<!-- Zoom Controls -->
<div class="control-group">
<label>🔍 Zoom & Pan:</label>
<div class="button-grid">
<button onclick="resetPanelTransform()" class="secondary-btn">Reset</button>
<input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled oninput="handleZoom(this)">
</div>
</div>
<!-- Actions -->
<div class="control-group">
<button onclick="exportComic()" class="export-btn">📥 Export as PNG</button>
<button onclick="goBackToUpload()" class="reset-btn" style="margin-top:10px;">🏠 Back to Home</button>
</div>
</div>
</div>
<!-- SAVE CODE MODAL -->
<div class="modal-overlay" id="save-modal">
<div class="modal-content">
<h2>✅ Comic Saved!</h2>
<p>Your unique save code is:</p>
<div class="code" id="modal-save-code">XXXXXXXX</div>
<p style="font-size:12px;">Write this code down or copy it.<br>Anyone can load this comic using this code.</p>
<button onclick="copyModalCode()">📋 Copy Code</button>
<button class="close-btn" onclick="closeModal()">Close</button>
</div>
</div>
<script>
// --- SESSION LOGIC ---
function genUUID(){ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,c=>{var r=Math.random()*16|0,v=c=='x'?r:(r&0x3|0x8);return v.toString(16);}); }
let sid = localStorage.getItem('comic_sid') || genUUID();
localStorage.setItem('comic_sid', sid);
console.log("Session ID:", sid);
let currentSaveCode = null;
let interval, selectedBubble = null, selectedPanel = null;
let isDragging = false, isResizing = false, isPanning = false;
let startX, startY, initX, initY, initW, initH;
let panStartX, panStartY, panStartTx, panStartTy;
let resizeHandle = '', originalWidth, originalHeight, originalX, originalY, originalMouseX, originalMouseY;
let currentlyEditing = null;
// Check for draft on load
if(localStorage.getItem('comic_draft_'+sid)) {
document.getElementById('restore-draft-btn').style.display = 'block';
}
// --- MODAL FUNCTIONS ---
function showSaveModal(code) {
document.getElementById('modal-save-code').textContent = code;
document.getElementById('save-modal').style.display = 'flex';
}
function closeModal() {
document.getElementById('save-modal').style.display = 'none';
}
function copyModalCode() {
const code = document.getElementById('modal-save-code').textContent;
navigator.clipboard.writeText(code).then(() => {
alert('Code copied to clipboard!');
});
}
function copyCode() {
if(currentSaveCode) {
navigator.clipboard.writeText(currentSaveCode).then(() => {
alert('Code copied!');
});
}
}
// --- SAVE COMIC TO SERVER ---
async function saveComic() {
const state = getCurrentState();
if(!state || state.length === 0) {
alert('No comic to save!');
return;
}
try {
const response = await fetch(`/save_comic?sid=${sid}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
pages: state,
savedAt: new Date().toISOString()
})
});
const data = await response.json();
if(data.success) {
currentSaveCode = data.code;
document.getElementById('display-save-code').textContent = data.code;
document.getElementById('current-save-code').style.display = 'block';
showSaveModal(data.code);
// Also save to local draft
saveDraft();
} else {
alert('Failed to save: ' + data.message);
}
} catch(e) {
console.error(e);
alert('Error saving comic');
}
}
// --- LOAD SAVED COMIC ---
async function loadSavedComic() {
const code = document.getElementById('load-code-input').value.trim().toUpperCase();
if(!code || code.length < 4) {
alert('Please enter a valid save code');
return;
}
try {
const response = await fetch(`/load_comic/${code}`);
const data = await response.json();
if(data.success) {
currentSaveCode = code;
sid = data.originalSid || sid;
localStorage.setItem('comic_sid', sid);
renderFromState(data.pages);
document.getElementById('upload-container').style.display = 'none';
document.getElementById('editor-container').style.display = 'block';
document.getElementById('display-save-code').textContent = code;
document.getElementById('current-save-code').style.display = 'block';
saveDraft();
} else {
alert('Could not load comic: ' + data.message);
}
} catch(e) {
console.error(e);
alert('Error loading comic. Check the code and try again.');
}
}
// --- RESTORE DRAFT (Local Storage) ---
function restoreDraft() {
const savedData = localStorage.getItem('comic_draft_'+sid);
if(!savedData) {
alert("No draft found.");
return;
}
try {
const state = JSON.parse(savedData);
if(state.saveCode) {
currentSaveCode = state.saveCode;
document.getElementById('display-save-code').textContent = state.saveCode;
document.getElementById('current-save-code').style.display = 'block';
}
renderFromState(state.pages || state);
document.getElementById('upload-container').style.display = 'none';
document.getElementById('editor-container').style.display = 'block';
} catch(e) {
console.error(e);
alert("Failed to restore draft.");
}
}
// --- GET CURRENT STATE ---
function getCurrentState() {
const pages = [];
document.querySelectorAll('.comic-page').forEach(p => {
const panels = [];
p.querySelectorAll('.panel').forEach(pan => {
const img = pan.querySelector('img');
const bubbles = [];
pan.querySelectorAll('.speech-bubble').forEach(b => {
const textEl = b.querySelector('.bubble-text');
bubbles.push({
text: textEl ? textEl.textContent : '',
left: b.style.left,
top: b.style.top,
width: b.style.width,
height: b.style.height,
classes: b.className,
type: b.dataset.type,
font: b.style.fontFamily,
tailPos: b.style.getPropertyValue('--tail-pos'),
colors: {
fill: b.style.getPropertyValue('--bubble-fill-color'),
text: b.style.getPropertyValue('--bubble-text-color')
}
});
});
panels.push({
src: img.src,
zoom: img.dataset.zoom,
tx: img.dataset.translateX,
ty: img.dataset.translateY,
bubbles: bubbles
});
});
pages.push({ panels: panels });
});
return pages;
}
// --- SAVE DRAFT TO LOCAL STORAGE ---
function saveDraft() {
const state = {
pages: getCurrentState(),
saveCode: currentSaveCode,
savedAt: new Date().toISOString()
};
localStorage.setItem('comic_draft_'+sid, JSON.stringify(state));
}
// --- RENDER FROM STATE ---
function renderFromState(pagesData) {
const con = document.getElementById('comic-container');
con.innerHTML = '';
pagesData.forEach((page, pageIdx) => {
const pageWrapper = document.createElement('div');
pageWrapper.className = 'page-wrapper';
const pageTitle = document.createElement('h2');
pageTitle.className = 'page-title';
pageTitle.textContent = `Page ${pageIdx + 1}`;
pageWrapper.appendChild(pageTitle);
const div = document.createElement('div');
div.className = 'comic-page';
const grid = document.createElement('div');
grid.className = 'comic-grid';
page.panels.forEach((pan) => {
const pDiv = document.createElement('div');
pDiv.className = 'panel';
pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
const img = document.createElement('img');
img.src = pan.src;
img.dataset.zoom = pan.zoom || 100;
img.dataset.translateX = pan.tx || 0;
img.dataset.translateY = pan.ty || 0;
updateImageTransform(img);
img.onmousedown = (e) => startPan(e, img);
pDiv.appendChild(img);
(pan.bubbles || []).forEach(bData => {
const b = createBubbleHTML(bData);
pDiv.appendChild(b);
});
grid.appendChild(pDiv);
});
div.appendChild(grid);
pageWrapper.appendChild(div);
con.appendChild(pageWrapper);
});
}
// --- UPLOAD LOGIC ---
async function upload() {
const f = document.getElementById('file-upload').files[0];
if(!f) return alert("Select a video file first");
// Generate new session for new upload
sid = genUUID();
localStorage.setItem('comic_sid', sid);
currentSaveCode = null;
document.querySelector('.upload-box').style.display='none';
document.getElementById('loading-view').style.display='flex';
const fd = new FormData(); fd.append('file', f);
const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
if(r.ok) interval = setInterval(checkStatus, 2000);
else { alert("Upload failed"); location.reload(); }
}
async function checkStatus() {
try {
const r = await fetch(`/status?sid=${sid}`);
const d = await r.json();
document.getElementById('status-text').innerText = d.message;
if(d.progress >= 100) {
clearInterval(interval);
document.getElementById('upload-container').style.display='none';
document.getElementById('editor-container').style.display='block';
loadNewComic();
} else if (d.progress < 0) {
clearInterval(interval);
document.getElementById('status-text').textContent = "Error: " + d.message;
document.querySelector('.loader').style.display = 'none';
}
} catch(e) {}
}
function loadNewComic() {
fetch(`/output/pages.json?sid=${sid}`).then(r=>r.json()).then(data => {
const cleanData = data.map((p, pi) => ({
panels: p.panels.map((pan, j) => ({
src: `/frames/${pan.image}?sid=${sid}`,
bubbles: (p.bubbles && p.bubbles[j] && p.bubbles[j].dialog) ? [{
text: p.bubbles[j].dialog,
left: (p.bubbles[j].bubble_offset_x || 50) + 'px',
top: (p.bubbles[j].bubble_offset_y || 20) + 'px',
type: 'speech',
tailPos: '50%',
colors: { text: '#ffffff', fill: '#4ECDC4' }
}] : []
}))
}));
renderFromState(cleanData);
saveDraft();
});
}
// --- GO BACK TO UPLOAD ---
function goBackToUpload() {
if(confirm('Go back to home? Make sure you saved your comic!')) {
document.getElementById('editor-container').style.display = 'none';
document.getElementById('upload-container').style.display = 'flex';
document.querySelector('.upload-box').style.display = 'block';
document.getElementById('loading-view').style.display = 'none';
document.getElementById('current-save-code').style.display = 'none';
}
}
// --- BUBBLE CREATION ---
function createBubbleHTML(data) {
const b = document.createElement('div');
b.dataset.type = data.type || 'speech';
applyBubbleType(b, data.type || 'speech', data.classes);
b.style.left = data.left;
b.style.top = data.top;
if(data.width) b.style.width = data.width;
if(data.height) b.style.height = data.height;
if(data.font) b.style.fontFamily = data.font;
if(data.colors) {
b.style.setProperty('--bubble-fill-color', data.colors.fill || '#4ECDC4');
b.style.setProperty('--bubble-text-color', data.colors.text || '#ffffff');
}
if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
const textSpan = document.createElement('span');
textSpan.className = 'bubble-text';
textSpan.textContent = data.text || '';
b.appendChild(textSpan);
['nw', 'ne', 'sw', 'se'].forEach(dir => {
const handle = document.createElement('div');
handle.className = `resize-handle ${dir}`;
handle.onmousedown = (e) => startResize(e, dir);
b.appendChild(handle);
});
b.onmousedown = (e) => {
if(e.target.classList.contains('resize-handle')) return;
e.stopPropagation(); selectBubble(b);
isDragging = true; startX = e.clientX; startY = e.clientY;
initX = b.offsetLeft; initY = b.offsetTop;
};
b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
b.onclick = (e) => { e.stopPropagation(); selectBubble(b); };
return b;
}
function applyBubbleType(bubble, type, existingClasses) {
bubble.querySelectorAll('.thought-dot').forEach(el => el.remove());
let baseClasses = 'speech-bubble ' + type;
if (type === 'speech') baseClasses += ' tail-bottom';
if (existingClasses && existingClasses.includes('selected')) baseClasses += ' selected';
bubble.className = baseClasses;
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 editBubbleText(bubble) {
if (currentlyEditing) return;
currentlyEditing = bubble;
const textSpan = bubble.querySelector('.bubble-text');
const textarea = document.createElement('textarea');
textarea.value = textSpan.textContent;
bubble.appendChild(textarea);
textSpan.style.display = 'none';
textarea.focus();
const finishEditing = () => {
textSpan.textContent = textarea.value;
textarea.remove();
textSpan.style.display = '';
currentlyEditing = null;
saveDraft();
};
textarea.addEventListener('blur', finishEditing, { once: true });
textarea.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
textarea.blur();
}
});
}
// --- GLOBAL EVENTS ---
document.addEventListener('mousemove', (e) => {
if(isDragging && selectedBubble) {
selectedBubble.style.left = (initX + e.clientX - startX) + 'px';
selectedBubble.style.top = (initY + e.clientY - startY) + 'px';
}
if(isResizing && selectedBubble) {
resizeBubble(e);
}
if(isPanning && selectedPanel) {
panImage(e);
}
});
document.addEventListener('mouseup', () => {
if(isDragging || isResizing || isPanning) saveDraft();
isDragging = false; isResizing = false; isPanning = false;
});
// --- RESIZE LOGIC ---
function startResize(e, dir) {
e.preventDefault();
e.stopPropagation();
isResizing = true;
resizeHandle = dir;
const rect = selectedBubble.getBoundingClientRect();
originalWidth = rect.width;
originalHeight = rect.height;
originalX = selectedBubble.offsetLeft;
originalY = selectedBubble.offsetTop;
originalMouseX = e.clientX;
originalMouseY = e.clientY;
}
function resizeBubble(e) {
if (!isResizing || !selectedBubble) return;
const dx = e.clientX - originalMouseX;
const dy = e.clientY - originalMouseY;
if (resizeHandle.includes('e')) selectedBubble.style.width = `${originalWidth + dx}px`;
if (resizeHandle.includes('w')) {
selectedBubble.style.width = `${originalWidth - dx}px`;
selectedBubble.style.left = `${originalX + dx}px`;
}
if (resizeHandle.includes('s')) selectedBubble.style.height = `${originalHeight + dy}px`;
if (resizeHandle.includes('n')) {
selectedBubble.style.height = `${originalHeight - dy}px`;
selectedBubble.style.top = `${originalY + dy}px`;
}
}
// --- SELECTION ---
function selectBubble(el) {
if(selectedBubble) selectedBubble.classList.remove('selected');
if(selectedPanel) { selectedPanel.classList.remove('selected'); selectedPanel = null; }
selectedBubble = el;
el.classList.add('selected');
const bubbleType = el.dataset.type || 'speech';
document.getElementById('tail-controls').style.display = (bubbleType === 'speech' || bubbleType === 'thought') ? 'block' : 'none';
['bubble-text-color','bubble-fill-color','bubble-type-select','font-select'].forEach(i => document.getElementById(i).disabled = false);
document.getElementById('zoom-slider').disabled = true;
document.getElementById('bubble-type-select').value = bubbleType;
const styles = window.getComputedStyle(el);
const textColor = styles.getPropertyValue('--bubble-text-color').trim() || rgbToHex(styles.color);
const fillColor = styles.getPropertyValue('--bubble-fill-color').trim() || rgbToHex(styles.backgroundColor);
document.getElementById('bubble-text-color').value = textColor;
document.getElementById('bubble-fill-color').value = fillColor;
const tailPos = styles.getPropertyValue('--tail-pos').trim();
document.getElementById('tail-slider').value = tailPos ? parseInt(tailPos) : 50;
}
function selectPanel(el) {
if(selectedPanel) selectedPanel.classList.remove('selected');
if(selectedBubble) { selectedBubble.classList.remove('selected'); selectedBubble = null; }
selectedPanel = el;
el.classList.add('selected');
const img = el.querySelector('img');
document.getElementById('zoom-slider').disabled = false;
document.getElementById('zoom-slider').value = img.dataset.zoom || 100;
['bubble-text-color','bubble-fill-color','bubble-type-select','font-select'].forEach(i => document.getElementById(i).disabled = true);
document.getElementById('tail-controls').style.display = 'none';
}
// --- BUBBLE ACTIONS ---
function addBubble() {
if(!selectedPanel) return alert("Select a panel first");
const b = createBubbleHTML({
text: "New Text", left: "50px", top: "30px",
type: 'speech',
colors: { text: '#ffffff', fill: '#4ECDC4' }
});
selectedPanel.appendChild(b);
selectBubble(b);
saveDraft();
}
function deleteBubble() {
if(!selectedBubble) return alert("Select a bubble first");
if(confirm("Delete this bubble?")) {
selectedBubble.remove();
selectedBubble = null;
saveDraft();
}
}
function changeBubbleType(type) {
if(!selectedBubble) return;
applyBubbleType(selectedBubble, type);
selectedBubble.classList.add('selected');
document.getElementById('tail-controls').style.display = (type === 'speech' || type === 'thought') ? 'block' : 'none';
saveDraft();
}
function changeFont(font) {
if(!selectedBubble) return;
selectedBubble.style.fontFamily = font;
saveDraft();
}
function rotateTail() {
if(!selectedBubble) return;
const type = selectedBubble.dataset.type;
if(type === 'speech') {
const positions = ['tail-bottom', 'tail-right', 'tail-top', 'tail-left'];
let current = 0;
positions.forEach((pos, i) => { if(selectedBubble.classList.contains(pos)) current = i; });
selectedBubble.classList.remove(positions[current]);
selectedBubble.classList.add(positions[(current + 1) % 4]);
} else if(type === 'thought') {
const isFlippedH = selectedBubble.classList.contains('flipped');
const isFlippedV = selectedBubble.classList.contains('flipped-vertical');
if (!isFlippedH && !isFlippedV) selectedBubble.classList.add('flipped');
else if (isFlippedH && !isFlippedV) selectedBubble.classList.add('flipped-vertical');
else if (isFlippedH && isFlippedV) selectedBubble.classList.remove('flipped');
else selectedBubble.classList.remove('flipped-vertical');
}
saveDraft();
}
function slideTail(v) {
if(selectedBubble) {
selectedBubble.style.setProperty('--tail-pos', v+'%');
saveDraft();
}
}
document.getElementById('bubble-text-color').addEventListener('input', (e) => {
if(selectedBubble) {
selectedBubble.style.setProperty('--bubble-text-color', e.target.value);
saveDraft();
}
});
document.getElementById('bubble-fill-color').addEventListener('input', (e) => {
if(selectedBubble) {
selectedBubble.style.setProperty('--bubble-fill-color', e.target.value);
saveDraft();
}
});
// --- PAN & ZOOM ---
function handleZoom(el) {
if(!selectedPanel) return;
const img = selectedPanel.querySelector('img');
img.dataset.zoom = el.value;
updateImageTransform(img);
saveDraft();
}
function startPan(e, img) {
if(parseFloat(img.dataset.zoom || 100) <= 100) return;
e.preventDefault();
isPanning = true;
selectedPanel = img.closest('.panel');
panStartX = e.clientX;
panStartY = e.clientY;
panStartTx = parseFloat(img.dataset.translateX || 0);
panStartTy = parseFloat(img.dataset.translateY || 0);
img.classList.add('panning');
}
function panImage(e) {
if(!isPanning || !selectedPanel) return;
const img = selectedPanel.querySelector('img');
img.dataset.translateX = panStartTx + (e.clientX - panStartX);
img.dataset.translateY = panStartTy + (e.clientY - panStartY);
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(!selectedPanel) return alert("Select a panel first");
const img = selectedPanel.querySelector('img');
img.dataset.zoom = 100;
img.dataset.translateX = 0;
img.dataset.translateY = 0;
document.getElementById('zoom-slider').value = 100;
updateImageTransform(img);
saveDraft();
}
// --- BACKEND ACTIONS ---
function replacePanelImage() {
if(!selectedPanel) return alert("Select a panel first");
const inp = document.getElementById('image-uploader');
inp.onchange = async (e) => {
const fd = new FormData();
fd.append('image', e.target.files[0]);
const img = selectedPanel.querySelector('img');
img.style.opacity = '0.5';
const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd});
const d = await r.json();
if(d.success) {
img.src = `/frames/${d.new_filename}?sid=${sid}`;
resetPanelTransform();
saveDraft();
} else {
alert('Error: ' + d.error);
}
img.style.opacity = '1';
inp.value = '';
};
inp.click();
}
async function adjustFrame(dir) {
if(!selectedPanel) return alert("Select a panel first");
const img = selectedPanel.querySelector('img');
let fname = img.src.split('/').pop().split('?')[0];
img.style.opacity = '0.5';
const r = await fetch(`/regenerate_frame?sid=${sid}`, {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({filename:fname, direction:dir})
});
const d = await r.json();
if(d.success) {
img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
} else {
alert('Error: ' + d.message);
}
img.style.opacity = '1';
saveDraft();
}
async function gotoTimestamp() {
if(!selectedPanel) return alert("Select a panel first");
let v = document.getElementById('timestamp-input').value.trim();
if(!v) return;
if(v.includes(':')) {
let p = v.split(':');
v = parseInt(p[0]) * 60 + parseFloat(p[1]);
} else {
v = parseFloat(v);
}
if(isNaN(v)) return alert("Invalid time format");
const img = selectedPanel.querySelector('img');
let fname = img.src.split('/').pop().split('?')[0];
img.style.opacity = '0.5';
const r = await fetch(`/goto_timestamp?sid=${sid}`, {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({filename:fname, timestamp:v})
});
const d = await r.json();
if(d.success) {
img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
document.getElementById('timestamp-input').value = '';
resetPanelTransform();
} else {
alert('Error: ' + d.message);
}
img.style.opacity = '1';
saveDraft();
}
// --- EXPORT ---
async function exportComic() {
const pgs = document.querySelectorAll('.comic-page');
if(pgs.length === 0) return alert("No pages found");
const bubbles = document.querySelectorAll('.speech-bubble');
bubbles.forEach(b => {
const rect = b.getBoundingClientRect();
b.style.width = rect.width + 'px';
b.style.height = rect.height + 'px';
});
alert(`Exporting ${pgs.length} page(s)...`);
for(let i = 0; i < pgs.length; i++) {
try {
const u = await htmlToImage.toPng(pgs[i], {pixelRatio: 3});
const a = document.createElement('a');
a.href = u;
a.download = `Comic-Page-${i+1}.png`;
a.click();
} catch(err) {
console.error(err);
alert(`Failed to export page ${i+1}`);
}
}
bubbles.forEach(b => {
b.style.width = '';
b.style.height = '';
});
}
// Helper
function rgbToHex(rgb) {
if (!rgb || !rgb.startsWith('rgb')) return '#ffffff';
let sep = rgb.indexOf(",") > -1 ? "," : " ";
rgb = rgb.substr(4).split(")")[0].split(sep);
let r = (+rgb[0]).toString(16), g = (+rgb[1]).toString(16), b = (+rgb[2]).toString(16);
if (r.length == 1) r = "0" + r;
if (g.length == 1) g = "0" + g;
if (b.length == 1) b = "0" + b;
return "#" + r + g + b;
}
</script>
</body>
</html>
'''
# --- 3. ENHANCED COMIC GENERATOR CLASS ---
class EnhancedComicGenerator:
def __init__(self, sid):
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')
self.metadata_path = os.path.join(self.frames_dir, 'frame_metadata.json')
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:
pass
def cleanup_previous_run(self):
print(f"🧹 Cleaning up for session {self.sid}...")
if os.path.exists(self.frames_dir):
for f in os.listdir(self.frames_dir):
try:
os.remove(os.path.join(self.frames_dir, f))
except:
pass
if os.path.exists(self.output_dir):
for f in os.listdir(self.output_dir):
if f != 'status.json':
try:
os.remove(os.path.join(self.output_dir, f))
except:
pass
user_srt = os.path.join(self.user_dir, 'subs.srt')
if os.path.exists(user_srt):
os.remove(user_srt)
print("✅ Cleanup complete.")
def generate_keyframes_from_moments(self, key_moments, max_frames=48):
try:
cap = cv2.VideoCapture(self.video_path)
if not cap.isOpened():
raise Exception("Cannot open video for keyframe extraction")
fps = self.video_fps
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
duration = total_frames / fps
key_moments.sort(key=lambda x: x['start'])
frame_metadata = {}
frame_count = 0
for i, moment in enumerate(key_moments[:max_frames]):
self.update_status(f"Extracting frame {i+1}/{min(len(key_moments), max_frames)}...",
25 + int(20 * (i / min(len(key_moments), max_frames))))
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()
if ret:
frame_filename = f"frame_{frame_count:04d}.png"
frame_path = os.path.join(self.frames_dir, frame_filename)
cv2.imwrite(frame_path, frame)
frame_metadata[frame_filename] = {
'time': frame_time,
'dialogue': moment['text'],
'start': moment['start'],
'end': moment['end']
}
frame_count += 1
cap.release()
with open(self.metadata_path, 'w') as f:
json.dump(frame_metadata, f, indent=2)
print(f"✅ Extracted {frame_count} keyframes from video")
return True
except Exception as e:
print(f"❌ Error extracting keyframes: {e}")
traceback.print_exc()
return False
def _enhance_all_images(self, single_image_path=None):
try:
enhancer = SimpleColorEnhancer()
if single_image_path:
enhancer.enhance_single(single_image_path)
else:
frame_paths = [os.path.join(self.frames_dir, f)
for f in os.listdir(self.frames_dir) if f.endswith('.png')]
with ThreadPoolExecutor() as executor:
list(executor.map(enhancer.enhance_single, frame_paths))
print("✅ Simple color enhancement complete")
except Exception as e:
print(f"⚠️ Simple enhancement failed: {e}")
def _enhance_quality_colors(self, single_image_path=None):
try:
enhancer = QualityColorEnhancer()
if single_image_path:
enhancer.enhance_single(single_image_path)
else:
frame_paths = [os.path.join(self.frames_dir, f)
for f in os.listdir(self.frames_dir) if f.endswith('.png')]
with ThreadPoolExecutor() as executor:
list(executor.map(enhancer.enhance_single, frame_paths))
print("✅ Quality color enhancement complete")
except Exception as e:
print(f"⚠️ Quality enhancement failed: {e}")
def _process_bubble_for_frame(self, frame_file):
frame_path = os.path.join(self.frames_dir, frame_file)
meta = self.frame_metadata.get(frame_file, {})
dialogue = meta.get('dialogue', '') if isinstance(meta, dict) else ''
try:
faces = face_detector.detect_faces(frame_path)
if faces:
lip_x, lip_y = face_detector.get_lip_position(frame_path, faces[0])
else:
lip_x, lip_y = -1, -1
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.")
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):
frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
if not os.path.exists(self.metadata_path):
return [bubble(dialog="") for _ in frame_files]
with open(self.metadata_path, 'r') as f:
self.frame_metadata = json.load(f)
with ThreadPoolExecutor() as executor:
bubbles = list(executor.map(self._process_bubble_for_frame, frame_files))
return bubbles
def _generate_pages(self, bubbles_list):
try:
from backend.fixed_12_pages_800x1080 import generate_12_pages_800x1080
frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
return generate_12_pages_800x1080(frame_files, bubbles_list)
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_list[start:end]
if page_panels:
pages.append(Page(panels=page_panels, bubbles=page_bubbles))
return pages
# --- APPLY ZEROGPU DECORATOR HERE ---
@gpu_task(duration=180) # Allocate GPU for 3 minutes for this task
def generate_comic(self):
start_time = time.time()
try:
if cv2 is None:
raise Exception("OpenCV not installed")
self.update_status("Cleaning up previous run...", 0)
self.cleanup_previous_run()
self.update_status("Analyzing video...", 5)
cap = cv2.VideoCapture(self.video_path)
if not cap.isOpened():
raise Exception("Cannot open video")
self.video_fps = cap.get(cv2.CAP_PROP_FPS) or 25
cap.release()
print(f"✅ Video FPS detected: {self.video_fps:.2f}")
self.update_status("Generating subtitles (this may take a while)...", 10)
user_srt = os.path.join(self.user_dir, 'subs.srt')
try:
get_real_subtitles(self.video_path)
if os.path.exists('test1.srt'):
shutil.move('test1.srt', user_srt)
except Exception as e:
print(f"⚠️ Subtitle generation failed: {e}. Creating fallback.")
with open(user_srt, 'w') as f:
f.write("1\n00:00:01,000 --> 00:00:04,000\nHello\n")
self.update_status("Parsing subtitles...", 20)
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]
self.update_status("Extracting keyframes...", 25)
if not self.generate_keyframes_from_moments(key_moments, max_frames=48):
raise Exception("Keyframe extraction failed")
self.update_status("Cropping black bars...", 45)
try:
black_x, black_y, _, _ = black_bar_crop()
except:
black_x, black_y = 0, 0
self.update_status("Enhancing images...", 50)
self._enhance_all_images()
self.update_status("Applying quality color enhancement...", 60)
self._enhance_quality_colors()
self.update_status("Placing speech bubbles...", 75)
bubbles = self._create_ai_bubbles_from_moments()
self.update_status("Assembling comic pages...", 90)
pages = self._generate_pages(bubbles)
self.update_status("Saving results...", 95)
self._save_results(pages)
execution_time = (time.time() - start_time) / 60
print(f"✅ Comic generation completed in {execution_time:.2f} minutes")
self.update_status("Complete!", 100)
return True
except Exception as e:
print(f"❌ Comic generation failed: {e}")
traceback.print_exc()
self.update_status(f"Error: {str(e)}", -1)
return False
def _save_results(self, pages):
try:
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)
print("✅ Results saved successfully!")
except Exception as e:
print(f"❌ Save results failed: {e}")
@gpu_task(duration=60)
def regenerate_frame(self, fname, direction):
try:
if not os.path.exists(self.metadata_path):
return {"success": False, "message": "Frame metadata missing."}
with open(self.metadata_path, 'r') as f:
meta = json.load(f)
if fname not in meta:
return {"success": False, "message": "Panel not linked to video."}
current_data = meta[fname]
if isinstance(current_data, dict):
curr_time = current_data['time']
else:
curr_time = current_data
if not self.video_fps:
cap = cv2.VideoCapture(self.video_path)
self.video_fps = cap.get(cv2.CAP_PROP_FPS) or 25
cap.release()
offset = (1.0 / self.video_fps) * (1 if direction == 'forward' else -1)
new_time = max(0, curr_time + offset)
cap = cv2.VideoCapture(self.video_path)
cap.set(cv2.CAP_PROP_POS_MSEC, new_time * 1000)
ret, frame = cap.read()
cap.release()
if ret:
frame_path = os.path.join(self.frames_dir, fname)
cv2.imwrite(frame_path, frame)
print(f"🎨 Applying enhancements to new frame: {fname}")
self._enhance_all_images(single_image_path=frame_path)
self._enhance_quality_colors(single_image_path=frame_path)
if isinstance(meta[fname], dict):
meta[fname]['time'] = new_time
else:
meta[fname] = new_time
with open(self.metadata_path, 'w') as f:
json.dump(meta, f, indent=2)
message = f"Adjusted {direction} to {new_time:.3f}s"
print(f"✅ {message}")
return {"success": True, "message": message}
return {"success": False, "message": "End of video"}
except Exception as e:
traceback.print_exc()
return {"success": False, "message": str(e)}
@gpu_task(duration=60)
def get_frame_at_timestamp(self, fname, ts):
try:
cap = cv2.VideoCapture(self.video_path)
if not cap.isOpened():
return {"success": False, "message": "Cannot open video."}
fps = cap.get(cv2.CAP_PROP_FPS) or 25
duration = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) / fps
if ts < 0 or ts > duration:
cap.release()
return {"success": False, "message": f"Timestamp must be between 0 and {duration:.2f}s."}
cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
ret, frame = cap.read()
cap.release()
if ret:
frame_path = os.path.join(self.frames_dir, fname)
cv2.imwrite(frame_path, frame)
print(f"🎨 Applying enhancements to frame from timestamp: {fname}")
self._enhance_all_images(single_image_path=frame_path)
self._enhance_quality_colors(single_image_path=frame_path)
if os.path.exists(self.metadata_path):
with open(self.metadata_path, 'r') as f:
meta = json.load(f)
if fname in meta:
if isinstance(meta[fname], dict):
meta[fname]['time'] = float(ts)
else:
meta[fname] = float(ts)
with open(self.metadata_path, 'w') as f:
json.dump(meta, f, indent=2)
message = f"Jumped to timestamp {ts:.3f}s"
print(f"✅ {message}")
return {"success": True, "message": message}
return {"success": False, "message": "Invalid time"}
except Exception as e:
traceback.print_exc()
return {"success": False, "message": str(e)}
# --- ROUTES ---
@app.route('/')
def index():
return INDEX_HTML
@app.route('/uploader', methods=['POST'])
def upload():
sid = request.args.get('sid')
if not sid:
return jsonify({'success': False, 'message': 'Missing session ID'}), 400
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']
gen = EnhancedComicGenerator(sid)
gen.cleanup_previous_run()
f.save(gen.video_path)
gen.update_status("Starting...", 5)
# We use a Thread to call the generated GPU function
threading.Thread(target=gen.generate_comic).start()
return jsonify({'success': True, 'message': 'Generation started.'})
@app.route('/status')
def get_status():
sid = request.args.get('sid')
if not sid:
return jsonify({'progress': 0, 'message': 'Missing session ID'})
path = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
if os.path.exists(path):
return send_file(path)
return jsonify({'progress': 0, 'message': "Waiting..."})
@app.route('/output/<path:filename>')
def get_output(filename):
sid = request.args.get('sid')
if not sid:
return "Missing session ID", 400
return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
@app.route('/frames/<path:filename>')
def get_frame(filename):
sid = request.args.get('sid')
if not sid:
return "Missing session ID", 400
return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
@app.route('/regenerate_frame', methods=['POST'])
def regen():
sid = request.args.get('sid')
if not sid:
return jsonify({'success': False, 'message': 'Missing session ID'})
d = request.get_json()
gen = EnhancedComicGenerator(sid)
# This might use ZeroGPU if configured
return jsonify(gen.regenerate_frame(d['filename'], d['direction']))
@app.route('/goto_timestamp', methods=['POST'])
def go_time():
sid = request.args.get('sid')
if not sid:
return jsonify({'success': False, 'message': 'Missing session ID'})
d = request.get_json()
gen = EnhancedComicGenerator(sid)
return jsonify(gen.get_frame_at_timestamp(d['filename'], float(d['timestamp'])))
@app.route('/replace_panel', methods=['POST'])
def rep_panel():
sid = request.args.get('sid')
if not sid:
return jsonify({'success': False, 'error': 'Missing session ID'})
if 'image' not in request.files:
return jsonify({'success': False, 'error': 'No image provided.'})
f = request.files['image']
frames_dir = os.path.join(BASE_USER_DIR, sid, 'frames')
os.makedirs(frames_dir, exist_ok=True)
fname = f"replaced_{int(time.time() * 1000)}.png"
f.save(os.path.join(frames_dir, fname))
return jsonify({'success': True, 'new_filename': fname})
# --- SAVE COMIC ENDPOINT ---
@app.route('/save_comic', methods=['POST'])
def save_comic():
sid = request.args.get('sid')
if not sid:
return jsonify({'success': False, 'message': 'Missing session ID'})
try:
data = request.get_json()
# Generate unique save code
save_code = generate_save_code()
save_dir = os.path.join(SAVED_COMICS_DIR, save_code)
os.makedirs(save_dir, exist_ok=True)
# Copy frames from user directory to saved directory
user_frames_dir = os.path.join(BASE_USER_DIR, sid, 'frames')
saved_frames_dir = os.path.join(save_dir, 'frames')
if os.path.exists(user_frames_dir):
if os.path.exists(saved_frames_dir):
shutil.rmtree(saved_frames_dir)
shutil.copytree(user_frames_dir, saved_frames_dir)
# Save the comic state
save_data = {
'code': save_code,
'originalSid': sid,
'pages': data.get('pages', []),
'savedAt': data.get('savedAt', time.strftime('%Y-%m-%d %H:%M:%S'))
}
with open(os.path.join(save_dir, 'comic_state.json'), 'w') as f:
json.dump(save_data, f, indent=2)
print(f"✅ Comic saved with code: {save_code}")
return jsonify({'success': True, 'code': save_code})
except Exception as e:
traceback.print_exc()
return jsonify({'success': False, 'message': str(e)})
# --- LOAD COMIC ENDPOINT ---
@app.route('/load_comic/<code>')
def load_comic(code):
code = code.upper()
save_dir = os.path.join(SAVED_COMICS_DIR, code)
state_file = os.path.join(save_dir, 'comic_state.json')
if not os.path.exists(state_file):
return jsonify({'success': False, 'message': 'Save code not found'})
try:
with open(state_file, 'r') as f:
save_data = json.load(f)
original_sid = save_data.get('originalSid')
# Copy frames to user directory if needed
saved_frames_dir = os.path.join(save_dir, 'frames')
if original_sid and os.path.exists(saved_frames_dir):
user_frames_dir = os.path.join(BASE_USER_DIR, original_sid, 'frames')
os.makedirs(user_frames_dir, exist_ok=True)
# Copy files that don't exist
for fname in os.listdir(saved_frames_dir):
src = os.path.join(saved_frames_dir, fname)
dst = os.path.join(user_frames_dir, fname)
if not os.path.exists(dst):
shutil.copy2(src, dst)
return jsonify({
'success': True,
'pages': save_data.get('pages', []),
'originalSid': original_sid,
'savedAt': save_data.get('savedAt')
})
except Exception as e:
traceback.print_exc()
return jsonify({'success': False, 'message': str(e)})
# --- SERVE SAVED COMIC FRAMES ---
@app.route('/saved_frames/<code>/<path:filename>')
def get_saved_frame(code, filename):
code = code.upper()
frames_dir = os.path.join(SAVED_COMICS_DIR, code, 'frames')
if os.path.exists(os.path.join(frames_dir, filename)):
return send_from_directory(frames_dir, filename)
return "Frame not found", 404
if __name__ == '__main__':
# HF Spaces use port 7860 by default
port = int(os.getenv("PORT", 7860))
print(f"🚀 Starting Enhanced Comic Generator on host 0.0.0.0, port {port}")
print(f"📁 User data directory: {BASE_USER_DIR}")
print(f"💾 Saved comics directory: {SAVED_COMICS_DIR}")
app.run(host='0.0.0.0', port=port, debug=False)