| | 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 |
| |
|
| | |
| | warnings.filterwarnings("ignore") |
| |
|
| | |
| | |
| | |
| | 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 |
| |
|
| | 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 |
| |
|
| | |
| | |
| | |
| | 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 |
| |
|
| | |
| | |
| | |
| |
|
| | 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) |
| | |
| | |
| | resized = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) |
| | canvas = np.zeros((target_h, target_w, 3), dtype=np.uint8) |
| | |
| | |
| | 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: |
| | |
| | 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 |
| |
|
| | |
| | |
| | |
| |
|
| | 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 |
| |
|
| | |
| | |
| | |
| | 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> |
| | ''' |
| |
|
| | |
| | |
| | |
| |
|
| | @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) |