passwords / app_enhanced.py
tester343's picture
Update app_enhanced.py
8fedb1d 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
# ======================================================
# 🧠 ROBUST GENERATION LOGIC
# ======================================================
def generate_subtitles_cpu(video_path, srt_path, status_callback=None):
try:
if status_callback: status_callback("Extracting Audio (FFmpeg)...", 10)
audio_path = video_path.replace(".mp4", ".wav")
try:
command = [
"ffmpeg", "-y", "-i", video_path, "-vn",
"-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", audio_path
]
subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
except FileNotFoundError:
print("❌ FFmpeg not found.")
return False
except Exception as e:
print(f"❌ Audio extraction failed: {e}")
return False
if status_callback: status_callback("Loading AI Model (Base)...", 15)
model = whisper.load_model("base", device="cpu")
if status_callback: status_callback("Transcribing Speech...", 20)
result = model.transcribe(audio_path, fp16=False, language='en')
segments = result['segments']
with open(srt_path, 'w', encoding='utf-8') as f:
for i, segment in enumerate(segments):
start = segment['start']
end = segment['end']
text = segment['text'].strip()
def fmt_time(t):
hours = int(t // 3600)
minutes = int((t % 3600) // 60)
seconds = int(t % 60)
milliseconds = int((t % 1) * 1000)
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"
f.write(f"{i + 1}\n")
f.write(f"{fmt_time(start)} --> {fmt_time(end)}\n")
f.write(f"{text}\n\n")
if os.path.exists(audio_path): os.remove(audio_path)
return True
except Exception as e:
print(f"Transcription Failed: {e}")
traceback.print_exc()
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:
frame = cv2.resize(frame, (width, height))
cv2.imwrite(output_path, frame, [int(cv2.IMWRITE_JPEG_QUALITY), 90])
return True
return False
except Exception:
return False
def generate_comic_smart(video_path, user_dir, frames_dir, metadata_path, target_pages, status_callback=None):
WORKER_COUNT = 4
print(f"🚀 Starting Smart Generation for {user_dir}")
if status_callback: status_callback("Analyzing Video Structure...", 5)
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')
has_text = generate_subtitles_cpu(video_path, user_srt, status_callback)
if status_callback: status_callback("Planning Comic Layout...", 25)
raw_moments = []
if has_text and os.path.exists(user_srt):
with open(user_srt, 'r', encoding='utf-8') as f:
try:
subs = list(srt.parse(f.read()))
valid_subs = [s for s in subs if s.content and s.content.strip() and s.content.strip() != "..."]
for s in valid_subs:
start = s.start.total_seconds()
end = s.end.total_seconds()
raw_moments.append({'text': s.content.strip(), 'start': start, 'end': end, 'mid': (start+end)/2})
except Exception as e:
print(f"SRT Parse Error: {e}")
panels_per_page = 4
total_panels_needed = int(target_pages) * panels_per_page
selected_moments = []
if len(raw_moments) > 0:
indices = np.linspace(0, len(raw_moments) - 1, total_panels_needed, dtype=int)
for i in indices: selected_moments.append(raw_moments[i])
else:
print("⚠️ No speech detected or FFmpeg missing, using visual spacing.")
times = np.linspace(1, max(1, duration-1), total_panels_needed)
for t in times: selected_moments.append({'text': "...", 'start': t, 'end': t+1, 'mid': t})
frame_metadata = {}
frame_files_ordered = []
tasks = []
count = 0
for m in selected_moments:
fname = f"frame_{count:04d}.jpg"
out_p = os.path.join(frames_dir, fname)
tasks.append((video_path, m['mid'], out_p, 1920, 1080))
frame_metadata[fname] = {'dialogue': m['text'], 'time': m['mid']}
frame_files_ordered.append(fname)
count += 1
total_tasks = len(tasks)
completed = 0
start_time = time.time()
gc.collect()
if status_callback: status_callback("Rendering Panels...", 35)
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:
elapsed = time.time() - start_time
prog = 35 + int((completed / total_tasks) * 60)
status_callback(f"Rendering Panels: {completed}/{total_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_ordered):
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
if pos_idx == 0: bx, by = 150, 80
elif pos_idx == 1: bx, by = 580, 80
elif pos_idx == 2: bx, by = 150, 600
elif pos_idx == 3: bx, by = 580, 600
else: bx, by = 50, 50
bubbles_list.append(bubble(dialog=dialogue, x=bx, y=by, type=b_type))
pages = []
for i in range(int(target_pages)):
start_idx = i * 4
end_idx = start_idx + 4
p_frames = frame_files_ordered[start_idx:end_idx]
p_times = [frame_metadata[f]['time'] for f in p_frames]
p_bubbles = bubbles_list[start_idx:end_idx]
while len(p_frames) < 4:
fname = f"empty_{i}_{len(p_frames)}.jpg"
img = np.zeros((1080, 1920, 3), dtype=np.uint8); img[:] = (30,30,30)
cv2.imwrite(os.path.join(frames_dir, fname), img, [int(cv2.IMWRITE_JPEG_QUALITY), 90])
p_frames.append(fname)
p_times.append(0.0)
p_bubbles.append(bubble(dialog="...", x=-999, y=-999, type='speech'))
if p_frames:
pg_panels = [panel(image=p_frames[j], time=p_times[j]) 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)
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) * (10 if direction == 'forward' else -10)
new_t = max(0, t + offset)
cap.set(cv2.CAP_PROP_POS_MSEC, new_t * 1000)
ret, frame = cap.read()
cap.release()
if ret:
frame = cv2.resize(frame, (1920, 1080))
cv2.imwrite(os.path.join(frames_dir, fname), frame, [int(cv2.IMWRITE_JPEG_QUALITY), 90])
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):
cap = cv2.VideoCapture(video_path)
cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
ret, frame = cap.read()
cap.release()
if ret:
frame = cv2.resize(frame, (1920, 1080))
cv2.imwrite(os.path.join(frames_dir, fname), frame, [int(cv2.IMWRITE_JPEG_QUALITY), 90])
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", "new_time": float(ts)}
return {"success": False, "message": "Invalid timestamp"}
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 Project...", 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("Finalizing Project...", 100)
except Exception as e:
traceback.print_exc()
self.write_status(f"Error: {str(e)}", -1)
def write_status(self, msg, prog):
temp_path = os.path.join(self.output_dir, 'status.tmp')
final_path = os.path.join(self.output_dir, 'status.json')
try:
with open(temp_path, 'w') as f:
json.dump({'message': msg, 'progress': prog}, f)
os.replace(temp_path, final_path)
except:
pass
# ======================================================
# 🌐 FRONTEND
# ======================================================
INDEX_HTML = '''
<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Puntoon Comic Studio</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&family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
<style>
:root {
--bg-dark: #1e1e1e;
--bg-panel: #252526;
--bg-light: #333333;
--border-color: #3e3e42;
--accent-blue: #0078d7;
--accent-hover: #1ca0f2;
--text-main: #cccccc;
--text-light: #ffffff;
--highlight: #2d2d2d;
}
* { box-sizing: border-box; }
body { background-color: var(--bg-dark); font-family: 'Inter', 'Lato', sans-serif; color: var(--text-main); margin: 0; min-height: 100vh; overflow-x: hidden; }
/* SPLASH SCREEN */
#splash-screen {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: #111; z-index: 9999; display: flex; flex-direction: column;
justify-content: center; align-items: center; transition: opacity 0.8s ease;
}
.splash-logo { font-family: 'Bangers'; font-size: 5rem; color: #fff; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 20px; animation: popIn 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
.splash-sub { font-family: 'Inter'; font-size: 1.2rem; color: var(--accent-blue); letter-spacing: 4px; text-transform: uppercase; }
@keyframes popIn { 0% { transform: scale(0.5); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
/* LAYOUT */
#upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
.upload-box { max-width: 450px; width: 100%; padding: 40px; background: var(--bg-panel); border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); text-align: center; }
#editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 150px; flex-direction: column; align-items: center; }
h1 { color: var(--text-light); margin-bottom: 20px; font-weight: 600; font-size: 1.5rem; text-transform: uppercase; letter-spacing: 1px; }
/* FORMS & BUTTONS - ADOBE STYLE */
.file-input { display: none; }
.file-label { display: block; padding: 12px; background: var(--bg-light); border: 1px solid var(--border-color); color: var(--text-light); cursor: pointer; font-weight: 600; margin-bottom: 15px; transition:0.2s; text-transform: uppercase; font-size: 0.85rem; letter-spacing: 0.5px; }
.file-label:hover { background: var(--border-color); }
.page-input-group { margin: 20px 0; text-align: left; }
.page-input-group label { font-weight: 600; font-size: 0.75rem; display: block; margin-bottom: 8px; color: var(--text-main); text-transform: uppercase; }
.page-input-group input, .control-group input[type="text"], .control-group select {
width: 100%; padding: 10px; border: 1px solid var(--border-color); background: var(--bg-dark); color: white; font-size: 14px; box-sizing: border-box; outline: none; transition: border 0.2s;
}
.page-input-group input:focus, .control-group input:focus { border-color: var(--accent-blue); }
.submit-btn { width: 100%; padding: 12px; background: var(--accent-blue); color: white; border: none; font-size: 14px; font-weight: 700; cursor: pointer; text-transform: uppercase; transition: background 0.2s; letter-spacing: 0.5px; }
.submit-btn:hover { background: var(--accent-hover); }
/* LOADING BAR */
.loading-view { display: none; margin-top: 25px; width: 100%; text-align: left; }
.progress-wrapper { width: 100%; background-color: var(--bg-dark); height: 6px; position: relative; margin-top: 8px; }
.progress-bar { height: 100%; background-color: var(--accent-blue); width: 0%; transition: width 0.3s ease; }
.status-detail { font-size: 12px; color: var(--accent-blue); font-family: monospace; margin-top: 5px; display:flex; justify-content: space-between; }
/* EDITOR */
.comic-wrapper { max-width: 1000px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; gap: 40px; width: 100%; }
.comic-page { width: 864px; height: 1080px; background: white; box-shadow: 0 0 20px rgba(0,0,0,0.5); position: relative; overflow: hidden; border: 10px solid #ffffff; flex-shrink: 0; }
/*
FIXED GRID SYSTEM FOR BORDERS
We moved the black border to the CONTAINER (.comic-grid) instead of the individual panels.
We set the background to #ffffff so the gaps become white dividers.
This prevents the "thin vs thick" issue caused by clip-path cutting off individual panel borders.
*/
.comic-grid {
width: 100%; height: 100%;
position: relative;
background: #ffffff; /* This creates the white gutter lines */
border: 3px solid #000; /* This creates the consistent outer frame */
box-sizing: border-box;
--y: 50%; --t1: 100%; --t2: 100%; --b1: 100%; --b2: 100%; --gap: 5px;
}
.panel {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
overflow: hidden;
background: #1a1a1a;
cursor: grab;
border: none; /* REMOVED BORDER HERE to fix alignment/thickness issues */
box-sizing: border-box;
}
.panel img { width: 100%; height: 100%; object-fit: contain; transform-origin: center; transition: transform 0.05s ease-out; display: block; }
.panel img.panning { cursor: grabbing; transition: none; }
.panel.selected { z-index: 5; outline: 4px solid var(--accent-blue); outline-offset: -4px; }
/* CLIP PATHS */
.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))); z-index: 1; }
.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))); z-index: 1; }
.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%); z-index: 1; }
.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%); z-index: 1; }
.handle { position: absolute; width: 16px; height: 16px; border: 2px solid white; border-radius: 50%; transform: translate(-50%, -50%); z-index: 101; cursor: ew-resize; box-shadow: 0 2px 5px rgba(0,0,0,0.8); }
.h-t1 { background: var(--accent-blue); left: var(--t1); top: 0%; margin-top: 10px; }
.h-t2 { background: var(--accent-blue); left: var(--t2); top: 50%; margin-top: -10px; }
.h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 10px; }
.h-b2 { background: #2ecc71; left: var(--b2); top: 100%; margin-top: -10px; }
/* BUBBLES */
.speech-bubble { position: absolute; display: flex; justify-content: center; align-items: center; min-width: 60px; min-height: 40px; box-sizing: border-box; z-index: 10; cursor: move; font-weight: bold; text-align: center; overflow: visible; line-height: 1.2; --tail-pos: 50%; font-size: 16px; }
.bubble-text { padding: 0.8em; word-wrap: break-word; white-space: pre-wrap; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; border-radius: inherit; pointer-events: none; }
.speech-bubble.selected { outline: 2px dashed var(--accent-blue); z-index: 100; }
/* CSS BUBBLE SHAPES (SAME AS BEFORE) */
.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: #eee; border: 2px solid #000; color: #000; border-radius: 0; font-family: 'Lato', sans-serif; box-shadow: 2px 2px 5px rgba(0,0,0,0.3); }
.resize-handle { position: absolute; bottom:-5px; right:-5px; width:10px; height:10px; background:var(--accent-blue); border:1px solid white; cursor:se-resize; display:none; }
.speech-bubble.selected .resize-handle { display:block; }
/* TOOLBAR */
.edit-controls { position: fixed; bottom: 20px; right: 20px; width: 280px; background: var(--bg-panel); color: #ccc; padding: 15px; border: 1px solid var(--border-color); box-shadow: 0 5px 25px rgba(0,0,0,0.6); z-index: 900; font-size: 12px; max-height: 90vh; overflow-y: auto; }
.edit-controls h4 { margin-top:0; color: var(--text-light); border-bottom: 1px solid var(--border-color); padding-bottom: 10px; text-transform: uppercase; font-size: 11px; letter-spacing: 1px; }
.control-group { margin-top: 15px; padding-bottom: 5px; }
.control-group label { font-weight: 600; display: block; margin-bottom: 5px; font-size: 11px; color: #aaa; text-transform: uppercase; }
button { width: 100%; margin-top: 5px; padding: 8px; background: var(--bg-light); color: var(--text-light); border: 1px solid var(--border-color); cursor: pointer; font-weight: 600; font-size: 11px; text-transform: uppercase; transition: 0.1s; }
button:hover { background: var(--border-color); }
.button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
.color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
.font-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 5px; }
.action-btn { background: var(--accent-blue); border: none; }
.action-btn:hover { background: var(--accent-hover); }
.reset-btn { background: #c0392b; border: none; }
.tip { text-align:center; padding:8px; background: var(--bg-panel); color: #888; border: 1px solid var(--border-color); font-size: 11px; margin-bottom: 20px; }
/* MODALS */
.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); display: none; justify-content: center; align-items: center; z-index: 2000; }
.modal-content { background: var(--bg-panel); padding: 30px; border: 1px solid var(--border-color); width: 90%; max-width: 400px; text-align: center; color: var(--text-light); }
.code { font-size: 24px; font-weight: bold; letter-spacing: 3px; background: var(--bg-dark); color: var(--accent-blue); padding: 15px; margin: 15px 0; display: inline-block; font-family: monospace; border: 1px solid var(--border-color); }
</style>
</head> <body>
<div id="splash-screen">
<div class="splash-logo">Puntoon</div>
<div class="splash-sub">Comic Studio 2026</div>
</div>
<div id="upload-container">
<div class="upload-box">
<h1>Create Project</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">Select Video Source</label>
<span id="fn" style="margin-bottom:15px; display:block; color:#777; font-size:12px;">No media selected</span>
<div class="page-input-group">
<label>Length (Pages)</label>
<input type="number" id="page-count" value="4" min="1" max="15">
</div>
<button class="submit-btn" onclick="upload()">Start Rendering</button>
<button id="restore-draft-btn" class="reset-btn" style="display:none; margin-top:10px; background:#444;" onclick="restoreDraft()">Restore Auto-Save</button>
<div style="margin-top:20px; border-top:1px solid var(--border-color); padding-top:15px; text-align:left;">
<label style="font-size:10px; color:#777; text-transform:uppercase;">Load Project ID</label>
<div style="display:flex; gap:5px;">
<input type="text" id="load-code" placeholder="XXXX-XXXX" style="width:70%;">
<button onclick="loadComic()" style="width:30%; background:var(--bg-light);">Load</button>
</div>
</div>
<div class="loading-view" id="loading-view">
<label style="font-size:11px; text-transform:uppercase; color:#aaa;">Processing Queue</label>
<div class="progress-wrapper">
<div id="progress-bar-fill" class="progress-bar"></div>
</div>
<div class="status-detail">
<span id="status-text">Initializing...</span>
<span id="status-percent">0%</span>
</div>
</div>
</div>
</div>
<div id="editor-container">
<div class="tip">DRAG PANEL HANDLES TO ADJUST LAYOUT • SCROLL TO ZOOM</div>
<div class="comic-wrapper" id="comic-container"></div>
<input type="file" id="image-uploader" style="display: none;" accept="image/*">
<div class="edit-controls">
<h4>Properties</h4>
<div class="control-group">
<div class="button-grid">
<button onclick="undo()" style="background:#444;">Undo</button>
<button onclick="saveComic()" style="background:var(--accent-blue); color:white;">Save Cloud</button>
</div>
</div>
<div class="control-group">
<label>Text Bubble</label>
<select id="bubble-type" onchange="updateBubbleType()">
<option value="speech">Standard Speech</option>
<option value="thought">Thought Cloud</option>
<option value="reaction">Burst / Reaction</option>
<option value="narration">Narration Box</option>
</select>
<div class="font-grid">
<select id="font-select" onchange="updateFont()">
<option value="'Comic Neue', cursive">Comic Sans</option>
<option value="'Bangers', cursive">Action</option>
<option value="'Gloria Hallelujah', cursive">Handwritten</option>
<option value="'Lato', sans-serif">Modern</option>
</select>
<input type="number" id="font-size-input" value="16" min="8" max="100" title="Size" onchange="updateFontSize()">
</div>
<div class="color-grid">
<input type="color" id="bub-fill" value="#ffffff" onchange="updateColors()" title="Fill">
<input type="color" id="bub-text" value="#000000" onchange="updateColors()" title="Text">
</div>
<div class="button-grid">
<button onclick="addBubble()" class="action-btn">Add New</button>
<button onclick="deleteBubble()" class="reset-btn">Remove</button>
</div>
</div>
<div class="control-group" id="tail-controls">
<label>Tail Orientation</label>
<div class="button-grid" style="margin-bottom:5px;">
<button onclick="setTail('tail-top')">Up</button>
<button onclick="setTail('tail-bottom')">Down</button>
<button onclick="setTail('tail-left')">Left</button>
<button onclick="setTail('tail-right')">Right</button>
</div>
<button onclick="toggleTailFlip()" style="margin-bottom:5px;">Flip Curve</button>
<input type="range" min="10" max="90" value="50" oninput="slideTail(this.value)" style="width:100%">
</div>
<div class="control-group">
<label>Panel Source</label>
<div style="display:flex; gap:5px; margin-bottom:5px;">
<input type="text" id="timestamp-input" placeholder="00:00">
<button onclick="gotoTimestamp()" style="width:40%">Jump</button>
</div>
<div class="button-grid">
<button onclick="adjustFrame('backward')">&laquo; Prev Frame</button>
<button onclick="adjustFrame('forward')">Next Frame &raquo;</button>
</div>
<button onclick="replaceImage()" style="margin-top:5px;">Upload Custom Image</button>
</div>
<div class="control-group">
<label>Viewport</label>
<input type="range" id="zoom-slider" min="20" max="300" value="100" step="5" oninput="handleZoom(this.value)" disabled>
<button onclick="resetPanelTransform()">Reset View</button>
</div>
<div class="control-group" style="margin-top:30px; border-top:1px solid var(--border-color); padding-top:10px;">
<button onclick="exportComic()" class="action-btn" style="padding:12px; font-size:12px;">Export PNG (864x1080)</button>
<button onclick="location.reload()" style="margin-top:10px; background:transparent; border:none; color:#555;">Exit Project</button>
</div>
</div>
</div>
<div class="modal-overlay" id="save-modal">
<div class="modal-content">
<h2 style="color:var(--accent-blue)">Project Saved</h2>
<div class="code" id="modal-code">XXXX</div>
<p style="font-size:12px; color:#888;">Use this ID to load your project later.</p>
<button onclick="closeModal()" class="action-btn" style="width:50%">Close</button>
</div>
</div>
<script>
// SPLASH SCREEN LOGIC
window.addEventListener('load', () => {
setTimeout(() => {
const splash = document.getElementById('splash-screen');
splash.style.opacity = '0';
setTimeout(() => splash.style.display = 'none', 800);
}, 1500);
});
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 interval, selectedBubble = null, selectedPanel = null;
let dragType = null, activeObj = null, dragStart = {x:0, y:0};
let historyStack = [];
let placingBubble = false;
// EXPORT PASSWORD LOGIC
function checkExportPassword() {
let p = prompt("ENTER EXPORT PASSWORD:", "");
if(p === "puntoon@2026") return true;
alert("ACCESS DENIED: INCORRECT PASSWORD");
return false;
}
function formatTime(s) {
s = parseFloat(s);
let m = Math.floor(s / 60);
let sec = Math.floor(s % 60);
let ms = Math.round((s % 1) * 100);
return `${m < 10 ? '0'+m : m}:${sec < 10 ? '0'+sec : sec}.${ms}`;
}
function parseTime(str) {
if(str.includes(':')) {
let p = str.split(':');
return parseInt(p[0]) * 60 + parseFloat(p[1]);
}
return parseFloat(str);
}
function saveState() {
const state = [];
document.querySelectorAll('.comic-page').forEach(pg => {
const grid = pg.querySelector('.comic-grid');
const layout = { t1: grid.style.getPropertyValue('--t1')||'100%', t2: grid.style.getPropertyValue('--t2')||'100%', b1: grid.style.getPropertyValue('--b1')||'100%', b2: grid.style.getPropertyValue('--b2')||'100%' };
const bubbles = [];
grid.querySelectorAll('.speech-bubble').forEach(b => {
bubbles.push({
text: b.querySelector('.bubble-text').textContent,
left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
type: b.dataset.type, font: b.style.fontFamily,
fontSize: b.style.fontSize || '16px',
colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') },
tailPos: b.style.getPropertyValue('--tail-pos'),
classes: b.className
});
});
const panels = [];
grid.querySelectorAll('.panel').forEach(pan => {
const img = pan.querySelector('img');
const srcParts = img.src.split('frames/');
const fname = srcParts.length > 1 ? srcParts[1].split('?')[0] : '';
panels.push({ image: fname, time: img.dataset.time, zoom: img.dataset.zoom, tx: img.dataset.translateX, ty: img.dataset.translateY });
});
state.push({ layout, bubbles, panels });
});
historyStack.push(JSON.stringify(state));
if(historyStack.length > 20) historyStack.shift();
localStorage.setItem('comic_draft_'+sid, JSON.stringify(state));
}
function undo() {
if(historyStack.length > 1) {
historyStack.pop();
const prev = JSON.parse(historyStack[historyStack.length-1]);
restoreFromState(prev);
}
}
function restoreFromState(stateData) {
if(!stateData) return;
const pages = document.querySelectorAll('.comic-page');
stateData.forEach((pgData, i) => {
if(i >= pages.length) return;
const grid = pages[i].querySelector('.comic-grid');
if(pgData.layout) {
grid.style.setProperty('--t1', pgData.layout.t1); grid.style.setProperty('--t2', pgData.layout.t2);
grid.style.setProperty('--b1', pgData.layout.b1); grid.style.setProperty('--b2', pgData.layout.b2);
}
grid.querySelectorAll('.speech-bubble').forEach(b=>b.remove());
pgData.bubbles.forEach(bData => { const b = createBubbleHTML(bData); grid.appendChild(b); });
const panels = grid.querySelectorAll('.panel');
pgData.panels.forEach((pData, pi) => {
if(pi < panels.length) {
const img = panels[pi].querySelector('img');
img.dataset.zoom = pData.zoom; img.dataset.tx = pData.tx; img.dataset.ty = pData.ty;
img.dataset.time = pData.time;
if(pData.image) img.src = `/frames/${pData.image}?sid=${sid}`;
updateImageTransform(img);
}
});
});
}
if(localStorage.getItem('comic_draft_'+sid)) document.getElementById('restore-draft-btn').style.display='inline-block';
function restoreDraft() {
document.getElementById('upload-container').style.display='none';
document.getElementById('editor-container').style.display='flex';
loadNewComic().then(() => {
setTimeout(() => restoreFromState(JSON.parse(localStorage.getItem('comic_draft_'+sid))), 500);
});
}
async function upload() {
const f = document.getElementById('file-upload').files[0];
const pCount = document.getElementById('page-count').value;
if(!f) return alert("Select video");
sid = genUUID(); localStorage.setItem('comic_sid', sid);
// UI Change for Processing
const btn = document.querySelector('.submit-btn');
btn.disabled = true; btn.style.opacity = '0.5';
document.getElementById('loading-view').style.display='block';
const fd = new FormData(); fd.append('file', f); fd.append('target_pages', pCount); fd.append('sid', sid);
const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
if(r.ok) interval = setInterval(checkStatus, 1000);
else { const d = await r.json(); alert("Upload failed: " + d.message); 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 >= 0) {
const bar = document.getElementById('progress-bar-fill');
bar.style.width = d.progress + '%';
document.getElementById('status-percent').innerText = d.progress + '%';
}
if(d.progress >= 100) { clearInterval(interval); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='flex'; loadNewComic(); }
} catch(e) {}
}
async function loadNewComic() {
const r = await fetch(`/output/pages.json?sid=${sid}`);
const data = await r.json();
const cleanData = data.map(p => ({
panels: p.panels.map(pan => ({ src: `/frames/${pan.image}?sid=${sid}`, time: pan.time })),
bubbles: p.bubbles.map(b => ({
text: b.dialog, left: (b.bubble_offset_x||50)+'px', top: (b.bubble_offset_y||20)+'px', type: b.type,
colors: b.colors, font: b.font, classes: b.classes, tailPos: b.tail_pos, fontSize: b.font_size
}))
}));
renderFromState(cleanData);
saveState();
}
function renderFromState(pagesData) {
const con = document.getElementById('comic-container'); con.innerHTML = '';
pagesData.forEach((page, pageIdx) => {
const pageWrapper = document.createElement('div'); pageWrapper.className = 'page-wrapper';
// Header with Download Button
const header = document.createElement('div');
header.style.width = '864px';
header.style.display = 'flex';
header.style.justifyContent = 'space-between';
header.style.alignItems = 'center';
header.style.marginBottom = '10px';
const title = document.createElement('h2');
title.innerText = `PAGE ${pageIdx + 1}`;
title.style.margin = '0';
title.style.fontSize = '14px';
title.style.color = '#777';
const dlBtn = document.createElement('button');
dlBtn.innerText = 'EXPORT PAGE';
dlBtn.style.width = 'auto';
dlBtn.style.padding = '5px 15px';
dlBtn.onclick = () => downloadSinglePage(pageIdx);
header.appendChild(title);
header.appendChild(dlBtn);
pageWrapper.appendChild(header);
const div = document.createElement('div');
div.className = 'comic-page';
div.id = `page-node-${pageIdx}`;
const grid = document.createElement('div'); grid.className = 'comic-grid';
page.panels.forEach((pan, idx) => {
const pDiv = document.createElement('div'); pDiv.className = 'panel';
const img = document.createElement('img');
img.src = pan.src.includes('?') ? pan.src : pan.src + `?sid=${sid}`;
img.dataset.zoom = 100; img.dataset.translateX = 0; img.dataset.translateY = 0;
img.dataset.time = pan.time.toFixed(2);
img.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); selectPanel(pDiv); dragType = 'pan'; activeObj = img; dragStart = {x:e.clientX, y:e.clientY}; img.classList.add('panning'); };
img.onwheel = (e) => { e.preventDefault(); let zoom = parseFloat(img.dataset.zoom); zoom += e.deltaY * -0.1; zoom = Math.min(Math.max(20, zoom), 300); img.dataset.zoom = zoom; updateImageTransform(img); if(selectedPanel === pDiv) document.getElementById('zoom-slider').value = zoom; saveState(); };
pDiv.appendChild(img); grid.appendChild(pDiv);
});
grid.append(createHandle('h-t1', grid, 't1'), createHandle('h-t2', grid, 't2'), createHandle('h-b1', grid, 'b1'), createHandle('h-b2', grid, 'b2'));
if(page.bubbles) {
page.bubbles.forEach((bData, bIdx) => {
if(bData.text) {
const b = createBubbleHTML(bData);
grid.appendChild(b);
}
});
}
div.appendChild(grid); pageWrapper.appendChild(div); con.appendChild(pageWrapper);
});
}
function createHandle(cls, grid, varName) {
let h = document.createElement('div'); h.className = `handle ${cls}`;
h.onmousedown = (e) => { e.stopPropagation(); dragType = 'handle'; activeObj = { grid: grid, var: varName }; };
return h;
}
function createBubbleHTML(data) {
const b = document.createElement('div');
const type = data.type || 'speech';
let className = `speech-bubble ${type}`;
if(type === 'speech' || type === 'thought' || type === 'reaction') {
if(data.classes && data.classes.includes('tail-')) className = data.classes;
else className += ' tail-bottom';
if(data.classes && data.classes.includes('tail-flip')) className += ' tail-flip';
}
b.className = className;
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.fontSize) b.style.fontSize = data.fontSize;
if(data.colors) { b.style.setProperty('--bubble-fill', data.colors.fill); b.style.setProperty('--bubble-text', data.colors.text); }
if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
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); } }
const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || 'Text'; b.appendChild(textSpan);
const resizer = document.createElement('div'); resizer.className = 'resize-handle';
resizer.onmousedown = (e) => { e.stopPropagation(); dragType='resize'; activeObj={b:b, startW:b.offsetWidth, startH:b.offsetHeight, mx:e.clientX, my:e.clientY}; };
b.appendChild(resizer);
b.onmousedown = (e) => { if(e.target === resizer) return; e.stopPropagation(); selectBubble(b); dragType = 'bubble'; activeObj = b; dragStart = {x: e.clientX, y: e.clientY}; };
b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
return b;
}
function editBubbleText(bubble) {
const textSpan = bubble.querySelector('.bubble-text');
const newText = prompt("Edit Text:", textSpan.textContent);
if(newText !== null) { textSpan.textContent = newText; saveState(); }
}
document.addEventListener('mousemove', (e) => {
if(!dragType) return;
if(dragType === 'handle') {
const rect = activeObj.grid.getBoundingClientRect();
let x = (e.clientX - rect.left) / rect.width * 100;
activeObj.grid.style.setProperty(`--${activeObj.var}`, Math.max(0, Math.min(100, x))+'%');
} else if(dragType === 'pan') {
const dx = e.clientX - dragStart.x; const dy = e.clientY - dragStart.y;
const img = activeObj;
img.dataset.translateX = parseFloat(img.dataset.translateX) + dx;
img.dataset.translateY = parseFloat(img.dataset.translateY) + dy;
updateImageTransform(img); dragStart = {x: e.clientX, y: e.clientY};
} else if(dragType === 'bubble') {
const rect = activeObj.parentElement.getBoundingClientRect();
activeObj.style.left = (e.clientX - rect.left - (activeObj.offsetWidth/2)) + 'px';
activeObj.style.top = (e.clientY - rect.top - (activeObj.offsetHeight/2)) + 'px';
} else if(dragType === 'resize') {
const dx = e.clientX - activeObj.mx; const dy = e.clientY - activeObj.my;
activeObj.b.style.width = (activeObj.startW + dx) + 'px';
activeObj.b.style.height = (activeObj.startH + dy) + 'px';
}
});
document.addEventListener('mouseup', () => {
if(activeObj && activeObj.classList) activeObj.classList.remove('panning');
if(dragType) saveState();
dragType = null; activeObj = null;
});
document.addEventListener('mousedown', (e) => {
if(!placingBubble) return;
const grid = e.target.closest('.comic-grid');
if(grid) {
e.preventDefault(); e.stopPropagation();
const rect = grid.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const b = createBubbleHTML({ text: "Text", left: (x - 40) + "px", top: (y - 25) + "px", type: "speech" });
grid.appendChild(b);
selectBubble(b);
saveState();
placingBubble = false;
document.body.style.cursor = 'default';
} else {
placingBubble = false;
document.body.style.cursor = 'default';
}
}, true);
function selectBubble(el) {
if(selectedBubble) selectedBubble.classList.remove('selected');
selectedBubble = el; el.classList.add('selected');
document.getElementById('bubble-type').value = el.dataset.type;
document.getElementById('font-select').value = el.style.fontFamily || "'Comic Neue', cursive";
const fs = window.getComputedStyle(el).fontSize;
document.getElementById('font-size-input').value = parseInt(fs) || 16;
const showTail = (el.dataset.type === 'speech' || el.dataset.type === 'thought' || el.dataset.type === 'reaction');
document.getElementById('tail-controls').style.display = showTail ? 'block' : 'none';
}
function selectPanel(el) {
if(selectedPanel) selectedPanel.classList.remove('selected');
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;
if(img.dataset.time) document.getElementById('timestamp-input').value = formatTime(img.dataset.time);
}
function addBubble() { placingBubble = true; document.body.style.cursor = 'crosshair'; }
function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); selectedBubble=null; saveState(); } }
function updateBubbleType() {
if(!selectedBubble) return;
const type = document.getElementById('bubble-type').value;
const oldB = selectedBubble;
const data = {
text: oldB.querySelector('.bubble-text').textContent,
left: oldB.style.left, top: oldB.style.top, width: oldB.style.width, height: oldB.style.height,
type: type, font: oldB.style.fontFamily, fontSize: oldB.style.fontSize,
colors: { fill: oldB.style.getPropertyValue('--bubble-fill'), text: oldB.style.getPropertyValue('--bubble-text') },
tailPos: oldB.style.getPropertyValue('--tail-pos')
};
const newB = createBubbleHTML(data);
oldB.parentElement.replaceChild(newB, oldB);
selectBubble(newB); saveState();
}
function updateColors() { if(!selectedBubble) return; selectedBubble.style.setProperty('--bubble-fill', document.getElementById('bub-fill').value); selectedBubble.style.setProperty('--bubble-text', document.getElementById('bub-text').value); saveState(); }
function updateFont() { if(selectedBubble) { selectedBubble.style.fontFamily = document.getElementById('font-select').value; saveState(); } }
function updateFontSize() { if(selectedBubble) { selectedBubble.style.fontSize = document.getElementById('font-size-input').value + 'px'; saveState(); } }
function slideTail(val) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', val+'%'); saveState(); } }
function setTail(dir) {
if(!selectedBubble) return;
const hasFlip = selectedBubble.classList.contains('tail-flip');
selectedBubble.classList.remove('tail-bottom', 'tail-top', 'tail-left', 'tail-right');
selectedBubble.classList.add(dir);
if(hasFlip) selectedBubble.classList.add('tail-flip');
saveState();
}
function toggleTailFlip() { if(selectedBubble) { selectedBubble.classList.toggle('tail-flip'); saveState(); } }
function handleZoom(val) { if(selectedPanel) { const img = selectedPanel.querySelector('img'); img.dataset.zoom = val; updateImageTransform(img); saveState(); } }
function updateImageTransform(img) { const z = (img.dataset.zoom||100)/100, x = img.dataset.translateX||0, y = img.dataset.translateY||0; img.style.transform = `translateX(${x}px) translateY(${y}px) scale(${z})`; }
function resetPanelTransform() { if(selectedPanel) { const img = selectedPanel.querySelector('img'); img.dataset.zoom=100; img.dataset.translateX=0; img.dataset.translateY=0; updateImageTransform(img); document.getElementById('zoom-slider').value=100; saveState(); } }
function replaceImage() {
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 r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd});
const d = await r.json();
if(d.success) {
const img = selectedPanel.querySelector('img');
img.src = `/frames/${d.new_filename}?sid=${sid}`;
saveState();
}
inp.value = '';
};
inp.click();
}
async function adjustFrame(dir) {
if(!selectedPanel) return alert("Click a panel first");
const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0];
img.style.opacity='0.5';
const r = await fetch(`/regenerate_frame?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, direction:dir}) });
const d = await r.json();
if(d.success) {
img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
img.dataset.time = d.new_time.toFixed(2);
document.getElementById('timestamp-input').value = formatTime(d.new_time);
}
img.style.opacity='1'; saveState();
}
async function gotoTimestamp() {
if(!selectedPanel) return alert("Select a panel");
let v = document.getElementById('timestamp-input').value.trim();
if(!v) return;
let s = parseTime(v);
if(isNaN(s)) return alert("Invalid Time");
const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().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.toFixed(2);
document.getElementById('timestamp-input').value = formatTime(d.new_time);
}
img.style.opacity = '1'; saveState();
}
async function downloadSinglePage(idx) {
if(!checkExportPassword()) return; // PASSWORD CHECK
if(selectedBubble) selectedBubble.classList.remove('selected');
selectedBubble = null;
if(selectedPanel) selectedPanel.classList.remove('selected');
selectedPanel = null;
const node = document.getElementById(`page-node-${idx}`);
// FORCE 864x1080 EXPORT (Pixel Ratio 1 with exact dimensions)
const u = await htmlToImage.toPng(node, {
width: 864,
height: 1080,
pixelRatio: 1,
filter: (node) => {
return (!node.classList || (!node.classList.contains('handle') && !node.classList.contains('resize-handle')));
}
});
const a = document.createElement('a'); a.href=u; a.download=`Puntoon_Page_${idx+1}.png`; a.click();
}
async function exportComic() {
if(!checkExportPassword()) return; // PASSWORD CHECK
const pgs = document.querySelectorAll('.comic-page');
for(let i=0; i<pgs.length; i++) {
// Re-call downloadSinglePage but bypass password check for loop?
// Better to copy logic to avoid multiple prompts.
// Simplified: just trigger downloads.
if(selectedBubble) selectedBubble.classList.remove('selected');
selectedBubble = null;
if(selectedPanel) selectedPanel.classList.remove('selected');
selectedPanel = null;
const node = pgs[i];
const u = await htmlToImage.toPng(node, {
width: 864,
height: 1080,
pixelRatio: 1,
filter: (node) => { return (!node.classList || (!node.classList.contains('handle') && !node.classList.contains('resize-handle'))); }
});
const a = document.createElement('a'); a.href=u; a.download=`Puntoon_Page_${i+1}.png`; a.click();
}
}
async function saveComic() {
const r = await fetch(`/save_comic?sid=${sid}`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({pages:getCurrentState()})});
const d = await r.json();
if(d.success) { document.getElementById('modal-code').innerText=d.code; document.getElementById('save-modal').style.display='flex'; }
}
async function loadComic() {
const code = document.getElementById('load-code').value;
const r = await fetch(`/load_comic/${code}`);
const d = await r.json();
if(d.success) { sid=d.originalSid; localStorage.setItem('comic_sid', sid); restoreFromState(d.pages); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='flex'; }
else alert(d.message);
}
function closeModal() { document.getElementById('save-modal').style.display='none'; }
function getCurrentState() {
const state = [];
document.querySelectorAll('.comic-page').forEach(pg => {
const grid = pg.querySelector('.comic-grid');
const layout = { t1: grid.style.getPropertyValue('--t1')||'100%', t2: grid.style.getPropertyValue('--t2')||'100%', b1: grid.style.getPropertyValue('--b1')||'100%', b2: grid.style.getPropertyValue('--b2')||'100%' };
const bubbles = [];
grid.querySelectorAll('.speech-bubble').forEach(b => {
bubbles.push({
text: b.querySelector('.bubble-text').textContent,
left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
type: b.dataset.type, font: b.style.fontFamily,
fontSize: b.style.fontSize || '16px',
colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') },
tailPos: b.style.getPropertyValue('--tail-pos'),
classes: b.className
});
});
const panels = [];
grid.querySelectorAll('.panel').forEach(pan => {
const img = pan.querySelector('img');
const srcParts = img.src.split('frames/');
const fname = srcParts.length > 1 ? srcParts[1].split('?')[0] : '';
panels.push({ image: fname, time: img.dataset.time, zoom: img.dataset.zoom, tx: img.dataset.translateX, ty: img.dataset.translateY });
});
state.push({ layout, bubbles, panels });
});
return state;
}
</script>
</body> </html>
'''
# ======================================================
# 🚀 FLASK ROUTES
# ======================================================
@app.route('/')
def index():
return INDEX_HTML
@app.route('/uploader', methods=['POST'])
def upload_file():
try:
sid = request.args.get('sid')
if not sid: return jsonify({'message': 'No SID'}), 400
target_pages = request.form.get('target_pages', 4)
file = request.files['file']
user_dir = os.path.join(BASE_USER_DIR, sid)
os.makedirs(user_dir, exist_ok=True)
video_path = os.path.join(user_dir, 'uploaded.mp4')
file.save(video_path)
generator = EnhancedComicGenerator(sid)
generator.cleanup()
thread = threading.Thread(target=generator.run, args=(target_pages,))
thread.start()
return jsonify({'message': 'Upload successful, processing started.'})
except Exception as e:
traceback.print_exc()
return jsonify({'message': str(e)}), 500
@app.route('/status')
def status():
sid = request.args.get('sid')
status_path = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
if os.path.exists(status_path):
try:
with open(status_path, 'r') as f: return jsonify(json.load(f))
except: return jsonify({'message': 'Reading status...', 'progress': 0})
return jsonify({'message': 'Waiting...', 'progress': 0})
@app.route('/frames/<path:filename>')
def serve_frame(filename):
sid = request.args.get('sid')
if not sid: return "Missing SID", 400
return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
@app.route('/output/<path:filename>')
def serve_output(filename):
sid = request.args.get('sid')
if not sid: return "Missing SID", 400
return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
@app.route('/regenerate_frame', methods=['POST'])
def regenerate_frame():
data = request.json
sid = request.args.get('sid')
return jsonify(regen_frame_cpu(
os.path.join(BASE_USER_DIR, sid, 'uploaded.mp4'),
os.path.join(BASE_USER_DIR, sid, 'frames'),
os.path.join(BASE_USER_DIR, sid, 'frames', 'frame_metadata.json'),
data['filename'],
data['direction']
))
@app.route('/goto_timestamp', methods=['POST'])
def goto_timestamp():
data = request.json
sid = request.args.get('sid')
return jsonify(get_frame_at_ts_cpu(
os.path.join(BASE_USER_DIR, sid, 'uploaded.mp4'),
os.path.join(BASE_USER_DIR, sid, 'frames'),
os.path.join(BASE_USER_DIR, sid, 'frames', 'frame_metadata.json'),
data['filename'],
data['timestamp']
))
@app.route('/replace_panel', methods=['POST'])
def replace_panel():
sid = request.args.get('sid')
f = request.files['image']
# Changed to .jpg
fname = f"custom_{int(time.time())}.jpg"
save_path = os.path.join(BASE_USER_DIR, sid, 'frames', fname)
f.save(save_path)
# Resize and save as JPG
img = cv2.imread(save_path)
if img is not None:
img = cv2.resize(img, (1920, 1080))
cv2.imwrite(save_path, img, [int(cv2.IMWRITE_JPEG_QUALITY), 90])
return jsonify({'success': True, 'new_filename': fname})
@app.route('/save_comic', methods=['POST'])
def save_comic():
try:
sid = request.args.get('sid')
data = request.json
code = generate_save_code()
save_dir = os.path.join(SAVED_COMICS_DIR, code)
os.makedirs(save_dir, exist_ok=True)
# Save JSON State
with open(os.path.join(save_dir, 'comic_data.json'), 'w') as f:
json.dump({'originalSid': sid, 'pages': data['pages']}, f)
# Copy Frames
src_frames = os.path.join(BASE_USER_DIR, sid, 'frames')
dest_frames = os.path.join(save_dir, 'frames')
if os.path.exists(src_frames):
shutil.copytree(src_frames, dest_frames)
return jsonify({'success': True, 'code': code})
except Exception as e:
traceback.print_exc()
return jsonify({'success': False, 'message': str(e)})
@app.route('/load_comic/<code>')
def load_comic_route(code):
save_dir = os.path.join(SAVED_COMICS_DIR, code)
if not os.path.exists(save_dir): return jsonify({'success': False, 'message': 'Comic not found'})
with open(os.path.join(save_dir, 'comic_data.json'), 'r') as f:
data = json.load(f)
# Restore to a new active session
new_sid = f"restored_{code}_{int(time.time())}"
new_user_dir = os.path.join(BASE_USER_DIR, new_sid)
os.makedirs(new_user_dir, exist_ok=True)
if os.path.exists(os.path.join(save_dir, 'frames')):
shutil.copytree(os.path.join(save_dir, 'frames'), os.path.join(new_user_dir, 'frames'))
# We need to make sure the output dir exists for status checks (dummy)
os.makedirs(os.path.join(new_user_dir, 'output'), exist_ok=True)
with open(os.path.join(new_user_dir, 'output', 'pages.json'), 'w') as f:
f.write("[]") # Placeholder
return jsonify({'success': True, 'originalSid': new_sid, 'pages': data['pages']})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=7860)