Test / app_enhanced.py
tester343's picture
Update app_enhanced.py
fb7922b verified
import spaces # <--- CRITICAL: MUST BE THE FIRST IMPORT
import os
import time
import threading
import json
import traceback
import logging
import string
import random
import shutil
import cv2
import math
import numpy as np
import srt
from flask import Flask, jsonify, request, send_from_directory, send_file
# ======================================================
# 🚀 ZEROGPU CONFIGURATION
# ======================================================
@spaces.GPU
def gpu_warmup():
import torch
print(f"✅ ZeroGPU Warmup: CUDA Available: {torch.cuda.is_available()}")
return True
# ======================================================
# 💾 PERSISTENT STORAGE CONFIGURATION
# ======================================================
# Checks for Hugging Face Persistent Storage
if os.path.exists('/data'):
BASE_STORAGE_PATH = '/data'
print("✅ Using Persistent Storage at /data (Files saved for days/weeks)")
else:
BASE_STORAGE_PATH = '.'
print("⚠️ Using Ephemeral/Local Storage (Files lost on restart)")
BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
SAVED_COMICS_DIR = os.path.join(BASE_STORAGE_PATH, "saved_comics")
os.makedirs(BASE_USER_DIR, exist_ok=True)
os.makedirs(SAVED_COMICS_DIR, exist_ok=True)
# ======================================================
# 🧱 DATA CLASSES
# ======================================================
def bubble(dialog="", bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, emotion='normal', type='speech'):
return {
'dialog': dialog,
'bubble_offset_x': int(bubble_offset_x),
'bubble_offset_y': int(bubble_offset_y),
'lip_x': int(lip_x),
'lip_y': int(lip_y),
'emotion': emotion,
'type': type,
'tail_pos': '50%',
'classes': f'speech-bubble {type} tail-bottom'
}
def panel(image=""):
return {'image': image}
class Page:
def __init__(self, panels, bubbles):
self.panels = panels
self.bubbles = bubbles
# ======================================================
# 🔧 APP CONFIG
# ======================================================
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = Flask(__name__)
def generate_save_code(length=8):
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
# ======================================================
# 🧠 GLOBAL GPU FUNCTIONS
# ======================================================
@spaces.GPU(duration=300)
def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
print(f"🚀 GPU Task Started: {video_path} | Pages: {target_pages}")
import cv2
import srt
import numpy as np
from backend.keyframes.keyframes import black_bar_crop
from backend.simple_color_enhancer import SimpleColorEnhancer
from backend.quality_color_enhancer import QualityColorEnhancer
from backend.subtitles.subs_real import get_real_subtitles
from backend.ai_bubble_placement import ai_bubble_placer
from backend.ai_enhanced_core import face_detector
cap = cv2.VideoCapture(video_path)
if not cap.isOpened(): raise Exception("Cannot open video")
fps = cap.get(cv2.CAP_PROP_FPS) or 25
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
duration = total_frames / fps
cap.release()
user_srt = os.path.join(user_dir, 'subs.srt')
try:
get_real_subtitles(video_path)
if os.path.exists('test1.srt'):
shutil.move('test1.srt', user_srt)
except:
with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
with open(user_srt, 'r', encoding='utf-8') as f:
try: all_subs = list(srt.parse(f.read()))
except: all_subs = []
valid_subs = [s for s in all_subs if s.content.strip()]
raw_moments = [{'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
if target_pages <= 0: target_pages = 1
panels_per_page = 4
total_panels_needed = target_pages * panels_per_page
selected_moments = []
if not raw_moments:
times = np.linspace(1, duration-1, total_panels_needed)
for t in times: selected_moments.append({'text': '', 'start': t, 'end': t+1})
elif len(raw_moments) <= total_panels_needed:
selected_moments = raw_moments
else:
indices = np.linspace(0, len(raw_moments) - 1, total_panels_needed, dtype=int)
selected_moments = [raw_moments[i] for i in indices]
frame_metadata = {}
cap = cv2.VideoCapture(video_path)
count = 0
frame_files_ordered = []
for i, moment in enumerate(selected_moments):
mid = (moment['start'] + moment['end']) / 2
if mid > duration: mid = duration - 1
cap.set(cv2.CAP_PROP_POS_FRAMES, int(mid * fps))
ret, frame = cap.read()
if ret:
fname = f"frame_{count:04d}.png"
p = os.path.join(frames_dir, fname)
cv2.imwrite(p, frame)
os.sync()
frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
frame_files_ordered.append(fname)
count += 1
cap.release()
with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
try: black_bar_crop()
except: pass
se = SimpleColorEnhancer()
qe = QualityColorEnhancer()
for f in frame_files_ordered:
p = os.path.join(frames_dir, f)
try: se.enhance_single(p, p)
except: pass
try: qe.enhance_single(p, p)
except: pass
bubbles_list = []
for f in frame_files_ordered:
p = os.path.join(frames_dir, f)
dialogue = frame_metadata.get(f, {}).get('dialogue', '')
b_type = 'speech'
if '(' in dialogue and ')' in dialogue: b_type = 'narration'
elif '!' in dialogue and dialogue.isupper(): b_type = 'reaction'
elif '?' in dialogue: b_type = 'speech'
try:
faces = face_detector.detect_faces(p)
lip = face_detector.get_lip_position(p, faces[0]) if faces else (-1, -1)
bx, by = ai_bubble_placer.place_bubble_ai(p, lip)
b = bubble(dialog=dialogue, bubble_offset_x=bx, bubble_offset_y=by, lip_x=lip[0], lip_y=lip[1], type=b_type)
bubbles_list.append(b)
except:
bubbles_list.append(bubble(dialog=dialogue, type=b_type))
pages = []
for i in range(target_pages):
start_idx = i * 4
end_idx = start_idx + 4
p_frames = frame_files_ordered[start_idx:end_idx]
p_bubbles = bubbles_list[start_idx:end_idx]
if p_frames:
pg_panels = [panel(image=f) for f in p_frames]
pages.append(Page(panels=pg_panels, bubbles=p_bubbles))
result = []
for pg in pages:
p_data = [p if isinstance(p, dict) else p.__dict__ for p in pg.panels]
b_data = [b if isinstance(b, dict) else b.__dict__ for b in pg.bubbles]
result.append({'panels': p_data, 'bubbles': b_data})
return result
@spaces.GPU
def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
import cv2
import json
from backend.simple_color_enhancer import SimpleColorEnhancer
if not os.path.exists(metadata_path): return {"success": False, "message": "No metadata"}
with open(metadata_path, 'r') as f: meta = json.load(f)
if fname not in meta: return {"success": False, "message": "Frame not found"}
t = meta[fname]['time'] if isinstance(meta[fname], dict) else meta[fname]
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS) or 25
offset = (1.0/fps) * (1 if direction == 'forward' else -1)
new_t = max(0, t + offset)
cap.set(cv2.CAP_PROP_POS_MSEC, new_t * 1000)
ret, frame = cap.read()
cap.release()
if ret:
p = os.path.join(frames_dir, fname)
cv2.imwrite(p, frame)
os.sync()
try: SimpleColorEnhancer().enhance_single(p, p)
except: pass
if isinstance(meta[fname], dict): meta[fname]['time'] = new_t
else: meta[fname] = new_t
with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
return {"success": True, "message": f"Adjusted to {new_t:.2f}s"}
return {"success": False, "message": "End of video"}
@spaces.GPU
def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
import cv2
import json
from backend.simple_color_enhancer import SimpleColorEnhancer
cap = cv2.VideoCapture(video_path)
cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
ret, frame = cap.read()
cap.release()
if ret:
p = os.path.join(frames_dir, fname)
cv2.imwrite(p, frame)
os.sync()
try: SimpleColorEnhancer().enhance_single(p, p)
except: pass
if os.path.exists(metadata_path):
with open(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(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
return {"success": True, "message": f"Jumped to {ts}s"}
return {"success": False, "message": "Invalid timestamp"}
# ======================================================
# 💻 BACKEND 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')
os.makedirs(self.frames_dir, exist_ok=True)
os.makedirs(self.output_dir, exist_ok=True)
self.metadata_path = os.path.join(self.frames_dir, 'frame_metadata.json')
def cleanup(self):
if os.path.exists(self.frames_dir): shutil.rmtree(self.frames_dir)
if os.path.exists(self.output_dir): shutil.rmtree(self.output_dir)
os.makedirs(self.frames_dir, exist_ok=True)
os.makedirs(self.output_dir, exist_ok=True)
def run(self, target_pages):
try:
self.write_status("Waiting for GPU...", 5)
data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, int(target_pages))
with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
json.dump(data, f, indent=2)
self.write_status("Complete!", 100)
except Exception as e:
traceback.print_exc()
self.write_status(f"Error: {str(e)}", -1)
def write_status(self, msg, prog):
with open(os.path.join(self.output_dir, 'status.json'), 'w') as f:
json.dump({'message': msg, 'progress': prog}, f)
# ======================================================
# 🌐 ROUTES & FULL UI
# ======================================================
INDEX_HTML = '''
<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>🎬 Enhanced 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> * { box-sizing: border-box; } body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
#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-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 100px; }
h1 { color: #2c3e50; margin-bottom: 20px; font-weight: 600; }
.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; }
.page-input-group { margin: 20px 0; text-align: left; }
.page-input-group label { font-weight: bold; font-size: 14px; display: block; margin-bottom: 5px; color: #333; }
.page-input-group input { width: 100%; padding: 12px; border: 2px solid #ddd; border-radius: 8px; font-size: 16px; box-sizing: border-box; }
.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; }
.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; }
.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 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 BUBBLES */
.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;
/* FIX FOR TAIL VISIBILITY */
overflow: visible;
line-height: 1.2;
--tail-pos: 50%;
}
/* FIX FOR 0% ERROR EXPORT: Handle overflow and clipping here */
.bubble-text {
padding: 0.5em;
word-wrap: break-word;
white-space: pre-wrap;
position: relative;
z-index: 5;
pointer-events: none;
user-select: none;
width: 100%;
height: 100%;
/* Strict overflow hidden to match editor vs export */
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
/* Ensure border radius matches parent so text doesn't leak corners */
border-radius: inherit;
}
.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; white-space: pre-wrap; }
/* SPEECH BUBBLE CSS (Tails) */
.speech-bubble.speech {
--b: 3em; --h: 1.8em; --t: 0.6; --p: var(--tail-pos, 50%); --r: 1.2em;
background: var(--bubble-fill-color, #4ECDC4);
color: var(--bubble-text-color, #fff);
padding: 0;
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-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; }
/* THOUGHT BUBBLE CSS (Fixed Rotation) */
.speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
.speech-bubble.thought::before { 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; }
.thought-dot-2 { width: 12px; height: 12px; }
/* Thought Tail Positions */
.speech-bubble.thought.pos-bl .thought-dot-1 { left: 20px; bottom: -20px; }
.speech-bubble.thought.pos-bl .thought-dot-2 { left: 10px; bottom: -32px; }
.speech-bubble.thought.pos-br .thought-dot-1 { right: 20px; bottom: -20px; }
.speech-bubble.thought.pos-br .thought-dot-2 { right: 10px; bottom: -32px; }
.speech-bubble.thought.pos-tr .thought-dot-1 { right: 20px; top: -20px; }
.speech-bubble.thought.pos-tr .thought-dot-2 { right: 10px; top: -32px; }
.speech-bubble.thought.pos-tl .thought-dot-1 { left: 20px; top: -20px; }
.speech-bubble.thought.pos-tl .thought-dot-2 { left: 10px; top: -32px; }
.speech-bubble.reaction { background: #FFD700; border: 3px solid #E53935; color: #D32F2F; font-weight: 900; text-transform: uppercase; 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; }
.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; }
/* CONTROLS */
.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-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
.color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
.slider-container { display: flex; align-items: center; gap: 5px; margin-top: 5px; }
.slider-container label { min-width: 40px; font-size: 11px; }
.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; }
.undo-btn { background: #7f8c8d; color: white; margin-bottom: 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 .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 button { background: #3498db; color: white; border: none; padding: 12px 30px; border-radius: 8px; cursor: pointer; font-weight: bold; margin: 5px; }
</style>
</head> <body> <div id="upload-container"> <div class="upload-box"> <h1>🎬 Enhanced Comic Generator</h1> <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>
<div class="page-input-group">
<label>📚 Total Comic Pages:</label>
<input type="number" id="page-count" value="4" min="1" max="15" placeholder="e.g. 4 (Video will be divided evenly)">
<small style="color:#666; font-size:11px; display:block; margin-top:5px;">System calculates ~4 panels per page.</small>
</div>
<button class="submit-btn" onclick="upload()">🚀 Generate Comic</button>
<button id="restore-draft-btn" class="restore-btn" style="display:none; margin-top:15px;" onclick="restoreDraft()">📂 Restore Unsaved Draft</button>
<div class="load-section">
<h3>📥 Load Saved Comic</h3>
<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>
<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>
<button onclick="undoLastAction()" class="undo-btn">↩️ Undo</button>
<div class="control-group">
<label>💾 Save & Load:</label>
<button onclick="saveComic()" class="save-btn">💾 Save Comic</button>
<div id="current-save-code" style="display:none; margin-top:5px; text-align:center;">
<span id="display-save-code" style="font-weight:bold; background:#eee; padding:2px 5px; border-radius:3px;"></span>
<button onclick="copyCode()" style="padding:2px; width:auto; font-size:10px;">Copy</button>
</div>
</div>
<div class="control-group">
<label>💬 Bubble Styling:</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 (Shout)</option>
<option value="narration">Narration (Box)</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>
<div class="button-grid">
<button onclick="addBubble()" class="action-btn">Add</button>
<button onclick="deleteBubble()" class="reset-btn">Delete</button>
</div>
</div>
<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>
<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" id="prev-btn">⬅️ Prev</button>
<button onclick="adjustFrame('forward')" class="action-btn" id="next-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" id="go-btn">Go</button>
</div>
</div>
<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>
<div class="control-group">
<button onclick="exportComic()" class="export-btn">📥 Export as PNG</button>
<button onclick="goBackToUpload()" class="reset-btn" style="margin-top:10px;">🏠 Home</button>
</div>
</div>
</div>
<div class="modal-overlay" id="save-modal">
<div class="modal-content">
<h2>✅ Comic Saved!</h2>
<div class="code" id="modal-save-code">XXXXXXXX</div>
<button onclick="copyModalCode()">📋 Copy Code</button>
<button class="close-btn" onclick="closeModal()">Close</button>
</div>
</div>
<script>
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);
let currentSaveCode = null;
let isProcessing = false;
let interval, selectedBubble = null, selectedPanel = null;
let isDragging = false, isResizing = false, isPanning = false;
let startX, startY, initX, initY, panStartX, panStartY, panStartTx, panStartTy;
let resizeHandle, originalWidth, originalHeight, originalMouseX, originalMouseY;
let currentlyEditing = null;
// UNDO SYSTEM
let historyStack = [];
let historyIndex = -1;
function addToHistory() {
if (historyIndex < historyStack.length - 1) {
historyStack = historyStack.slice(0, historyIndex + 1);
}
const state = JSON.stringify(getCurrentState());
if (historyStack.length > 0 && historyStack[historyStack.length - 1] === state) return;
historyStack.push(state);
historyIndex++;
if (historyStack.length > 30) {
historyStack.shift();
historyIndex--;
}
}
function undoLastAction() {
if (historyIndex > 0) {
historyIndex--;
const previousState = JSON.parse(historyStack[historyIndex]);
renderFromState(previousState);
saveDraft(false);
}
}
if(localStorage.getItem('comic_draft_'+sid)) document.getElementById('restore-draft-btn').style.display = 'block';
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() { navigator.clipboard.writeText(document.getElementById('modal-save-code').textContent).then(() => alert('Code copied!')); }
function copyCode() { if(currentSaveCode) navigator.clipboard.writeText(currentSaveCode).then(() => alert('Code copied!')); }
function setProcessing(busy) {
isProcessing = busy;
const btns = ['prev-btn', 'next-btn', 'go-btn'];
btns.forEach(id => {
const el = document.getElementById(id);
if(el) { el.disabled = busy; el.style.opacity = busy ? '0.5' : '1'; el.innerText = busy ? '⏳' : el.getAttribute('data-txt') || el.innerText; }
});
}
async function saveComic() {
const state = getCurrentState();
if(!state || state.length === 0) { alert('No comic to save!'); return; }
try {
const r = await fetch(`/save_comic?sid=${sid}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ pages: state, savedAt: new Date().toISOString() }) });
const d = await r.json();
if(d.success) { currentSaveCode = d.code; document.getElementById('display-save-code').textContent = d.code; document.getElementById('current-save-code').style.display = 'block'; showSaveModal(d.code); saveDraft(false); }
else { alert('Failed to save: ' + d.message); }
} catch(e) { console.error(e); alert('Error saving comic'); }
}
async function loadSavedComic() {
const code = document.getElementById('load-code-input').value.trim().toUpperCase();
if(!code || code.length < 4) { alert('Invalid code'); return; }
try {
const r = await fetch(`/load_comic/${code}`);
const d = await r.json();
if(d.success) { currentSaveCode = code; sid = d.originalSid || sid; localStorage.setItem('comic_sid', sid); renderFromState(d.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(true); }
else { alert('Load failed: ' + d.message); }
} catch(e) { console.error(e); alert('Error loading comic.'); }
}
function restoreDraft() {
try {
const state = JSON.parse(localStorage.getItem('comic_draft_'+sid));
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';
addToHistory();
} catch(e) { console.error(e); alert("Failed to restore."); }
}
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.replace(' selected', ''),
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;
}
function saveDraft(recordHistory = true) {
if(recordHistory) addToHistory();
localStorage.setItem('comic_draft_'+sid, JSON.stringify({ pages: getCurrentState(), saveCode: currentSaveCode, savedAt: new Date().toISOString() }));
}
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.includes('?') ? pan.src : pan.src + `?sid=${sid}`;
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 => { pDiv.appendChild(createBubbleHTML(bData)); });
grid.appendChild(pDiv);
});
div.appendChild(grid); pageWrapper.appendChild(div); con.appendChild(pageWrapper);
});
selectedBubble = null;
selectedPanel = null;
document.getElementById('bubble-type-select').disabled = true;
document.getElementById('font-select').disabled = true;
}
async function upload() {
const f = document.getElementById('file-upload').files[0];
const pCount = document.getElementById('page-count').value;
if(!f) return alert("Select a video");
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); fd.append('target_pages', pCount);
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: (p.bubbles[j].type || 'speech'),
classes: `speech-bubble ${p.bubbles[j].type || 'speech'} tail-bottom`
}] : []
}))
}));
renderFromState(cleanData); saveDraft(true);
});
}
function createBubbleHTML(data) {
const b = document.createElement('div');
const type = data.type || 'speech';
b.className = data.classes || `speech-bubble ${type} tail-bottom`;
if (type === 'thought' && !b.className.includes('pos-')) b.className += ' pos-bl';
b.dataset.type = type;
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);
if(type === 'thought') { for(let i=1; i<=2; i++){ const d = document.createElement('div'); d.className = `thought-dot thought-dot-${i}`; b.appendChild(d); } }
['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.onclick = (e) => { e.stopPropagation(); };
b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
return b;
}
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(true);
};
textarea.addEventListener('blur', finishEditing, { once: true });
textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); } });
}
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(true);
}
isDragging = false; isResizing = false; isPanning = false;
});
function startResize(e, dir) { e.preventDefault(); e.stopPropagation(); isResizing = true; resizeHandle = dir; const rect = selectedBubble.getBoundingClientRect(); originalWidth = rect.width; originalHeight = rect.height; 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('s')) selectedBubble.style.height = (originalHeight + dy)+'px'; }
function selectBubble(el) {
if(selectedBubble) selectedBubble.classList.remove('selected');
if(selectedPanel) { selectedPanel.classList.remove('selected'); selectedPanel = null; }
selectedBubble = el; el.classList.add('selected');
document.getElementById('bubble-type-select').disabled = false;
document.getElementById('font-select').disabled = false;
document.getElementById('bubble-text-color').disabled = false;
document.getElementById('bubble-fill-color').disabled = false;
document.getElementById('tail-controls').style.display = 'block';
document.getElementById('bubble-type-select').value = el.dataset.type || 'speech';
}
function selectPanel(el) {
if(selectedPanel) selectedPanel.classList.remove('selected');
if(selectedBubble) { selectedBubble.classList.remove('selected'); selectedBubble = null; }
selectedPanel = el; el.classList.add('selected');
document.getElementById('zoom-slider').disabled = false;
const img = el.querySelector('img');
document.getElementById('zoom-slider').value = img.dataset.zoom || 100;
document.getElementById('bubble-type-select').disabled = true;
document.getElementById('font-select').disabled = true;
document.getElementById('tail-controls').style.display = 'none';
}
function addBubble() {
if(!selectedPanel) return alert("Select a panel first");
const b = createBubbleHTML({ text: "Text", left: "50px", top: "30px", type: 'speech', classes: "speech-bubble speech tail-bottom" });
selectedPanel.appendChild(b); selectBubble(b); saveDraft(true);
}
function deleteBubble() {
if(!selectedBubble) return alert("Select a bubble");
selectedBubble.remove(); selectedBubble=null; saveDraft(true);
}
function changeBubbleType(type) {
if(!selectedBubble) return;
selectedBubble.dataset.type = type;
selectedBubble.className = 'speech-bubble ' + type + ' selected';
if(type === 'thought') selectedBubble.classList.add('pos-bl');
else selectedBubble.classList.add('tail-bottom');
selectedBubble.querySelectorAll('.thought-dot').forEach(d=>d.remove());
if(type === 'thought') { for(let i=1; i<=2; i++){ const d = document.createElement('div'); d.className = `thought-dot thought-dot-${i}`; selectedBubble.appendChild(d); } }
saveDraft(true);
}
function changeFont(font) { if(!selectedBubble) return; selectedBubble.style.fontFamily = font; saveDraft(true); }
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 positions = ['pos-bl', 'pos-br', 'pos-tr', 'pos-tl'];
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]);
}
saveDraft(true);
}
function slideTail(v) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', v+'%'); saveDraft(true); } }
document.getElementById('bubble-text-color').addEventListener('change', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-text-color', e.target.value); saveDraft(true); } });
document.getElementById('bubble-fill-color').addEventListener('change', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-fill-color', e.target.value); saveDraft(true); } });
function handleZoom(el) { if(!selectedPanel) return; const img = selectedPanel.querySelector('img'); img.dataset.zoom = el.value; updateImageTransform(img); }
document.getElementById('zoom-slider').addEventListener('change', () => saveDraft(true));
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"); 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(true); }
function replacePanelImage() { if(!selectedPanel) return alert("Select a panel"); 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'); 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}`; saveDraft(true); } inp.value = ''; }; inp.click(); }
async function adjustFrame(dir) { if(isProcessing) return; if(!selectedPanel) return alert("Select a panel"); const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0]; setProcessing(true); img.style.opacity = '0.5'; try { 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); } } catch(e) { console.error(e); } img.style.opacity = '1'; setProcessing(false); saveDraft(true); }
async function gotoTimestamp() { if(isProcessing) return; if(!selectedPanel) return alert("Select a panel"); 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"); const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0]; setProcessing(true); img.style.opacity = '0.5'; try { 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 = ''; } else { alert('Error: ' + d.message); } } catch(e) { console.error(e); } img.style.opacity = '1'; setProcessing(false); saveDraft(true); }
async function exportComic() {
const pgs = document.querySelectorAll('.comic-page');
if(pgs.length === 0) return alert("No pages found");
// Remove selection highlights
if(selectedBubble) selectedBubble.classList.remove('selected');
if(selectedPanel) selectedPanel.classList.remove('selected');
alert(`Exporting ${pgs.length} page(s)...`);
// --- 0% ERROR FIX ---
// 1. Lock specific pixel dimensions + 1px buffer to prevent word wrapping
const bubbles = document.querySelectorAll('.speech-bubble');
bubbles.forEach(b => {
const rect = b.getBoundingClientRect();
// Add slight buffer (1px) to width to handle sub-pixel rendering differences
// This prevents "just fitting" words from wrapping in the export
b.style.width = (rect.width + 1) + 'px';
b.style.height = rect.height + 'px';
b.style.display = 'flex';
b.style.alignItems = 'center';
b.style.justifyContent = 'center';
});
for(let i = 0; i < pgs.length; i++) {
try {
const u = await htmlToImage.toPng(pgs[i], {
pixelRatio: 2, // High quality
style: { transform: 'none' }
});
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}`);
}
}
// Optional: Reload page or reset styles if user wants to continue editing immediately
// In this app, styles are locked until reload, which is safer for the export focus.
}
function goBackToUpload() { if(confirm('Go home? Unsaved changes will be lost.')) { document.getElementById('editor-container').style.display = 'none'; document.getElementById('upload-container').style.display = 'flex'; document.getElementById('loading-view').style.display = 'none'; } }
</script>
</body> </html> '''
@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
# GET PAGE COUNT FROM FORM
target_pages = request.form.get('target_pages', 4)
f = request.files['file']
gen = EnhancedComicGenerator(sid)
gen.cleanup()
f.save(gen.video_path)
gen.write_status("Starting...", 5)
# Run in thread
threading.Thread(target=gen.run, args=(target_pages,)).start()
return jsonify({'success': True, 'message': 'Generation started.'})
@app.route('/status')
def get_status():
sid = request.args.get('sid')
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')
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')
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')
d = request.get_json()
gen = EnhancedComicGenerator(sid)
return jsonify(regen_frame_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], d['direction']))
@app.route('/goto_timestamp', methods=['POST'])
def go_time():
sid = request.args.get('sid')
d = request.get_json()
gen = EnhancedComicGenerator(sid)
return jsonify(get_frame_at_ts_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], float(d['timestamp'])))
@app.route('/replace_panel', methods=['POST'])
def rep_panel():
sid = request.args.get('sid')
if 'image' not in request.files: return jsonify({'success': False, 'error': 'No image'})
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})
@app.route('/save_comic', methods=['POST'])
def save_comic():
sid = request.args.get('sid')
try:
data = request.get_json()
save_code = generate_save_code()
save_dir = os.path.join(SAVED_COMICS_DIR, save_code)
os.makedirs(save_dir, exist_ok=True)
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_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)
return jsonify({'success': True, 'code': save_code})
except Exception as e:
traceback.print_exc()
return jsonify({'success': False, 'message': str(e)})
@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')
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)
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)})
if __name__ == '__main__':
try: gpu_warmup()
except: pass
app.run(host='0.0.0.0', port=7860)