password / app_enhanced.py
tester343's picture
Update app_enhanced.py
02dcf01 verified
import os
import time
import threading
import json
import traceback
import string
import random
import shutil
import cv2
import numpy as np
import gc
import srt
import warnings
import subprocess
import whisper
from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import Flask, jsonify, request, send_from_directory, send_file
# Filter annoying CPU warnings
warnings.filterwarnings("ignore")
# ======================================================
# 💾 STORAGE SETUP
# ======================================================
if os.path.exists('/data'):
BASE_STORAGE_PATH = '/data'
print("✅ Using Persistent Storage at /data")
else:
BASE_STORAGE_PATH = '.'
print("⚠️ Using Ephemeral Storage")
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)
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024 * 1024 # 2GB Limit
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
# ======================================================
# 🧱 DATA CLASSES
# ======================================================
def bubble(dialog="", x=50, y=20, type='speech'):
classes = f"speech-bubble {type}"
if type == 'speech':
classes += " tail-bottom"
elif type == 'thought':
classes += " pos-bl"
elif type == 'reaction':
classes += " tail-bottom"
return {
'dialog': dialog,
'bubble_offset_x': int(x),
'bubble_offset_y': int(y),
'type': type,
'tail_pos': '50%',
'classes': classes,
'colors': {'fill': '#ffffff', 'text': '#000000'},
'font': "'Comic Neue', cursive",
'font_size': '16px'
}
def panel(image="", time=0.0):
return {'image': image, 'time': time}
class Page:
def __init__(self, panels, bubbles):
self.panels = panels
self.bubbles = bubbles
# ======================================================
# 🧠 HIGH QUALITY GENERATION LOGIC
# ======================================================
def resize_contain_hq(image, target_w=1080, target_h=1080):
""" 1:1 Square Ratio Resize (Instagram Style) """
h, w = image.shape[:2]
scale = min(target_w / w, target_h / h)
new_w = int(w * scale)
new_h = int(h * scale)
# Lanczos4 for sharpness
resized = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4)
canvas = np.zeros((target_h, target_w, 3), dtype=np.uint8)
# Center
x_offset = (target_w - new_w) // 2
y_offset = (target_h - new_h) // 2
canvas[y_offset:y_offset+new_h, x_offset:x_offset+new_w] = resized
return canvas
def generate_subtitles_cpu(video_path, srt_path, status_callback=None):
try:
if status_callback: status_callback("Extracting Audio...", 5)
audio_path = video_path.replace(".mp4", ".wav")
try:
# -preset ultrafast for speed
command = ["ffmpeg", "-y", "-i", video_path, "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", "-threads", "4", "-preset", "ultrafast", audio_path]
subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
except: return False
if status_callback: status_callback("Loading AI...", 15)
model = whisper.load_model("tiny", device="cpu")
if status_callback: status_callback("Transcribing...", 25)
result = model.transcribe(audio_path, fp16=False, language='en')
with open(srt_path, 'w', encoding='utf-8') as f:
for i, segment in enumerate(result['segments']):
start, end, text = segment['start'], segment['end'], segment['text'].strip()
def fmt(t): return f"{int(t//3600):02d}:{int((t%3600)//60):02d}:{int(t%60):02d},{int((t%1)*1000):03d}"
f.write(f"{i+1}\n{fmt(start)} --> {fmt(end)}\n{text}\n\n")
if os.path.exists(audio_path): os.remove(audio_path)
return True
except: return False
def extract_frame_task(args):
video_path, time_sec, output_path, width, height = args
try:
cap = cv2.VideoCapture(video_path)
cap.set(cv2.CAP_PROP_POS_MSEC, time_sec * 1000)
ret, frame = cap.read()
cap.release()
if ret:
final = resize_contain_hq(frame, width, height)
cv2.imwrite(output_path, final, [cv2.IMWRITE_PNG_COMPRESSION, 3])
return True
return False
except: return False
def generate_comic_smart(video_path, user_dir, frames_dir, metadata_path, target_pages, status_callback=None):
WORKER_COUNT = 4
if status_callback: status_callback("Analyzing Video...", 5)
cap = cv2.VideoCapture(video_path)
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')
has_text = generate_subtitles_cpu(video_path, user_srt, status_callback)
if status_callback: status_callback("Planning Pages...", 40)
raw_moments = []
if has_text and os.path.exists(user_srt):
with open(user_srt, 'r', encoding='utf-8') as f:
try:
for s in list(srt.parse(f.read())):
if s.content.strip(): raw_moments.append({'text': s.content.strip(), 'mid': (s.start.total_seconds() + s.end.total_seconds())/2})
except: pass
total_panels = int(target_pages) * 4
selected_moments = []
if len(raw_moments) > 0:
indices = np.linspace(0, len(raw_moments) - 1, total_panels, dtype=int)
for i in indices: selected_moments.append(raw_moments[i])
else:
times = np.linspace(1, max(1, duration-1), total_panels)
for t in times: selected_moments.append({'text': "...", 'mid': t})
frame_metadata = {}
frame_files = []
tasks = []
EXTRACT_W, EXTRACT_H = 1080, 1080
count = 0
for m in selected_moments:
fname = f"frame_{count:04d}.png"
tasks.append((video_path, m['mid'], os.path.join(frames_dir, fname), EXTRACT_W, EXTRACT_H))
frame_metadata[fname] = {'dialogue': m['text'], 'time': m['mid']}
frame_files.append(fname)
count += 1
completed = 0
gc.collect()
if status_callback: status_callback("Drawing Panels...", 50)
with ThreadPoolExecutor(max_workers=WORKER_COUNT) as executor:
futures = [executor.submit(extract_frame_task, t) for t in tasks]
for _ in as_completed(futures):
completed += 1
if status_callback:
prog = 50 + int((completed / len(tasks)) * 45)
status_callback(f"Drawing Panels: {completed}/{len(tasks)}", prog)
with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
bubbles_list = []
for i, f in enumerate(frame_files):
dialogue = frame_metadata.get(f, {}).get('dialogue', '...')
b_type = 'speech'
if '(' in dialogue or '[' in dialogue: b_type = 'narration'
elif '!' in dialogue and dialogue.isupper() and len(dialogue) < 15: b_type = 'reaction'
elif dialogue == "...": b_type = 'thought'
pos_idx = i % 4
bx, by = (150, 80) if pos_idx == 0 else (580, 80) if pos_idx == 1 else (150, 600) if pos_idx == 2 else (580, 600)
bubbles_list.append(bubble(dialog=dialogue, x=bx, y=by, type=b_type))
pages = []
for i in range(int(target_pages)):
start, end = i * 4, (i + 1) * 4
p_frames = frame_files[start:end]
p_bubbles = bubbles_list[start:end]
while len(p_frames) < 4:
fname = f"empty_{i}_{len(p_frames)}.png"
cv2.imwrite(os.path.join(frames_dir, fname), np.zeros((EXTRACT_H, EXTRACT_W, 3), dtype=np.uint8))
p_frames.append(fname)
p_bubbles.append(bubble(dialog="...", x=-999, y=-999, type='speech'))
if p_frames:
pg_panels = [panel(image=p_frames[j], time=frame_metadata.get(p_frames[j], {}).get('time', 0)) for j in range(len(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
# ======================================================
# 🔧 HELPER FUNCTIONS
# ======================================================
def regen_frame_cpu(video_path, frames_dir, metadata_path, fname, direction):
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 linked"}
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
new_t = max(0, t + (1.0/fps) * (10 if direction == 'forward' else -10))
cap.set(cv2.CAP_PROP_POS_MSEC, new_t * 1000)
ret, frame = cap.read()
cap.release()
if ret:
cv2.imwrite(os.path.join(frames_dir, fname), resize_contain_hq(frame, 1080, 1080))
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"Time: {new_t:.2f}s", "new_time": new_t}
return {"success": False}
def get_frame_at_ts_cpu(video_path, frames_dir, metadata_path, fname, ts):
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 linked"}
cap = cv2.VideoCapture(video_path)
cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
ret, frame = cap.read()
cap.release()
if ret:
cv2.imwrite(os.path.join(frames_dir, fname), resize_contain_hq(frame, 1080, 1080))
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", "new_time": float(ts)}
return {"success": False}
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("Initializing...", 1)
data = generate_comic_smart(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, int(target_pages), status_callback=self.write_status)
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):
try:
with open(os.path.join(self.output_dir, 'status.tmp'), 'w') as f: json.dump({'message': msg, 'progress': prog}, f)
os.replace(os.path.join(self.output_dir, 'status.tmp'), os.path.join(self.output_dir, 'status.json'))
except: pass
# ======================================================
# 🌐 FRONTEND
# ======================================================
INDEX_HTML = r'''
<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Comic Studio Pro (HQ)</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>
:root { --bg-dark: #121212; --bg-panel: #1e1e1e; --accent: #f39c12; --text: #e0e0e0; --border: #333; --neon-glow: 0 0 15px rgba(243, 156, 18, 0.4); }
* { box-sizing: border-box; user-select: none; }
body { background-color: var(--bg-dark); font-family: 'Lato', sans-serif; color: var(--text); margin: 0; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
/* === CRAZY SPLASH SCREEN === */
@keyframes speedlines { 0% { background-position: 0 0; } 100% { background-position: 100px 100px; } }
@keyframes pop-in { 0% { transform: scale(0) rotate(-15deg); opacity: 0; } 70% { transform: scale(1.2) rotate(5deg); } 100% { transform: scale(1) rotate(0deg); opacity: 1; } }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(243, 156, 18, 0.7); } 70% { box-shadow: 0 0 0 20px rgba(243, 156, 18, 0); } 100% { box-shadow: 0 0 0 0 rgba(243, 156, 18, 0); } }
#splash-screen { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 5000; display: flex; flex-direction: column; align-items: center; justify-content: center; background: radial-gradient(circle at center, #222 0%, #000 100%); overflow: hidden; transition: opacity 0.5s ease-out; }
.bg-fx { position: absolute; top:0; left:0; width:100%; height:100%; background: repeating-linear-gradient(45deg, #222 0, #222 2px, transparent 2px, transparent 10px); opacity: 0.1; animation: speedlines 1s linear infinite; pointer-events: none; }
.splash-logo { font-family: 'Bangers'; font-size: 80px; color: var(--accent); margin-bottom: 10px; text-shadow: 5px 5px 0px #000; animation: pop-in 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275); z-index: 2; transform-origin: center; }
.splash-sub { color: #888; margin-bottom: 40px; font-size: 18px; z-index: 2; letter-spacing: 2px; }
.splash-btn { padding: 20px 60px; font-size: 24px; background: var(--accent); color: #000; border: none; border-radius: 50px; cursor: pointer; font-family: 'Bangers'; letter-spacing: 2px; text-transform: uppercase; animation: pulse 2s infinite; transition: 0.2s; z-index: 2; }
.splash-btn:hover { transform: scale(1.1) rotate(-2deg); background: #fff; }
/* LOADING SCREEN */
#loading-screen { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #000; z-index: 4000; flex-direction: column; align-items: center; justify-content: center; color: var(--accent); }
.spinner { width: 80px; height: 80px; border: 8px solid rgba(255,255,255,0.1); border-top: 8px solid var(--accent); border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 20px; box-shadow: var(--neon-glow); }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.loading-bar-container { width: 300px; height: 10px; background: #333; border-radius: 5px; overflow: hidden; margin-top: 20px; border: 1px solid #555; }
.loading-bar { height: 100%; width: 0%; background: var(--accent); transition: width 0.3s linear; box-shadow: 0 0 10px var(--accent); }
.loading-text { margin-top: 15px; font-size: 18px; font-family: 'Bangers'; letter-spacing: 2px; animation: blink 1.5s infinite; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
/* MAIN UI */
.top-bar { height: 50px; background: var(--bg-panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; padding: 0 20px; z-index: 1000; }
.brand { font-family: 'Bangers'; font-size: 24px; color: var(--accent); letter-spacing: 1px; text-shadow: 2px 2px 0px #000; }
.top-actions { display: flex; gap: 10px; }
.t-btn { background: #333; border: 1px solid var(--border); color: #ccc; padding: 5px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; transition: 0.2s; }
.t-btn:hover { background: #444; color: white; }
.t-btn.primary { background: var(--accent); color: #000; border: none; font-weight: bold; }
.main-container { display: flex; flex: 1; overflow: hidden; opacity: 0; transition: opacity 0.5s; }
.toolbar { width: 50px; background: var(--bg-panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; align-items: center; padding-top: 15px; gap: 15px; z-index: 900; }
.tool-icon { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: 6px; cursor: pointer; font-size: 20px; color: #aaa; transition: 0.2s; }
.tool-icon:hover { background: #333; color: white; }
.tool-icon.active { background: var(--accent); color: #000; }
.workspace { flex: 1; background: #0a0a0a; overflow: auto; padding: 40px; display: flex; flex-direction: column; align-items: center; gap: 30px; }
.page-wrapper { margin-bottom: 30px; }
.page-label-bar { display: flex; justify-content: space-between; align-items: center; width: 1080px; margin-bottom: 5px; color: #888; font-size: 12px; font-weight: bold; text-transform: uppercase; }
.inspector { width: 300px; background: var(--bg-panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; z-index: 900; }
.inspector-header { padding: 15px; border-bottom: 1px solid var(--border); font-weight: bold; font-size: 12px; text-transform: uppercase; color: #888; }
.inspector-content { padding: 15px; overflow-y: auto; flex: 1; }
/* COMIC ELEMENTS */
.comic-page { width: 1080px; height: 1080px; background: white; box-shadow: 0 0 30px rgba(0,0,0,0.5); position: relative; overflow: hidden; border: 5px solid #fff; flex-shrink: 0; }
.comic-grid { width: 100%; height: 100%; position: relative; background: #fff; --y: 50%; --t1: 100%; --t2: 100%; --b1: 100%; --b2: 100%; --gap: 5px; }
.placement-mode { cursor: crosshair !important; }
.panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #000; cursor: grab; z-index:1; }
.panel:active { cursor: grabbing; }
.panel img { width: 100%; height: 100%; object-fit: contain; }
.panel.selected { border: 4px solid var(--accent); z-index: 5; }
/* CLIP PATHS - ADDED GAPS FOR "LINES" */
.panel:nth-child(1) { clip-path: polygon(0 0, calc(var(--t1) - var(--gap)) 0, calc(var(--t2) - var(--gap)) calc(var(--y) - var(--gap)), 0 calc(var(--y) - var(--gap))); }
.panel:nth-child(2) { clip-path: polygon(calc(var(--t1) + var(--gap)) 0, 100% 0, 100% calc(var(--y) - var(--gap)), calc(var(--t2) + var(--gap)) calc(var(--y) - var(--gap))); }
.panel:nth-child(3) { clip-path: polygon(0 calc(var(--y) + var(--gap)), calc(var(--b1) - var(--gap)) calc(var(--y) + var(--gap)), calc(var(--b2) - var(--gap)) 100%, 0 100%); }
.panel:nth-child(4) { clip-path: polygon(calc(var(--b1) + var(--gap)) calc(var(--y) + var(--gap)), 100% calc(var(--y) + var(--gap)), 100% 100%, calc(var(--b2) + var(--gap)) 100%); }
.handle { position: absolute; width: 15px; height: 15px; background: white; border: 2px solid var(--accent); border-radius: 50%; transform: translate(-50%, -50%); z-index: 101; cursor: ew-resize; }
.h-t1 { left: var(--t1); top: 0%; margin-top: 10px; } .h-t2 { left: var(--t2); top: 50%; margin-top: -10px; }
.h-b1 { left: var(--b1); top: 50%; margin-top: 10px; } .h-b2 { left: var(--b2); top: 100%; margin-top: -10px; }
/* BUBBLES */
.speech-bubble { position: absolute; display: flex; justify-content: center; align-items: center; min-width: 80px; box-sizing: border-box; z-index: 100; cursor: move; font-weight: bold; text-align: center; line-height: 1.2; --tail-pos: 50%; font-size: 16px; }
.bubble-text { padding: 0.8em; word-wrap: break-word; white-space: pre-wrap; pointer-events: none; }
.speech-bubble.selected { outline: 2px dashed var(--accent); z-index: 1000; }
.speech-bubble.speech { --b: 3em; --h: 1.8em; --t: 0.6; --p: var(--tail-pos, 50%); --r: 1.2em; background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); 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: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: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: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.speech.tail-bottom.tail-flip:before { transform: scaleX(-1); }
.speech-bubble.speech.tail-top.tail-flip:before { transform: scaleY(-1) scaleX(-1); }
.speech-bubble.speech.tail-left.tail-flip:before { transform: rotate(90deg) scaleX(-1); }
.speech-bubble.speech.tail-right.tail-flip:before { transform: rotate(-90deg) scaleX(-1); }
.speech-bubble.thought { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 2px dashed #555; border-radius: 50%; }
.thought-dot { position: absolute; background-color: var(--bubble-fill, #fff); border: 2px solid #555; border-radius: 50%; z-index: -1; }
.thought-dot-1 { width: 15px; height: 15px; bottom:-15px; left:20px; } .thought-dot-2 { width: 10px; height: 10px; bottom:-25px; left:10px; }
.speech-bubble.thought.pos-bl .thought-dot-1 { left: 20px; bottom: -20px; }
.speech-bubble.reaction { background: #FFD700; border: 3px solid #E53935; color: #D32F2F; font-family: 'Bangers'; 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: var(--bubble-fill, #eeeeee); color: var(--bubble-text, #000000); border: 2px solid #000; border-radius: 0px; font-family: 'Lato', sans-serif; box-shadow: 3px 3px 0px rgba(0,0,0,0.4); }
/* UTILS */
.hidden { display: none !important; }
.control-label { display: block; font-size: 11px; font-weight: bold; color: #aaa; margin-bottom: 8px; text-transform: uppercase; }
input, select { width: 100%; background: #333; border: 1px solid #444; padding: 8px; color: white; border-radius: 4px; margin-bottom: 10px; }
.icon-btn { width: 100%; padding: 8px; background: #333; border: 1px solid #444; color: white; cursor: pointer; border-radius: 4px; margin-bottom: 5px; }
.icon-btn:hover { background: #444; }
/* UPLOAD MODAL */
#upload-modal { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.9); z-index:3000; align-items:center; justify-content:center; }
.modal-box { background: var(--bg-panel); padding: 40px; border-radius: 12px; width: 400px; text-align: center; border: 1px solid #333; box-shadow: var(--neon-glow); }
</style>
</head>
<body>
<!-- CRAZY SPLASH SCREEN -->
<div id="splash-screen">
<div class="bg-fx"></div>
<div class="splash-logo">⚡ COMIC STUDIO PRO</div>
<p class="splash-sub">AI-Powered Comic Generation Suite</p>
<button class="splash-btn" onclick="enterStudio()">ENTER STUDIO ▶</button>
</div>
<!-- UPLOAD MODAL -->
<div id="upload-modal">
<div class="modal-box">
<h2 style="margin-top:0; color:var(--accent);">NEW PROJECT</h2>
<input type="file" id="file-upload" style="display:none" onchange="document.getElementById('fn').innerText=this.files[0].name">
<button onclick="document.getElementById('file-upload').click()" class="icon-btn" style="padding:20px; font-size:16px; border:2px dashed #555; background:transparent; color:#888;">📁 CLICK TO SELECT VIDEO</button>
<div id="fn" style="color:#var(--accent); margin:10px 0; font-size:12px;">No file selected</div>
<div style="text-align:left; margin-top:20px;">
<label class="control-label">PAGES TO GENERATE</label>
<input type="number" id="page-count" value="4" min="1" max="20">
</div>
<button class="t-btn primary" style="width:100%; padding:15px; margin-top:10px; font-size:16px;" onclick="upload()">🚀 GENERATE NOW</button>
<div style="margin-top:20px; border-top:1px solid #333; padding-top:15px;">
<p style="font-size:11px; color:#666;">EXISTING PROJECT?</p>
<div style="display:flex; gap:5px;">
<input type="text" id="load-code" placeholder="ENTER SAVE CODE" style="margin:0;">
<button class="t-btn" onclick="loadComic()" style="margin:0;">LOAD</button>
</div>
</div>
</div>
</div>
<!-- LOADING SCREEN -->
<div id="loading-screen">
<div class="spinner"></div>
<div style="font-size:24px; margin-bottom:10px; font-family:'Bangers'; letter-spacing:1px;">GENERATING COMIC...</div>
<div class="loading-bar-container">
<div id="prog-bar" class="loading-bar"></div>
</div>
<div id="status-text" class="loading-text">INITIALIZING...</div>
</div>
<!-- MAIN UI -->
<div class="top-bar">
<div class="brand">COMIC STUDIO</div>
<div class="top-actions">
<button class="t-btn" onclick="document.getElementById('upload-modal').style.display='flex'">➕ New Project</button>
<button class="t-btn" onclick="addNewPage()">📄 Add Page</button>
<button class="t-btn" onclick="saveComic()">💾 Save</button>
<button class="t-btn primary" onclick="exportComic()">📥 Export ALL</button>
</div>
</div>
<div class="main-container">
<div class="toolbar">
<div class="tool-icon active" id="tool-select" onclick="setTool('select')">➤</div>
<div class="tool-icon" id="tool-add" onclick="setTool('add')">💬</div>
</div>
<div class="workspace" id="workspace">
<div id="comic-container"></div>
</div>
<div class="inspector">
<div class="inspector-header">Properties</div>
<div class="inspector-content" id="props-content">
<div style="text-align:center; color:#555; margin-top:50px;">Select an item to edit</div>
</div>
</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 currentTool='select', selectedBubble=null, selectedPanel=null, interval;
function enterStudio() {
document.getElementById('splash-screen').style.opacity = '0';
setTimeout(() => {
document.getElementById('splash-screen').style.display = 'none';
document.querySelector('.main-container').style.opacity = '1';
// Show upload modal if no comic loaded
if(document.getElementById('comic-container').children.length === 0) {
document.getElementById('upload-modal').style.display = 'flex';
}
}, 500);
}
function setTool(t) {
currentTool = t;
document.querySelectorAll('.tool-icon').forEach(e=>e.classList.remove('active'));
document.getElementById('tool-'+t).classList.add('active');
const grids = document.querySelectorAll('.comic-grid');
if(t==='add') grids.forEach(g=>g.classList.add('placement-mode'));
else grids.forEach(g=>g.classList.remove('placement-mode'));
}
// === PROPERTIES RENDERER ===
function renderProperties() {
const p = document.getElementById('props-content');
p.innerHTML = '';
if(selectedBubble) {
p.innerHTML = `
<div class="control-group"><label class="control-label">Type</label><select id="p-type" onchange="updateProp('type', this.value)"><option value="speech">Speech</option><option value="thought">Thought</option><option value="reaction">Reaction</option><option value="narration">Narration</option></select></div>
<div class="control-group"><label class="control-label">Style</label><div style="display:flex; gap:5px"><select id="p-font" onchange="updateProp('font', this.value)"><option value="'Comic Neue', cursive">Comic</option><option value="'Bangers', cursive">Bangers</option></select><input type="number" id="p-size" style="width:60px" onchange="updateProp('size', this.value)"></div></div>
<div class="control-group" id="tail-sec"><label class="control-label">Tail</label><div style="display:flex; gap:5px; margin-bottom:5px"><button class="icon-btn" onclick="setTail('tail-top')">⬆</button><button class="icon-btn" onclick="setTail('tail-bottom')">⬇</button><button class="icon-btn" onclick="setTail('tail-left')">⬅</button><button class="icon-btn" onclick="setTail('tail-right')">➡</button></div><button class="icon-btn" onclick="toggleFlip()">🔄 Flip Curve</button></div>
<button class="icon-btn" style="background:#c0392b; border:none" onclick="deleteBubble()">Delete</button>
`;
document.getElementById('p-type').value = selectedBubble.dataset.type;
document.getElementById('p-font').value = selectedBubble.style.fontFamily.replace(/"/g, "'");
document.getElementById('p-size').value = parseInt(selectedBubble.style.fontSize);
if(selectedBubble.dataset.type === 'narration') document.getElementById('tail-sec').style.display='none';
} else if(selectedPanel) {
const img = selectedPanel.querySelector('img');
p.innerHTML = `
<div class="control-group"><label class="control-label">Time</label><input type="text" id="p-time" value="${formatTime(img.dataset.time)}"><button class="icon-btn" onclick="gotoTimestamp()">Jump to Time</button></div>
<div class="control-group"><label class="control-label">Frame</label><div style="display:flex; gap:5px"><button class="icon-btn" onclick="adjustFrame('backward')">◀ Prev</button><button class="icon-btn" onclick="adjustFrame('forward')">Next ▶</button></div></div>
<div class="control-group"><label class="control-label">Image</label><button class="icon-btn accent-btn" onclick="replaceImage()">Replace...</button></div>
`;
}
}
// === LOGIC ===
function updateProp(k, v) {
if(!selectedBubble) return;
if(k==='type') {
let cls = selectedBubble.className;
['speech','thought','reaction','narration'].forEach(t=>cls=cls.replace(t,''));
cls = `speech-bubble ${v} ` + cls.replace('speech-bubble','').trim();
const n = createBubble({
text: selectedBubble.querySelector('.bubble-text').textContent,
left: selectedBubble.style.left, top: selectedBubble.style.top,
width: selectedBubble.style.width, height: selectedBubble.style.height,
type: v, font: selectedBubble.style.fontFamily, fontSize: selectedBubble.style.fontSize,
classes: cls
});
selectedBubble.replaceWith(n);
selectBubble(n);
}
else if(k==='font') selectedBubble.style.fontFamily = v;
else if(k==='size') selectedBubble.style.fontSize = v+'px';
}
function setTail(d) {
if(!selectedBubble) return;
const flip = selectedBubble.classList.contains('tail-flip');
selectedBubble.className = `speech-bubble ${selectedBubble.dataset.type} ${d} selected` + (flip ? ' tail-flip' : '');
}
function toggleFlip() { if(selectedBubble) selectedBubble.classList.toggle('tail-flip'); }
function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); selectedBubble=null; renderProperties(); } }
function createBubble(d) {
const b = document.createElement('div');
b.className = d.classes || `speech-bubble ${d.type} tail-bottom`;
b.dataset.type = d.type;
b.style.left = d.left; b.style.top = d.top;
if(d.width) b.style.width=d.width; if(d.height) b.style.height=d.height;
b.style.fontFamily = d.font || "'Comic Neue', cursive";
b.style.fontSize = d.fontSize || '16px';
b.innerHTML = `<span class="bubble-text">${d.text}</span><div class="resize-handle"></div>`;
b.onmousedown = (e) => {
// If resizing, ignore move
if(e.target.classList.contains('resize-handle')) {
window.dragActive = { b: b, startW: b.offsetWidth, startH: b.offsetHeight, mx: e.clientX, my: e.clientY, type: 'resize' };
e.stopPropagation(); // prevent panel dragging
} else if(currentTool !== 'add') {
// Bubble dragging
window.dragActive = { b: b, type: 'move', offsetX: e.clientX - b.offsetLeft, offsetY: e.clientY - b.offsetTop };
selectBubble(b);
e.stopPropagation(); // prevent panel dragging
}
};
b.ondblclick = (e) => { e.stopPropagation(); const t=prompt("Edit:", b.innerText); if(t) b.querySelector('.bubble-text').innerText=t; };
return b;
}
document.addEventListener('mousemove', e => {
if(window.dragActive) {
const d = window.dragActive;
if(d.type === 'resize') {
d.b.style.width = (d.startW + (e.clientX - d.mx)) + 'px';
d.b.style.height = (d.startH + (e.clientY - d.my)) + 'px';
} else if(d.type === 'move') {
d.b.style.left = (e.clientX - d.offsetX) + 'px';
d.b.style.top = (e.clientY - d.offsetY) + 'px';
} else if(d.type === 'pan_image') { // IMAGE PANNING
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
d.img.dataset.translateX = d.tx + dx;
d.img.dataset.translateY = d.ty + dy;
updateImageTransform(d.img);
}
}
});
document.addEventListener('mouseup', () => { window.dragActive = null; });
function selectBubble(b) {
if(selectedBubble) selectedBubble.classList.remove('selected');
selectedBubble = b; b.classList.add('selected');
selectedPanel = null; if(document.querySelector('.panel.selected')) document.querySelector('.panel.selected').classList.remove('selected');
renderProperties();
}
function selectPanel(p) {
if(selectedPanel) selectedPanel.classList.remove('selected');
selectedPanel = p; p.classList.add('selected');
selectedBubble = null; if(document.querySelector('.speech-bubble.selected')) document.querySelector('.speech-bubble.selected').classList.remove('selected');
renderProperties();
}
// === UPLOAD / GENERATE ===
async function upload() {
const f = document.getElementById('file-upload').files[0];
if(!f) return alert("Select a video first!");
// TRANSITION TO LOADING SCREEN
document.getElementById('upload-modal').style.display = 'none';
document.getElementById('loading-screen').style.display = 'flex';
const fd = new FormData();
fd.append('file', f);
fd.append('target_pages', document.getElementById('page-count').value);
fd.append('sid', sid);
const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
if(r.ok) {
interval = setInterval(async () => {
const rs = await fetch(`/status?sid=${sid}`);
const d = await rs.json();
document.getElementById('status-text').innerText = d.message.toUpperCase();
document.getElementById('prog-bar').style.width = d.progress + '%';
if(d.progress >= 100) {
clearInterval(interval);
document.getElementById('loading-screen').style.display = 'none';
document.querySelector('.main-container').style.opacity = '1';
loadComic();
}
}, 1000);
}
}
async function loadComic() {
const r = await fetch(`/output/pages.json?sid=${sid}`);
if(r.ok) {
const data = await r.json();
const con = document.getElementById('comic-container');
con.innerHTML = '';
data.forEach((p, idx) => {
const wrapper = document.createElement('div');
wrapper.className = 'page-wrapper';
wrapper.innerHTML = `<div class="page-label-bar"><span>Page ${idx+1}</span><button class="dl-page-btn" onclick="downloadPage('page-${idx}')">⬇ Download</button></div>`;
const pageDiv = document.createElement('div');
pageDiv.className = 'comic-page';
pageDiv.id = `page-${idx}`;
const grid = document.createElement('div');
grid.className = 'comic-grid';
// Click-to-place logic
grid.addEventListener('click', (e) => {
if(currentTool === 'add') {
const rect = grid.getBoundingClientRect();
const b = createBubble({ text: "Text", left: (e.clientX - rect.left - 40)+'px', top: (e.clientY - rect.top - 20)+'px', type: 'speech' });
grid.appendChild(b);
selectBubble(b);
setTool('select');
}
});
// Panels
p.panels.forEach(pan => {
const pDiv = document.createElement('div');
pDiv.className = 'panel';
const img = document.createElement('img');
img.src = `/frames/${pan.image}?sid=${sid}`;
img.dataset.time = pan.time;
img.dataset.zoom = 100;
img.dataset.translateX = 0;
img.dataset.translateY = 0;
// --- PAN LOGIC ---
img.onmousedown = (e) => {
if(currentTool !== 'add') {
e.preventDefault(); e.stopPropagation();
selectPanel(pDiv);
window.dragActive = {
type: 'pan_image',
img: img,
startX: e.clientX,
startY: e.clientY,
tx: parseFloat(img.dataset.translateX),
ty: parseFloat(img.dataset.translateY)
};
}
};
// --- ZOOM LOGIC ---
img.onwheel = (e) => {
if(currentTool !== 'add') {
e.preventDefault();
let z = parseFloat(img.dataset.zoom);
z += e.deltaY * -0.1;
z = Math.min(Math.max(20, z), 300);
img.dataset.zoom = z;
updateImageTransform(img);
}
};
pDiv.appendChild(img);
grid.appendChild(pDiv);
});
// Bubbles
p.bubbles.forEach(b => {
const el = createBubble({
text: b.dialog, left: (b.bubble_offset_x)+'px', top: (b.bubble_offset_y)+'px',
type: b.type, classes: b.classes,
font: b.font, fontSize: b.font_size
});
grid.appendChild(el);
});
pageDiv.appendChild(grid);
wrapper.appendChild(pageDiv);
con.appendChild(wrapper);
});
}
}
function updateImageTransform(img) {
const z = parseFloat(img.dataset.zoom) / 100;
const x = parseFloat(img.dataset.translateX);
const y = parseFloat(img.dataset.translateY);
img.style.transform = `translate(${x}px, ${y}px) scale(${z})`;
}
function addNewPage() {
const idx = document.querySelectorAll('.comic-page').length;
const con = document.getElementById('comic-container');
const wrapper = document.createElement('div');
wrapper.className = 'page-wrapper';
wrapper.innerHTML = `<div class="page-label-bar"><span>Page ${idx+1}</span><button class="dl-page-btn" onclick="downloadPage('page-${idx}')">⬇ Download</button></div>`;
const pageDiv = document.createElement('div');
pageDiv.className = 'comic-page';
pageDiv.id = `page-${idx}`;
const grid = document.createElement('div');
grid.className = 'comic-grid';
grid.innerHTML = `<div class="panel"><img src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="></div>`.repeat(4);
grid.addEventListener('click', (e) => {
if(currentTool === 'add') {
const rect = grid.getBoundingClientRect();
const b = createBubble({ text: "Text", left: (e.clientX - rect.left - 40)+'px', top: (e.clientY - rect.top - 20)+'px', type: 'speech' });
grid.appendChild(b); selectBubble(b); setTool('select');
}
});
pageDiv.appendChild(grid);
wrapper.appendChild(pageDiv);
con.appendChild(wrapper);
wrapper.scrollIntoView({behavior:'smooth'});
}
// === UTILS ===
function formatTime(s) { s=parseFloat(s); return `${Math.floor(s/60)}:${Math.floor(s%60)}`; }
// === EXPORT ===
async function downloadPage(id) {
if(prompt("Password:") !== "puntoon@2026") return alert("Wrong Password");
if(selectedBubble) selectedBubble.classList.remove('selected');
if(selectedPanel) selectedPanel.classList.remove('selected');
const el = document.getElementById(id);
const u = await htmlToImage.toPng(el, {pixelRatio:2});
const a = document.createElement('a'); a.href=u; a.download=`${id}.png`; a.click();
}
async function exportComic() {
if(prompt("Password:") !== "puntoon@2026") return alert("Wrong Password");
document.querySelectorAll('.comic-page').forEach(async (el, i) => {
const u = await htmlToImage.toPng(el, {pixelRatio:2});
const a = document.createElement('a'); a.href=u; a.download=`Page-${i+1}.png`; a.click();
});
}
async function adjustFrame(dir) {
if(!selectedPanel) return;
const img = selectedPanel.querySelector('img');
const fname = img.src.split('frames/')[1].split('?')[0];
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()}`; img.dataset.time=d.new_time; }
}
async function gotoTimestamp() {
if(!selectedPanel) return;
const val = document.getElementById('p-time').value;
const s = val.includes(':') ? (parseInt(val.split(':')[0])*60 + parseFloat(val.split(':')[1])) : parseFloat(val);
const img = selectedPanel.querySelector('img');
const fname = img.src.split('frames/')[1].split('?')[0];
const r = await fetch(`/goto_timestamp?sid=${sid}`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, timestamp:s})});
const d = await r.json();
if(d.success) { img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`; img.dataset.time=d.new_time; }
}
function replaceImage() {
if(!selectedPanel) return;
const i = document.createElement('input'); i.type='file';
i.onchange = async () => {
const fd = new FormData(); fd.append('image', i.files[0]);
const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd});
const d = await r.json();
if(d.success) selectedPanel.querySelector('img').src = `/frames/${d.new_filename}?sid=${sid}`;
};
i.click();
}
async function saveComic() {
const pages = [];
alert("Save logic here (Simulated). Code: " + genUUID().substring(0,8));
}
</script>
</body> </html>
'''
# ======================================================
# 🚀 FLASK ROUTES
# ======================================================
@app.route('/')
def index():
return INDEX_HTML
@app.route('/uploader', methods=['POST'])
def upload():
sid = request.args.get('sid') or request.form.get('sid')
if not sid: return jsonify({'success': False, 'message': 'Missing session ID'}), 400
file = request.files.get('file')
if not file or file.filename == '': return jsonify({'success': False, 'message': 'No file uploaded'}), 400
target_pages = request.form.get('target_pages', 4)
gen = EnhancedComicGenerator(sid)
gen.cleanup()
file.save(gen.video_path)
gen.write_status("Starting...", 5)
threading.Thread(target=gen.run, args=(target_pages,)).start()
return jsonify({'success': True})
@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_cpu(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_cpu(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')
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)
with open(os.path.join(save_dir, 'comic_state.json'), 'w') as f:
json.dump({'originalSid': sid, 'pages': data['pages'], 'savedAt': time.time()}, f)
return jsonify({'success': True, 'code': save_code})
except Exception as e: 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)
if not os.path.exists(save_dir): return jsonify({'success': False, 'message': 'Code not found'})
try:
with open(os.path.join(save_dir, 'comic_state.json'), 'r') as f: data = json.load(f)
orig_sid = data['originalSid']
saved_frames = os.path.join(save_dir, 'frames')
user_frames = os.path.join(BASE_USER_DIR, orig_sid, 'frames')
os.makedirs(user_frames, exist_ok=True)
for fn in os.listdir(saved_frames):
shutil.copy2(os.path.join(saved_frames, fn), os.path.join(user_frames, fn))
return jsonify({'success': True, 'originalSid': orig_sid, 'pages': data['pages']})
except Exception as e: return jsonify({'success': False, 'message': str(e)})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=7860)