tester343 commited on
Commit
4e345b6
·
verified ·
1 Parent(s): edc1e07

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +231 -193
app_enhanced.py CHANGED
@@ -2,35 +2,30 @@ import spaces # <--- CRITICAL: MUST BE FIRST
2
  import os
3
  import time
4
  import threading
5
- import uuid
6
- import shutil
7
  import json
8
  import traceback
9
  import logging
10
  import string
11
  import random
 
12
  import cv2
 
13
  import numpy as np
14
  import srt
15
- from concurrent.futures import ThreadPoolExecutor
16
- from flask import Flask, render_template, request, jsonify, send_from_directory, send_file
17
 
18
  # ======================================================
19
- # 🚀 ZEROGPU WARMUP (Required for HF Detection)
20
  # ======================================================
21
  @spaces.GPU
22
  def gpu_warmup():
23
  import torch
24
- print(f"✅ ZeroGPU Active. CUDA: {torch.cuda.is_available()}")
25
  return True
26
 
27
  # ======================================================
28
- # 🔧 CONFIG & LOGGING
29
  # ======================================================
30
- logging.basicConfig(level=logging.INFO)
31
- logger = logging.getLogger(__name__)
32
-
33
- # --- 2. ROBUST DATA CLASSES (Defined locally to avoid Import Errors) ---
34
  def bubble(dialog="", bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, emotion='normal'):
35
  return {
36
  'dialog': dialog,
@@ -49,7 +44,12 @@ class Page:
49
  self.panels = panels
50
  self.bubbles = bubbles
51
 
52
- # --- 3. FLASK SETUP ---
 
 
 
 
 
53
  app = Flask(__name__)
54
  BASE_USER_DIR = "userdata"
55
  SAVED_COMICS_DIR = "saved_comics"
@@ -65,16 +65,16 @@ def generate_save_code(length=8):
65
  return code
66
 
67
  # ======================================================
68
- # 🧠 GLOBAL GPU FUNCTIONS (The Engine)
69
  # ======================================================
70
 
71
  @spaces.GPU(duration=240)
72
- def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path):
73
- print(f"🚀 GPU Task Started: {video_path}")
74
 
75
- # --- Local Imports ensure they run in GPU Context ---
76
  import cv2
77
  import srt
 
78
  import numpy as np
79
  from backend.keyframes.keyframes import black_bar_crop
80
  from backend.simple_color_enhancer import SimpleColorEnhancer
@@ -98,7 +98,7 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path):
98
  if os.path.exists('test1.srt'):
99
  shutil.move('test1.srt', user_srt)
100
  except Exception as e:
101
- print(f"⚠️ Subtitle error: {e}. Using fallback.")
102
  with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\nHello\n")
103
 
104
  with open(user_srt, 'r', encoding='utf-8') as f:
@@ -107,12 +107,13 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path):
107
  key_moments = [{'index': s.index, 'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in all_subs]
108
  key_moments.sort(key=lambda x: x['start'])
109
 
110
- # 3. Extract Keyframes
111
  frame_metadata = {}
112
  cap = cv2.VideoCapture(video_path)
113
  count = 0
114
- # Limit frames for GPU stability
115
- for i, moment in enumerate(key_moments[:32]):
 
116
  mid = (moment['start'] + moment['end']) / 2
117
  if mid > duration: continue
118
  cap.set(cv2.CAP_PROP_POS_FRAMES, int(mid * fps))
@@ -138,9 +139,9 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path):
138
  print(f"🎨 Enhancing {len(frame_files)} frames...")
139
  for f in frame_files:
140
  p = os.path.join(frames_dir, f)
141
- try: se.enhance_single(p, p) # Pass output path!
142
  except: pass
143
- try: qe.enhance_single(p, p) # Pass output path!
144
  except: pass
145
 
146
  # 5. Bubbles
@@ -153,23 +154,35 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path):
153
  faces = face_detector.detect_faces(p)
154
  lip = face_detector.get_lip_position(p, faces[0]) if faces else (-1, -1)
155
  bx, by = ai_bubble_placer.place_bubble_ai(p, lip)
156
- # Use local safe function
157
  bubbles_list.append(bubble(dialog=dialogue, bubble_offset_x=bx, bubble_offset_y=by, lip_x=lip[0], lip_y=lip[1]))
158
  except:
159
  bubbles_list.append(bubble(dialog=dialogue))
160
 
161
- # 6. Layout
162
  print("📄 Generating layout...")
163
- try:
164
- from backend.fixed_12_pages_800x1080 import generate_12_pages_800x1080
165
- pages = generate_12_pages_800x1080(frame_files, bubbles_list)
166
- except:
167
- pages = []
168
- for i in range((len(frame_files)+3)//4):
169
- start = i*4
170
- pg_panels = [panel(image=f) for f in frame_files[start:start+4]]
171
- pg_bubbles = bubbles_list[start:start+4]
172
- if pg_panels: pages.append(Page(panels=pg_panels, bubbles=pg_bubbles))
 
 
 
 
 
 
 
 
 
 
 
 
 
173
 
174
  # Serialize Results
175
  result = []
@@ -185,7 +198,6 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
185
  import cv2
186
  import json
187
  from backend.simple_color_enhancer import SimpleColorEnhancer
188
- from backend.quality_color_enhancer import QualityColorEnhancer
189
 
190
  if not os.path.exists(metadata_path): return {"success": False, "message": "No metadata"}
191
  with open(metadata_path, 'r') as f: meta = json.load(f)
@@ -203,11 +215,12 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
203
 
204
  if ret:
205
  p = os.path.join(frames_dir, fname)
 
206
  cv2.imwrite(p, frame)
 
 
207
  try: SimpleColorEnhancer().enhance_single(p, p)
208
  except: pass
209
- try: QualityColorEnhancer().enhance_single(p, p)
210
- except: pass
211
 
212
  if isinstance(meta[fname], dict): meta[fname]['time'] = new_t
213
  else: meta[fname] = new_t
@@ -220,7 +233,6 @@ def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
220
  import cv2
221
  import json
222
  from backend.simple_color_enhancer import SimpleColorEnhancer
223
- from backend.quality_color_enhancer import QualityColorEnhancer
224
 
225
  cap = cv2.VideoCapture(video_path)
226
  cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
@@ -230,10 +242,10 @@ def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
230
  if ret:
231
  p = os.path.join(frames_dir, fname)
232
  cv2.imwrite(p, frame)
 
 
233
  try: SimpleColorEnhancer().enhance_single(p, p)
234
  except: pass
235
- try: QualityColorEnhancer().enhance_single(p, p)
236
- except: pass
237
 
238
  if os.path.exists(metadata_path):
239
  with open(metadata_path, 'r') as f: meta = json.load(f)
@@ -245,7 +257,7 @@ def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
245
  return {"success": False, "message": "Invalid timestamp"}
246
 
247
  # ======================================================
248
- # 💻 BACKEND HELPER CLASS
249
  # ======================================================
250
  class EnhancedComicGenerator:
251
  def __init__(self, sid):
@@ -264,11 +276,11 @@ class EnhancedComicGenerator:
264
  os.makedirs(self.frames_dir, exist_ok=True)
265
  os.makedirs(self.output_dir, exist_ok=True)
266
 
267
- def run(self):
268
  try:
269
  self.write_status("Waiting for GPU...", 5)
270
- # Call Global GPU Function
271
- data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path)
272
  with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
273
  json.dump(data, f, indent=2)
274
  self.write_status("Complete!", 100)
@@ -281,10 +293,9 @@ class EnhancedComicGenerator:
281
  json.dump({'message': msg, 'progress': prog}, f)
282
 
283
  # ======================================================
284
- # 🌐 ROUTES & HTML
285
  # ======================================================
286
 
287
- # PASTE YOUR FULL INTERACTIVE HTML HERE
288
  INDEX_HTML = '''
289
  <!DOCTYPE html>
290
  <html lang="en">
@@ -297,22 +308,37 @@ INDEX_HTML = '''
297
  <style>
298
  * { box-sizing: border-box; }
299
  body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
 
 
300
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
301
  .upload-box { max-width: 500px; width: 100%; padding: 40px; background: white; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.12); text-align: center; }
 
 
302
  #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 100px; }
 
303
  h1 { color: #2c3e50; margin-bottom: 20px; font-weight: 600; }
304
  .file-input { display: none; }
305
  .file-label { display: block; padding: 15px; background: #2c3e50; color: white; border-radius: 8px; cursor: pointer; font-weight: bold; margin-bottom: 10px; transition:0.2s; }
306
  .file-label:hover { background: #34495e; }
 
 
 
 
 
 
307
  .submit-btn { width: 100%; padding: 15px; background: #e67e22; color: white; border: none; border-radius: 8px; font-size: 18px; font-weight: bold; cursor: pointer; transition: 0.2s; }
308
  .submit-btn:hover { background: #d35400; }
309
  .restore-btn { margin-top: 10px; background: #27ae60; color: white; padding: 12px; width: 100%; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
 
310
  .load-section { margin-top: 30px; padding-top: 20px; border-top: 2px solid #eee; }
311
  .load-input-group { display: flex; gap: 10px; margin-top: 10px; }
312
  .load-input-group input { flex: 1; padding: 12px; border: 2px solid #ddd; border-radius: 8px; font-size: 16px; text-transform: uppercase; letter-spacing: 2px; text-align: center; }
313
  .load-input-group button { padding: 12px 20px; background: #3498db; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
 
314
  .loader { width: 120px; height: 20px; background: radial-gradient(circle 10px, #e67e22 100%, transparent 0); background-size: 20px 20px; animation: ball 1s infinite linear; margin: 20px auto; }
315
  @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
 
 
316
  .comic-wrapper { max-width: 1000px; margin: 0 auto; }
317
  .page-wrapper { margin: 30px auto; display: flex; flex-direction: column; align-items: center; }
318
  .page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
@@ -323,6 +349,8 @@ INDEX_HTML = '''
323
  .panel img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.1s ease-out; transform-origin: center center; }
324
  .panel img.pannable { cursor: grab; }
325
  .panel img.panning { cursor: grabbing; }
 
 
326
  .speech-bubble { position: absolute; display: flex; justify-content: center; align-items: center; width: 150px; height: 80px; min-width: 50px; min-height: 30px; box-sizing: border-box; z-index: 10; cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold; font-size: 13px; text-align: center; overflow: visible; }
327
  .bubble-text { padding: 0.8em; word-wrap: break-word; position: relative; z-index: 5; }
328
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
@@ -332,57 +360,24 @@ INDEX_HTML = '''
332
  .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))); }
333
  .speech-bubble.speech.tail-top { border-radius: min(var(--r), calc(var(--p) - (1 - var(--t)) * var(--b) / 2)) min(var(--r), calc(100% - var(--p) - (1 - var(--t)) * var(--b) / 2)) var(--r) var(--r) / var(--r); }
334
  .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); }
335
- .speech-bubble.speech.tail-left { border-radius: var(--r); }
336
- .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; }
337
- .speech-bubble.speech.tail-right { border-radius: var(--r); }
338
- .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; }
339
- .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
340
- .speech-bubble.thought::after { display:none; }
341
- .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
342
- .thought-dot-1 { width: 20px; height: 20px; bottom: -20px; left: 15px; }
343
- .thought-dot-2 { width: 12px; height: 12px; bottom: -32px; left: 5px; }
344
- .speech-bubble.flipped .thought-dot-1 { left: auto; right: 15px; }
345
- .speech-bubble.flipped .thought-dot-2 { left: auto; right: 5px; }
346
- .speech-bubble.flipped-vertical .thought-dot-1 { bottom: auto; top: -20px; }
347
- .speech-bubble.flipped-vertical .thought-dot-2 { bottom: auto; top: -32px; }
348
- .speech-bubble.reaction { background: #FFD700; border: 3px solid #E53935; color: #D32F2F; font-weight: 900; text-transform: uppercase; width: 180px; clip-path: polygon(0% 25%, 17% 21%, 17% 0%, 31% 16%, 50% 4%, 69% 16%, 83% 0%, 83% 21%, 100% 25%, 85% 45%, 95% 62%, 82% 79%, 100% 97%, 79% 89%, 60% 98%, 46% 82%, 27% 95%, 15% 78%, 5% 62%, 15% 45%); }
349
- .speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; }
350
- .speech-bubble.idea { background: linear-gradient(180deg,#FFFDD0 0%, #FFF8B5 100%); border: 2px solid #FFA500; color: #6a4b00; border-radius: 40% 60% 40% 60% / 60% 40% 60% 40%; }
351
  .resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; z-index: 11; }
352
  .speech-bubble.selected .resize-handle { display: block; }
353
  .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
354
- .resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
355
- .resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
356
- .resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
357
  .edit-controls { position: fixed; bottom: 20px; right: 20px; width: 260px; background: rgba(44, 62, 80, 0.95); color: white; padding: 15px; border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 900; font-size: 13px; max-height: 90vh; overflow-y: auto; }
358
  .edit-controls h4 { margin: 0 0 10px 0; color: #4ECDC4; text-align: center; }
359
  .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
360
- .control-group label { font-size: 11px; font-weight: bold; display: block; margin-bottom: 3px; }
361
  button, input, select { width: 100%; margin-top: 5px; padding: 6px; border-radius: 4px; border: 1px solid #ddd; cursor: pointer; font-weight: bold; font-size: 12px; }
362
- button:hover { background: #f5f5f5; }
363
  .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
364
- .color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
365
- .color-grid div { text-align: center; }
366
- .color-grid input[type="color"] { height: 30px; padding: 2px; }
367
- .slider-container { display: flex; align-items: center; gap: 5px; margin-top: 5px; }
368
- .slider-container label { min-width: 40px; font-size: 11px; }
369
- .timestamp-controls { display: grid; grid-template-columns: 1fr auto; gap: 5px; }
370
- .timestamp-controls input { color: #333; font-weight: normal; }
371
  .action-btn { background: #4CAF50; color: white; }
372
- .reset-btn { background: #e74c3c; color: white; }
373
- .secondary-btn { background: #f39c12; color: white; }
374
- .export-btn { background: #2196F3; color: white; }
375
- .save-btn { background: #9b59b6; color: white; }
376
- .save-code-display { background: #2ecc71; color: white; padding: 15px; border-radius: 8px; text-align: center; margin-top: 10px; display: none; }
377
- .save-code-display .code { font-size: 24px; font-weight: bold; letter-spacing: 3px; background: white; color: #2ecc71; padding: 10px 20px; border-radius: 4px; display: inline-block; margin: 10px 0; font-family: monospace; }
378
- .save-code-display button { background: white; color: #2ecc71; border: none; padding: 8px 15px; border-radius: 4px; cursor: pointer; font-weight: bold; margin-top: 5px; }
379
  .modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); display: none; justify-content: center; align-items: center; z-index: 9999; }
380
  .modal-content { background: white; padding: 30px; border-radius: 12px; max-width: 400px; width: 90%; text-align: center; }
381
- .modal-content h2 { color: #2ecc71; margin-bottom: 20px; }
382
  .modal-content .code { font-size: 32px; font-weight: bold; letter-spacing: 4px; background: #f0f0f0; padding: 15px 25px; border-radius: 8px; display: inline-block; margin: 15px 0; font-family: monospace; user-select: all; }
383
- .modal-content p { color: #666; margin: 10px 0; }
384
- .modal-content button { background: #3498db; color: white; border: none; padding: 12px 30px; border-radius: 8px; cursor: pointer; font-weight: bold; font-size: 14px; margin: 5px; }
385
- .modal-content button.close-btn { background: #95a5a6; }
386
  </style>
387
  </head>
388
  <body>
@@ -392,11 +387,17 @@ INDEX_HTML = '''
392
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
393
  <label for="file-upload" class="file-label">📁 Choose Video File</label>
394
  <span id="fn" style="margin-bottom:10px; display:block; color:#666;">No file selected</span>
 
 
 
 
 
 
395
  <button class="submit-btn" onclick="upload()">🚀 Generate Comic</button>
396
  <button id="restore-draft-btn" class="restore-btn" style="display:none; margin-top:15px;" onclick="restoreDraft()">📂 Restore Unsaved Draft</button>
 
397
  <div class="load-section">
398
  <h3>📥 Load Saved Comic</h3>
399
- <p style="font-size:12px; color:#888; margin-bottom:10px;">Enter your save code to continue editing</p>
400
  <div class="load-input-group">
401
  <input type="text" id="load-code-input" placeholder="SAVE CODE" maxlength="8" style="text-transform:uppercase;">
402
  <button onclick="loadSavedComic()">Load</button>
@@ -418,58 +419,25 @@ INDEX_HTML = '''
418
  <label>💾 Save & Load:</label>
419
  <button onclick="saveComic()" class="save-btn">💾 Save Comic (Get Code)</button>
420
  <div id="current-save-code" style="display:none; margin-top:8px; padding:8px; background:#2ecc71; border-radius:4px; text-align:center;">
421
- <span style="font-size:11px;">Current Save Code:</span><br>
422
  <span id="display-save-code" style="font-size:18px; font-weight:bold; letter-spacing:2px;"></span>
423
  <button onclick="copyCode()" style="padding:4px 8px; margin-left:5px; font-size:10px;">📋 Copy</button>
424
  </div>
425
  </div>
426
  <div class="control-group">
427
  <label>💬 Bubble Tools:</label>
428
- <select id="bubble-type-select" onchange="changeBubbleType(this.value)" disabled>
429
- <option value="speech">Speech</option>
430
- <option value="thought">Thought</option>
431
- <option value="reaction">Reaction</option>
432
- <option value="narration">Narration</option>
433
- <option value="idea">Idea</option>
434
- </select>
435
- <select id="font-select" onchange="changeFont(this.value)" disabled>
436
- <option value="'Comic Neue', cursive">Comic Neue</option>
437
- <option value="'Bangers', cursive">Bangers</option>
438
- <option value="'Gloria Hallelujah', cursive">Gloria</option>
439
- <option value="'Lato', sans-serif">Lato</option>
440
- </select>
441
- <div class="color-grid">
442
- <div><label>Text</label><input type="color" id="bubble-text-color" value="#ffffff" disabled></div>
443
- <div><label>Fill</label><input type="color" id="bubble-fill-color" value="#4ECDC4" disabled></div>
444
- </div>
445
  <button onclick="addBubble()" class="action-btn">💬 Add Bubble</button>
446
  <button onclick="deleteBubble()" class="reset-btn">🗑️ Delete Bubble</button>
447
  </div>
448
- <div class="control-group" id="tail-controls" style="display:none;">
449
- <label>📐 Tail Adjustment:</label>
450
- <button onclick="rotateTail()" class="secondary-btn">🔄 Rotate Side</button>
451
- <div class="slider-container">
452
- <label>Pos:</label>
453
- <input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)">
454
- </div>
455
- </div>
456
  <div class="control-group">
457
  <label>🖼️ Panel Tools:</label>
458
  <button onclick="replacePanelImage()" class="action-btn">🖼️ Replace Image</button>
459
  <div class="button-grid">
460
- <button onclick="adjustFrame('backward')" class="secondary-btn">⬅️ Prev</button>
461
- <button onclick="adjustFrame('forward')" class="action-btn">Next ➡️</button>
462
  </div>
463
  <div class="timestamp-controls">
464
  <input type="text" id="timestamp-input" placeholder="mm:ss or secs">
465
- <button onclick="gotoTimestamp()" class="action-btn">Go</button>
466
- </div>
467
- </div>
468
- <div class="control-group">
469
- <label>🔍 Zoom & Pan:</label>
470
- <div class="button-grid">
471
- <button onclick="resetPanelTransform()" class="secondary-btn">Reset</button>
472
- <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled oninput="handleZoom(this)">
473
  </div>
474
  </div>
475
  <div class="control-group">
@@ -479,12 +447,11 @@ INDEX_HTML = '''
479
  </div>
480
  </div>
481
 
 
482
  <div class="modal-overlay" id="save-modal">
483
  <div class="modal-content">
484
  <h2>✅ Comic Saved!</h2>
485
- <p>Your unique save code is:</p>
486
  <div class="code" id="modal-save-code">XXXXXXXX</div>
487
- <p style="font-size:12px;">Write this code down or copy it.<br>Anyone can load this comic using this code.</p>
488
  <button onclick="copyModalCode()">📋 Copy Code</button>
489
  <button class="close-btn" onclick="closeModal()">Close</button>
490
  </div>
@@ -494,53 +461,62 @@ INDEX_HTML = '''
494
  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);}); }
495
  let sid = localStorage.getItem('comic_sid') || genUUID();
496
  localStorage.setItem('comic_sid', sid);
 
497
  let currentSaveCode = null;
 
498
  let interval, selectedBubble = null, selectedPanel = null;
499
- let isDragging = false, isResizing = false, isPanning = false;
500
- let startX, startY, initX, initY, initW, initH;
501
- let panStartX, panStartY, panStartTx, panStartTy;
502
  let resizeHandle = '', originalWidth, originalHeight, originalX, originalY, originalMouseX, originalMouseY;
503
  let currentlyEditing = null;
504
 
505
  if(localStorage.getItem('comic_draft_'+sid)) document.getElementById('restore-draft-btn').style.display = 'block';
506
 
 
507
  function showSaveModal(code) { document.getElementById('modal-save-code').textContent = code; document.getElementById('save-modal').style.display = 'flex'; }
508
  function closeModal() { document.getElementById('save-modal').style.display = 'none'; }
509
- function copyModalCode() { navigator.clipboard.writeText(document.getElementById('modal-save-code').textContent).then(() => alert('Code copied to clipboard!')); }
510
  function copyCode() { if(currentSaveCode) navigator.clipboard.writeText(currentSaveCode).then(() => alert('Code copied!')); }
511
-
 
 
 
 
 
 
 
 
 
512
  async function saveComic() {
513
  const state = getCurrentState();
514
  if(!state || state.length === 0) { alert('No comic to save!'); return; }
515
  try {
516
- const response = await fetch(`/save_comic?sid=${sid}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ pages: state, savedAt: new Date().toISOString() }) });
517
- const data = await response.json();
518
- if(data.success) { currentSaveCode = data.code; document.getElementById('display-save-code').textContent = data.code; document.getElementById('current-save-code').style.display = 'block'; showSaveModal(data.code); saveDraft(); }
519
- else { alert('Failed to save: ' + data.message); }
520
  } catch(e) { console.error(e); alert('Error saving comic'); }
521
  }
522
 
523
  async function loadSavedComic() {
524
  const code = document.getElementById('load-code-input').value.trim().toUpperCase();
525
- if(!code || code.length < 4) { alert('Please enter a valid save code'); return; }
526
  try {
527
- const response = await fetch(`/load_comic/${code}`);
528
- const data = await response.json();
529
- if(data.success) { currentSaveCode = code; sid = data.originalSid || sid; localStorage.setItem('comic_sid', sid); renderFromState(data.pages); document.getElementById('upload-container').style.display = 'none'; document.getElementById('editor-container').style.display = 'block'; document.getElementById('display-save-code').textContent = code; document.getElementById('current-save-code').style.display = 'block'; saveDraft(); }
530
- else { alert('Could not load comic: ' + data.message); }
531
- } catch(e) { console.error(e); alert('Error loading comic. Check the code and try again.'); }
532
  }
533
 
534
  function restoreDraft() {
535
- const savedData = localStorage.getItem('comic_draft_'+sid);
536
- if(!savedData) { alert("No draft found."); return; }
537
  try {
538
- const state = JSON.parse(savedData);
539
  if(state.saveCode) { currentSaveCode = state.saveCode; document.getElementById('display-save-code').textContent = state.saveCode; document.getElementById('current-save-code').style.display = 'block'; }
540
  renderFromState(state.pages || state);
541
  document.getElementById('upload-container').style.display = 'none';
542
  document.getElementById('editor-container').style.display = 'block';
543
- } catch(e) { console.error(e); alert("Failed to restore draft."); }
544
  }
545
 
546
  function getCurrentState() {
@@ -552,9 +528,9 @@ INDEX_HTML = '''
552
  const bubbles = [];
553
  pan.querySelectorAll('.speech-bubble').forEach(b => {
554
  const textEl = b.querySelector('.bubble-text');
555
- bubbles.push({ text: textEl ? textEl.textContent : '', left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height, classes: b.className, type: b.dataset.type, font: b.style.fontFamily, tailPos: b.style.getPropertyValue('--tail-pos'), colors: { fill: b.style.getPropertyValue('--bubble-fill-color'), text: b.style.getPropertyValue('--bubble-text-color') } });
556
  });
557
- panels.push({ src: img.src, zoom: img.dataset.zoom, tx: img.dataset.translateX, ty: img.dataset.translateY, bubbles: bubbles });
558
  });
559
  pages.push({ panels: panels });
560
  });
@@ -575,8 +551,8 @@ INDEX_HTML = '''
575
  const pDiv = document.createElement('div'); pDiv.className = 'panel';
576
  pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
577
  const img = document.createElement('img');
578
- img.src = pan.src; img.dataset.zoom = pan.zoom || 100; img.dataset.translateX = pan.tx || 0; img.dataset.translateY = pan.ty || 0;
579
- updateImageTransform(img); img.onmousedown = (e) => startPan(e, img);
580
  pDiv.appendChild(img);
581
  (pan.bubbles || []).forEach(bData => { pDiv.appendChild(createBubbleHTML(bData)); });
582
  grid.appendChild(pDiv);
@@ -587,10 +563,17 @@ INDEX_HTML = '''
587
 
588
  async function upload() {
589
  const f = document.getElementById('file-upload').files[0];
590
- if(!f) return alert("Select a video file first");
 
591
  sid = genUUID(); localStorage.setItem('comic_sid', sid); currentSaveCode = null;
592
- document.querySelector('.upload-box').style.display='none'; document.getElementById('loading-view').style.display='flex';
593
- const fd = new FormData(); fd.append('file', f);
 
 
 
 
 
 
594
  const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
595
  if(r.ok) interval = setInterval(checkStatus, 2000);
596
  else { alert("Upload failed"); location.reload(); }
@@ -610,36 +593,26 @@ INDEX_HTML = '''
610
  const cleanData = data.map((p, pi) => ({
611
  panels: p.panels.map((pan, j) => ({
612
  src: `/frames/${pan.image}?sid=${sid}`,
613
- bubbles: (p.bubbles && p.bubbles[j] && p.bubbles[j].dialog) ? [{ text: p.bubbles[j].dialog, left: (p.bubbles[j].bubble_offset_x || 50) + 'px', top: (p.bubbles[j].bubble_offset_y || 20) + 'px', type: 'speech', tailPos: '50%', colors: { text: '#ffffff', fill: '#4ECDC4' } }] : []
614
  }))
615
  }));
616
  renderFromState(cleanData); saveDraft();
617
  });
618
  }
619
 
 
620
  function createBubbleHTML(data) {
621
- const b = document.createElement('div'); b.dataset.type = data.type || 'speech';
622
- applyBubbleType(b, data.type || 'speech', data.classes);
623
  b.style.left = data.left; b.style.top = data.top;
624
- if(data.width) b.style.width = data.width; if(data.height) b.style.height = data.height; if(data.font) b.style.fontFamily = data.font;
625
- if(data.colors) { b.style.setProperty('--bubble-fill-color', data.colors.fill || '#4ECDC4'); b.style.setProperty('--bubble-text-color', data.colors.text || '#ffffff'); }
626
- if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
627
  const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || ''; b.appendChild(textSpan);
628
  ['nw', 'ne', 'sw', 'se'].forEach(dir => { const handle = document.createElement('div'); handle.className = `resize-handle ${dir}`; handle.onmousedown = (e) => startResize(e, dir); b.appendChild(handle); });
629
  b.onmousedown = (e) => { if(e.target.classList.contains('resize-handle')) return; e.stopPropagation(); selectBubble(b); isDragging = true; startX = e.clientX; startY = e.clientY; initX = b.offsetLeft; initY = b.offsetTop; };
630
- b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); }; b.onclick = (e) => { e.stopPropagation(); selectBubble(b); };
631
  return b;
632
  }
633
 
634
- function applyBubbleType(bubble, type, existingClasses) {
635
- bubble.querySelectorAll('.thought-dot').forEach(el => el.remove());
636
- let baseClasses = 'speech-bubble ' + type;
637
- if (type === 'speech') baseClasses += ' tail-bottom';
638
- if (existingClasses && existingClasses.includes('selected')) baseClasses += ' selected';
639
- bubble.className = baseClasses; bubble.dataset.type = type;
640
- if (type === 'thought') { for (let i = 1; i <= 2; i++) { const dot = document.createElement('div'); dot.className = `thought-dot thought-dot-${i}`; bubble.appendChild(dot); } }
641
- }
642
-
643
  function editBubbleText(bubble) {
644
  if (currentlyEditing) return; currentlyEditing = bubble;
645
  const textSpan = bubble.querySelector('.bubble-text'); const textarea = document.createElement('textarea');
@@ -649,30 +622,94 @@ INDEX_HTML = '''
649
  textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); } });
650
  }
651
 
652
- document.addEventListener('mousemove', (e) => { if(isDragging && selectedBubble) { selectedBubble.style.left = (initX + e.clientX - startX) + 'px'; selectedBubble.style.top = (initY + e.clientY - startY) + 'px'; } if(isResizing && selectedBubble) { resizeBubble(e); } if(isPanning && selectedPanel) { panImage(e); } });
653
- document.addEventListener('mouseup', () => { if(isDragging || isResizing || isPanning) saveDraft(); isDragging = false; isResizing = false; isPanning = false; });
654
- function startResize(e, dir) { e.preventDefault(); e.stopPropagation(); isResizing = true; resizeHandle = dir; const rect = selectedBubble.getBoundingClientRect(); originalWidth = rect.width; originalHeight = rect.height; originalX = selectedBubble.offsetLeft; originalY = selectedBubble.offsetTop; originalMouseX = e.clientX; originalMouseY = e.clientY; }
655
- function resizeBubble(e) { if (!isResizing || !selectedBubble) return; const dx = e.clientX - originalMouseX; const dy = e.clientY - originalMouseY; if (resizeHandle.includes('e')) selectedBubble.style.width = `${originalWidth + dx}px`; if (resizeHandle.includes('w')) { selectedBubble.style.width = `${originalWidth - dx}px`; selectedBubble.style.left = `${originalX + dx}px`; } if (resizeHandle.includes('s')) selectedBubble.style.height = `${originalHeight + dy}px`; if (resizeHandle.includes('n')) { selectedBubble.style.height = `${originalHeight - dy}px`; selectedBubble.style.top = `${originalY + dy}px`; } }
656
- function selectBubble(el) { if(selectedBubble) selectedBubble.classList.remove('selected'); if(selectedPanel) { selectedPanel.classList.remove('selected'); selectedPanel = null; } selectedBubble = el; el.classList.add('selected'); const bubbleType = el.dataset.type || 'speech'; document.getElementById('tail-controls').style.display = (bubbleType === 'speech' || bubbleType === 'thought') ? 'block' : 'none'; ['bubble-text-color','bubble-fill-color','bubble-type-select','font-select'].forEach(i => document.getElementById(i).disabled = false); document.getElementById('zoom-slider').disabled = true; document.getElementById('bubble-type-select').value = bubbleType; const styles = window.getComputedStyle(el); document.getElementById('bubble-text-color').value = rgbToHex(styles.getPropertyValue('--bubble-text-color').trim() || styles.color); document.getElementById('bubble-fill-color').value = rgbToHex(styles.getPropertyValue('--bubble-fill-color').trim() || styles.backgroundColor); document.getElementById('tail-slider').value = parseInt(styles.getPropertyValue('--tail-pos').trim() || 50); }
657
- function selectPanel(el) { if(selectedPanel) selectedPanel.classList.remove('selected'); if(selectedBubble) { selectedBubble.classList.remove('selected'); selectedBubble = null; } selectedPanel = el; el.classList.add('selected'); const img = el.querySelector('img'); document.getElementById('zoom-slider').disabled = false; document.getElementById('zoom-slider').value = img.dataset.zoom || 100; ['bubble-text-color','bubble-fill-color','bubble-type-select','font-select'].forEach(i => document.getElementById(i).disabled = true); document.getElementById('tail-controls').style.display = 'none'; }
658
- function addBubble() { if(!selectedPanel) return alert("Select a panel first"); const b = createBubbleHTML({ text: "New Text", left: "50px", top: "30px", type: 'speech', colors: { text: '#ffffff', fill: '#4ECDC4' } }); selectedPanel.appendChild(b); selectBubble(b); saveDraft(); }
659
- function deleteBubble() { if(!selectedBubble) return alert("Select a bubble first"); if(confirm("Delete this bubble?")) { selectedBubble.remove(); selectedBubble = null; saveDraft(); } }
660
- function changeBubbleType(type) { if(!selectedBubble) return; applyBubbleType(selectedBubble, type); selectedBubble.classList.add('selected'); document.getElementById('tail-controls').style.display = (type === 'speech' || type === 'thought') ? 'block' : 'none'; saveDraft(); }
661
- function changeFont(font) { if(!selectedBubble) return; selectedBubble.style.fontFamily = font; saveDraft(); }
662
- function rotateTail() { if(!selectedBubble) return; const type = selectedBubble.dataset.type; if(type === 'speech') { const positions = ['tail-bottom', 'tail-right', 'tail-top', 'tail-left']; let current = 0; positions.forEach((pos, i) => { if(selectedBubble.classList.contains(pos)) current = i; }); selectedBubble.classList.remove(positions[current]); selectedBubble.classList.add(positions[(current + 1) % 4]); } else if(type === 'thought') { const isFlippedH = selectedBubble.classList.contains('flipped'); const isFlippedV = selectedBubble.classList.contains('flipped-vertical'); if (!isFlippedH && !isFlippedV) selectedBubble.classList.add('flipped'); else if (isFlippedH && !isFlippedV) selectedBubble.classList.add('flipped-vertical'); else if (isFlippedH && isFlippedV) selectedBubble.classList.remove('flipped'); else selectedBubble.classList.remove('flipped-vertical'); } saveDraft(); }
663
- function slideTail(v) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', v+'%'); saveDraft(); } }
664
- document.getElementById('bubble-text-color').addEventListener('input', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-text-color', e.target.value); saveDraft(); } });
665
- document.getElementById('bubble-fill-color').addEventListener('input', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-fill-color', e.target.value); saveDraft(); } });
666
- function handleZoom(el) { if(!selectedPanel) return; const img = selectedPanel.querySelector('img'); img.dataset.zoom = el.value; updateImageTransform(img); saveDraft(); }
667
- function startPan(e, img) { if(parseFloat(img.dataset.zoom || 100) <= 100) return; e.preventDefault(); isPanning = true; selectedPanel = img.closest('.panel'); panStartX = e.clientX; panStartY = e.clientY; panStartTx = parseFloat(img.dataset.translateX || 0); panStartTy = parseFloat(img.dataset.translateY || 0); img.classList.add('panning'); }
668
- function panImage(e) { if(!isPanning || !selectedPanel) return; const img = selectedPanel.querySelector('img'); img.dataset.translateX = panStartTx + (e.clientX - panStartX); img.dataset.translateY = panStartTy + (e.clientY - panStartY); updateImageTransform(img); }
669
- function updateImageTransform(img) { const z = (img.dataset.zoom || 100) / 100; const x = img.dataset.translateX || 0; const y = img.dataset.translateY || 0; img.style.transform = `translateX(${x}px) translateY(${y}px) scale(${z})`; img.classList.toggle('pannable', z > 1); }
670
- function resetPanelTransform() { if(!selectedPanel) return alert("Select a panel first"); const img = selectedPanel.querySelector('img'); img.dataset.zoom = 100; img.dataset.translateX = 0; img.dataset.translateY = 0; document.getElementById('zoom-slider').value = 100; updateImageTransform(img); saveDraft(); }
671
- function replacePanelImage() { if(!selectedPanel) return alert("Select a panel first"); const inp = document.getElementById('image-uploader'); inp.onchange = async (e) => { const fd = new FormData(); fd.append('image', e.target.files[0]); const img = selectedPanel.querySelector('img'); img.style.opacity = '0.5'; const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd}); const d = await r.json(); if(d.success) { img.src = `/frames/${d.new_filename}?sid=${sid}`; resetPanelTransform(); saveDraft(); } else { alert('Error: ' + d.error); } img.style.opacity = '1'; inp.value = ''; }; inp.click(); }
672
- async function adjustFrame(dir) { if(!selectedPanel) return alert("Select a panel first"); const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0]; img.style.opacity = '0.5'; const r = await fetch(`/regenerate_frame?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, direction:dir}) }); const d = await r.json(); if(d.success) { img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`; } else { alert('Error: ' + d.message); } img.style.opacity = '1'; saveDraft(); }
673
- async function gotoTimestamp() { if(!selectedPanel) return alert("Select a panel first"); let v = document.getElementById('timestamp-input').value.trim(); if(!v) return; if(v.includes(':')) { let p = v.split(':'); v = parseInt(p[0]) * 60 + parseFloat(p[1]); } else { v = parseFloat(v); } if(isNaN(v)) return alert("Invalid time format"); const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0]; img.style.opacity = '0.5'; const r = await fetch(`/goto_timestamp?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, timestamp:v}) }); const d = await r.json(); if(d.success) { img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`; document.getElementById('timestamp-input').value = ''; resetPanelTransform(); } else { alert('Error: ' + d.message); } img.style.opacity = '1'; saveDraft(); }
674
- async function exportComic() { const pgs = document.querySelectorAll('.comic-page'); if(pgs.length === 0) return alert("No pages found"); const bubbles = document.querySelectorAll('.speech-bubble'); bubbles.forEach(b => { const rect = b.getBoundingClientRect(); b.style.width = rect.width + 'px'; b.style.height = rect.height + 'px'; }); alert(`Exporting ${pgs.length} page(s)...`); for(let i = 0; i < pgs.length; i++) { try { const u = await htmlToImage.toPng(pgs[i], {pixelRatio: 3}); const a = document.createElement('a'); a.href = u; a.download = `Comic-Page-${i+1}.png`; a.click(); } catch(err) { console.error(err); alert(`Failed to export page ${i+1}`); } } bubbles.forEach(b => { b.style.width = ''; b.style.height = ''; }); }
675
- function rgbToHex(rgb) { if (!rgb || !rgb.startsWith('rgb')) return '#ffffff'; let sep = rgb.indexOf(",") > -1 ? "," : " "; rgb = rgb.substr(4).split(")")[0].split(sep); let r = (+rgb[0]).toString(16), g = (+rgb[1]).toString(16), b = (+rgb[2]).toString(16); if (r.length == 1) r = "0" + r; if (g.length == 1) g = "0" + g; if (b.length == 1) b = "0" + b; return "#" + r + g + b; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
676
  </script>
677
  </body>
678
  </html>
@@ -689,13 +726,16 @@ def upload():
689
  if 'file' not in request.files or not request.files['file'].filename:
690
  return jsonify({'success': False, 'message': 'No file selected'}), 400
691
 
 
 
 
692
  f = request.files['file']
693
  gen = EnhancedComicGenerator(sid)
694
  gen.cleanup()
695
  f.save(gen.video_path)
696
  gen.write_status("Starting...", 5)
697
 
698
- threading.Thread(target=gen.run).start()
699
  return jsonify({'success': True, 'message': 'Generation started.'})
700
 
701
  @app.route('/status')
@@ -720,7 +760,6 @@ def regen():
720
  sid = request.args.get('sid')
721
  d = request.get_json()
722
  gen = EnhancedComicGenerator(sid)
723
- # CALL GLOBAL GPU FUNCTION
724
  return jsonify(regen_frame_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], d['direction']))
725
 
726
  @app.route('/goto_timestamp', methods=['POST'])
@@ -728,7 +767,6 @@ def go_time():
728
  sid = request.args.get('sid')
729
  d = request.get_json()
730
  gen = EnhancedComicGenerator(sid)
731
- # CALL GLOBAL GPU FUNCTION
732
  return jsonify(get_frame_at_ts_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], float(d['timestamp'])))
733
 
734
  @app.route('/replace_panel', methods=['POST'])
 
2
  import os
3
  import time
4
  import threading
 
 
5
  import json
6
  import traceback
7
  import logging
8
  import string
9
  import random
10
+ import shutil
11
  import cv2
12
+ import math
13
  import numpy as np
14
  import srt
15
+ from flask import Flask, jsonify, request, send_from_directory, send_file
 
16
 
17
  # ======================================================
18
+ # 🚀 ZEROGPU CONFIGURATION
19
  # ======================================================
20
  @spaces.GPU
21
  def gpu_warmup():
22
  import torch
23
+ print(f"✅ ZeroGPU Warmup: CUDA Available: {torch.cuda.is_available()}")
24
  return True
25
 
26
  # ======================================================
27
+ # 🧱 DATA CLASSES (Local Definition)
28
  # ======================================================
 
 
 
 
29
  def bubble(dialog="", bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, emotion='normal'):
30
  return {
31
  'dialog': dialog,
 
44
  self.panels = panels
45
  self.bubbles = bubbles
46
 
47
+ # ======================================================
48
+ # 🔧 APP SETUP
49
+ # ======================================================
50
+ logging.basicConfig(level=logging.INFO)
51
+ logger = logging.getLogger(__name__)
52
+
53
  app = Flask(__name__)
54
  BASE_USER_DIR = "userdata"
55
  SAVED_COMICS_DIR = "saved_comics"
 
65
  return code
66
 
67
  # ======================================================
68
+ # 🧠 GLOBAL GPU FUNCTIONS
69
  # ======================================================
70
 
71
  @spaces.GPU(duration=240)
72
+ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
73
+ print(f"🚀 GPU Task Started: {video_path} | Target Pages: {target_pages}")
74
 
 
75
  import cv2
76
  import srt
77
+ import math
78
  import numpy as np
79
  from backend.keyframes.keyframes import black_bar_crop
80
  from backend.simple_color_enhancer import SimpleColorEnhancer
 
98
  if os.path.exists('test1.srt'):
99
  shutil.move('test1.srt', user_srt)
100
  except Exception as e:
101
+ print(f"⚠️ Subtitle error: {e}")
102
  with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\nHello\n")
103
 
104
  with open(user_srt, 'r', encoding='utf-8') as f:
 
107
  key_moments = [{'index': s.index, 'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in all_subs]
108
  key_moments.sort(key=lambda x: x['start'])
109
 
110
+ # 3. Extract Frames (Limit max frames to keep it snappy)
111
  frame_metadata = {}
112
  cap = cv2.VideoCapture(video_path)
113
  count = 0
114
+ max_frames_to_extract = 40
115
+
116
+ for i, moment in enumerate(key_moments[:max_frames_to_extract]):
117
  mid = (moment['start'] + moment['end']) / 2
118
  if mid > duration: continue
119
  cap.set(cv2.CAP_PROP_POS_FRAMES, int(mid * fps))
 
139
  print(f"🎨 Enhancing {len(frame_files)} frames...")
140
  for f in frame_files:
141
  p = os.path.join(frames_dir, f)
142
+ try: se.enhance_single(p, p)
143
  except: pass
144
+ try: qe.enhance_single(p, p)
145
  except: pass
146
 
147
  # 5. Bubbles
 
154
  faces = face_detector.detect_faces(p)
155
  lip = face_detector.get_lip_position(p, faces[0]) if faces else (-1, -1)
156
  bx, by = ai_bubble_placer.place_bubble_ai(p, lip)
 
157
  bubbles_list.append(bubble(dialog=dialogue, bubble_offset_x=bx, bubble_offset_y=by, lip_x=lip[0], lip_y=lip[1]))
158
  except:
159
  bubbles_list.append(bubble(dialog=dialogue))
160
 
161
+ # 6. Dynamic Layout Calculation
162
  print("📄 Generating layout...")
163
+ total_panels = len(frame_files)
164
+
165
+ # Calculate panels per page
166
+ if target_pages <= 0: target_pages = 1
167
+
168
+ # If we have 32 panels and user wants 4 pages -> 8 panels/page
169
+ # If we have 32 panels and user wants 10 pages -> ~3 panels/page
170
+ panels_per_page = math.ceil(total_panels / target_pages)
171
+
172
+ print(f"📊 Layout: {total_panels} panels across {target_pages} pages (~{panels_per_page}/page)")
173
+
174
+ pages = []
175
+ for i in range(target_pages):
176
+ start_idx = i * panels_per_page
177
+ end_idx = start_idx + panels_per_page
178
+
179
+ # Slicing safely
180
+ page_frames = frame_files[start_idx:end_idx]
181
+ page_bubbles = bubbles_list[start_idx:end_idx]
182
+
183
+ if page_frames:
184
+ pg_panels = [panel(image=f) for f in page_frames]
185
+ pages.append(Page(panels=pg_panels, bubbles=page_bubbles))
186
 
187
  # Serialize Results
188
  result = []
 
198
  import cv2
199
  import json
200
  from backend.simple_color_enhancer import SimpleColorEnhancer
 
201
 
202
  if not os.path.exists(metadata_path): return {"success": False, "message": "No metadata"}
203
  with open(metadata_path, 'r') as f: meta = json.load(f)
 
215
 
216
  if ret:
217
  p = os.path.join(frames_dir, fname)
218
+ # Write then Flush to prevent libpng errors
219
  cv2.imwrite(p, frame)
220
+ os.sync()
221
+
222
  try: SimpleColorEnhancer().enhance_single(p, p)
223
  except: pass
 
 
224
 
225
  if isinstance(meta[fname], dict): meta[fname]['time'] = new_t
226
  else: meta[fname] = new_t
 
233
  import cv2
234
  import json
235
  from backend.simple_color_enhancer import SimpleColorEnhancer
 
236
 
237
  cap = cv2.VideoCapture(video_path)
238
  cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
 
242
  if ret:
243
  p = os.path.join(frames_dir, fname)
244
  cv2.imwrite(p, frame)
245
+ os.sync()
246
+
247
  try: SimpleColorEnhancer().enhance_single(p, p)
248
  except: pass
 
 
249
 
250
  if os.path.exists(metadata_path):
251
  with open(metadata_path, 'r') as f: meta = json.load(f)
 
257
  return {"success": False, "message": "Invalid timestamp"}
258
 
259
  # ======================================================
260
+ # 💻 BACKEND CLASS
261
  # ======================================================
262
  class EnhancedComicGenerator:
263
  def __init__(self, sid):
 
276
  os.makedirs(self.frames_dir, exist_ok=True)
277
  os.makedirs(self.output_dir, exist_ok=True)
278
 
279
+ def run(self, target_pages):
280
  try:
281
  self.write_status("Waiting for GPU...", 5)
282
+ # Pass target_pages to GPU function
283
+ data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, int(target_pages))
284
  with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
285
  json.dump(data, f, indent=2)
286
  self.write_status("Complete!", 100)
 
293
  json.dump({'message': msg, 'progress': prog}, f)
294
 
295
  # ======================================================
296
+ # 🌐 ROUTES
297
  # ======================================================
298
 
 
299
  INDEX_HTML = '''
300
  <!DOCTYPE html>
301
  <html lang="en">
 
308
  <style>
309
  * { box-sizing: border-box; }
310
  body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
311
+
312
+ /* UPLOAD SCREEN */
313
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
314
  .upload-box { max-width: 500px; width: 100%; padding: 40px; background: white; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.12); text-align: center; }
315
+
316
+ /* EDITOR SCREEN */
317
  #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 100px; }
318
+
319
  h1 { color: #2c3e50; margin-bottom: 20px; font-weight: 600; }
320
  .file-input { display: none; }
321
  .file-label { display: block; padding: 15px; background: #2c3e50; color: white; border-radius: 8px; cursor: pointer; font-weight: bold; margin-bottom: 10px; transition:0.2s; }
322
  .file-label:hover { background: #34495e; }
323
+
324
+ /* PAGE INPUT STYLES */
325
+ .page-input-group { margin: 15px 0; text-align: left; }
326
+ .page-input-group label { font-weight: bold; font-size: 14px; display: block; margin-bottom: 5px; color: #333; }
327
+ .page-input-group input { width: 100%; padding: 10px; border: 2px solid #ddd; border-radius: 8px; font-size: 16px; }
328
+
329
  .submit-btn { width: 100%; padding: 15px; background: #e67e22; color: white; border: none; border-radius: 8px; font-size: 18px; font-weight: bold; cursor: pointer; transition: 0.2s; }
330
  .submit-btn:hover { background: #d35400; }
331
  .restore-btn { margin-top: 10px; background: #27ae60; color: white; padding: 12px; width: 100%; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
332
+
333
  .load-section { margin-top: 30px; padding-top: 20px; border-top: 2px solid #eee; }
334
  .load-input-group { display: flex; gap: 10px; margin-top: 10px; }
335
  .load-input-group input { flex: 1; padding: 12px; border: 2px solid #ddd; border-radius: 8px; font-size: 16px; text-transform: uppercase; letter-spacing: 2px; text-align: center; }
336
  .load-input-group button { padding: 12px 20px; background: #3498db; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
337
+
338
  .loader { width: 120px; height: 20px; background: radial-gradient(circle 10px, #e67e22 100%, transparent 0); background-size: 20px 20px; animation: ball 1s infinite linear; margin: 20px auto; }
339
  @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
340
+
341
+ /* COMIC ELEMENTS */
342
  .comic-wrapper { max-width: 1000px; margin: 0 auto; }
343
  .page-wrapper { margin: 30px auto; display: flex; flex-direction: column; align-items: center; }
344
  .page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
 
349
  .panel img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.1s ease-out; transform-origin: center center; }
350
  .panel img.pannable { cursor: grab; }
351
  .panel img.panning { cursor: grabbing; }
352
+
353
+ /* BUBBLES */
354
  .speech-bubble { position: absolute; display: flex; justify-content: center; align-items: center; width: 150px; height: 80px; min-width: 50px; min-height: 30px; box-sizing: border-box; z-index: 10; cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold; font-size: 13px; text-align: center; overflow: visible; }
355
  .bubble-text { padding: 0.8em; word-wrap: break-word; position: relative; z-index: 5; }
356
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
 
360
  .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))); }
361
  .speech-bubble.speech.tail-top { border-radius: min(var(--r), calc(var(--p) - (1 - var(--t)) * var(--b) / 2)) min(var(--r), calc(100% - var(--p) - (1 - var(--t)) * var(--b) / 2)) var(--r) var(--r) / var(--r); }
362
  .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); }
363
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  .resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; z-index: 11; }
365
  .speech-bubble.selected .resize-handle { display: block; }
366
  .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
367
+
368
+ /* CONTROLS */
 
369
  .edit-controls { position: fixed; bottom: 20px; right: 20px; width: 260px; background: rgba(44, 62, 80, 0.95); color: white; padding: 15px; border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 900; font-size: 13px; max-height: 90vh; overflow-y: auto; }
370
  .edit-controls h4 { margin: 0 0 10px 0; color: #4ECDC4; text-align: center; }
371
  .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
 
372
  button, input, select { width: 100%; margin-top: 5px; padding: 6px; border-radius: 4px; border: 1px solid #ddd; cursor: pointer; font-weight: bold; font-size: 12px; }
 
373
  .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
 
 
 
 
 
 
 
374
  .action-btn { background: #4CAF50; color: white; }
375
+ .action-btn:disabled { background: #555; cursor: not-allowed; opacity: 0.7; }
376
+
377
+ /* MODAL */
 
 
 
 
378
  .modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); display: none; justify-content: center; align-items: center; z-index: 9999; }
379
  .modal-content { background: white; padding: 30px; border-radius: 12px; max-width: 400px; width: 90%; text-align: center; }
 
380
  .modal-content .code { font-size: 32px; font-weight: bold; letter-spacing: 4px; background: #f0f0f0; padding: 15px 25px; border-radius: 8px; display: inline-block; margin: 15px 0; font-family: monospace; user-select: all; }
 
 
 
381
  </style>
382
  </head>
383
  <body>
 
387
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
388
  <label for="file-upload" class="file-label">📁 Choose Video File</label>
389
  <span id="fn" style="margin-bottom:10px; display:block; color:#666;">No file selected</span>
390
+
391
+ <div class="page-input-group">
392
+ <label>📚 Number of Pages:</label>
393
+ <input type="number" id="page-count" value="4" min="1" max="10" placeholder="e.g. 4">
394
+ </div>
395
+
396
  <button class="submit-btn" onclick="upload()">🚀 Generate Comic</button>
397
  <button id="restore-draft-btn" class="restore-btn" style="display:none; margin-top:15px;" onclick="restoreDraft()">📂 Restore Unsaved Draft</button>
398
+
399
  <div class="load-section">
400
  <h3>📥 Load Saved Comic</h3>
 
401
  <div class="load-input-group">
402
  <input type="text" id="load-code-input" placeholder="SAVE CODE" maxlength="8" style="text-transform:uppercase;">
403
  <button onclick="loadSavedComic()">Load</button>
 
419
  <label>💾 Save & Load:</label>
420
  <button onclick="saveComic()" class="save-btn">💾 Save Comic (Get Code)</button>
421
  <div id="current-save-code" style="display:none; margin-top:8px; padding:8px; background:#2ecc71; border-radius:4px; text-align:center;">
 
422
  <span id="display-save-code" style="font-size:18px; font-weight:bold; letter-spacing:2px;"></span>
423
  <button onclick="copyCode()" style="padding:4px 8px; margin-left:5px; font-size:10px;">📋 Copy</button>
424
  </div>
425
  </div>
426
  <div class="control-group">
427
  <label>💬 Bubble Tools:</label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  <button onclick="addBubble()" class="action-btn">💬 Add Bubble</button>
429
  <button onclick="deleteBubble()" class="reset-btn">🗑️ Delete Bubble</button>
430
  </div>
 
 
 
 
 
 
 
 
431
  <div class="control-group">
432
  <label>🖼️ Panel Tools:</label>
433
  <button onclick="replacePanelImage()" class="action-btn">🖼️ Replace Image</button>
434
  <div class="button-grid">
435
+ <button onclick="adjustFrame('backward')" class="secondary-btn" id="prev-btn">⬅️ Prev</button>
436
+ <button onclick="adjustFrame('forward')" class="action-btn" id="next-btn">Next ➡️</button>
437
  </div>
438
  <div class="timestamp-controls">
439
  <input type="text" id="timestamp-input" placeholder="mm:ss or secs">
440
+ <button onclick="gotoTimestamp()" class="action-btn" id="go-btn">Go</button>
 
 
 
 
 
 
 
441
  </div>
442
  </div>
443
  <div class="control-group">
 
447
  </div>
448
  </div>
449
 
450
+ <!-- MODAL -->
451
  <div class="modal-overlay" id="save-modal">
452
  <div class="modal-content">
453
  <h2>✅ Comic Saved!</h2>
 
454
  <div class="code" id="modal-save-code">XXXXXXXX</div>
 
455
  <button onclick="copyModalCode()">📋 Copy Code</button>
456
  <button class="close-btn" onclick="closeModal()">Close</button>
457
  </div>
 
461
  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);}); }
462
  let sid = localStorage.getItem('comic_sid') || genUUID();
463
  localStorage.setItem('comic_sid', sid);
464
+
465
  let currentSaveCode = null;
466
+ let isProcessing = false; // Lock to prevent 429 errors
467
  let interval, selectedBubble = null, selectedPanel = null;
468
+ let isDragging = false, isResizing = false;
469
+ let startX, startY, initX, initY;
 
470
  let resizeHandle = '', originalWidth, originalHeight, originalX, originalY, originalMouseX, originalMouseY;
471
  let currentlyEditing = null;
472
 
473
  if(localStorage.getItem('comic_draft_'+sid)) document.getElementById('restore-draft-btn').style.display = 'block';
474
 
475
+ // --- HELPERS ---
476
  function showSaveModal(code) { document.getElementById('modal-save-code').textContent = code; document.getElementById('save-modal').style.display = 'flex'; }
477
  function closeModal() { document.getElementById('save-modal').style.display = 'none'; }
478
+ function copyModalCode() { navigator.clipboard.writeText(document.getElementById('modal-save-code').textContent).then(() => alert('Code copied!')); }
479
  function copyCode() { if(currentSaveCode) navigator.clipboard.writeText(currentSaveCode).then(() => alert('Code copied!')); }
480
+ function setProcessing(busy) {
481
+ isProcessing = busy;
482
+ const btns = ['prev-btn', 'next-btn', 'go-btn'];
483
+ btns.forEach(id => {
484
+ const el = document.getElementById(id);
485
+ if(el) { el.disabled = busy; el.style.opacity = busy ? '0.5' : '1'; }
486
+ });
487
+ }
488
+
489
+ // --- CORE ACTIONS ---
490
  async function saveComic() {
491
  const state = getCurrentState();
492
  if(!state || state.length === 0) { alert('No comic to save!'); return; }
493
  try {
494
+ const r = await fetch(`/save_comic?sid=${sid}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ pages: state, savedAt: new Date().toISOString() }) });
495
+ const d = await r.json();
496
+ if(d.success) { currentSaveCode = d.code; document.getElementById('display-save-code').textContent = d.code; document.getElementById('current-save-code').style.display = 'block'; showSaveModal(d.code); saveDraft(); }
497
+ else { alert('Failed to save: ' + d.message); }
498
  } catch(e) { console.error(e); alert('Error saving comic'); }
499
  }
500
 
501
  async function loadSavedComic() {
502
  const code = document.getElementById('load-code-input').value.trim().toUpperCase();
503
+ if(!code || code.length < 4) { alert('Invalid code'); return; }
504
  try {
505
+ const r = await fetch(`/load_comic/${code}`);
506
+ const d = await r.json();
507
+ if(d.success) { currentSaveCode = code; sid = d.originalSid || sid; localStorage.setItem('comic_sid', sid); renderFromState(d.pages); document.getElementById('upload-container').style.display = 'none'; document.getElementById('editor-container').style.display = 'block'; document.getElementById('display-save-code').textContent = code; document.getElementById('current-save-code').style.display = 'block'; saveDraft(); }
508
+ else { alert('Load failed: ' + d.message); }
509
+ } catch(e) { console.error(e); alert('Error loading comic.'); }
510
  }
511
 
512
  function restoreDraft() {
 
 
513
  try {
514
+ const state = JSON.parse(localStorage.getItem('comic_draft_'+sid));
515
  if(state.saveCode) { currentSaveCode = state.saveCode; document.getElementById('display-save-code').textContent = state.saveCode; document.getElementById('current-save-code').style.display = 'block'; }
516
  renderFromState(state.pages || state);
517
  document.getElementById('upload-container').style.display = 'none';
518
  document.getElementById('editor-container').style.display = 'block';
519
+ } catch(e) { console.error(e); alert("Failed to restore."); }
520
  }
521
 
522
  function getCurrentState() {
 
528
  const bubbles = [];
529
  pan.querySelectorAll('.speech-bubble').forEach(b => {
530
  const textEl = b.querySelector('.bubble-text');
531
+ bubbles.push({ text: textEl ? textEl.textContent : '', left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height, classes: b.className });
532
  });
533
+ panels.push({ src: img.src, bubbles: bubbles });
534
  });
535
  pages.push({ panels: panels });
536
  });
 
551
  const pDiv = document.createElement('div'); pDiv.className = 'panel';
552
  pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
553
  const img = document.createElement('img');
554
+ // Add timestamp to src to force refresh
555
+ img.src = pan.src.includes('?') ? pan.src : pan.src + `?sid=${sid}`;
556
  pDiv.appendChild(img);
557
  (pan.bubbles || []).forEach(bData => { pDiv.appendChild(createBubbleHTML(bData)); });
558
  grid.appendChild(pDiv);
 
563
 
564
  async function upload() {
565
  const f = document.getElementById('file-upload').files[0];
566
+ const pCount = document.getElementById('page-count').value;
567
+ if(!f) return alert("Select a video");
568
  sid = genUUID(); localStorage.setItem('comic_sid', sid); currentSaveCode = null;
569
+
570
+ document.querySelector('.upload-box').style.display='none';
571
+ document.getElementById('loading-view').style.display='flex';
572
+
573
+ const fd = new FormData();
574
+ fd.append('file', f);
575
+ fd.append('target_pages', pCount); // Send page count to backend
576
+
577
  const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
578
  if(r.ok) interval = setInterval(checkStatus, 2000);
579
  else { alert("Upload failed"); location.reload(); }
 
593
  const cleanData = data.map((p, pi) => ({
594
  panels: p.panels.map((pan, j) => ({
595
  src: `/frames/${pan.image}?sid=${sid}`,
596
+ bubbles: (p.bubbles && p.bubbles[j] && p.bubbles[j].dialog) ? [{ text: p.bubbles[j].dialog, left: (p.bubbles[j].bubble_offset_x || 50) + 'px', top: (p.bubbles[j].bubble_offset_y || 20) + 'px', classes: 'speech-bubble speech tail-bottom' }] : []
597
  }))
598
  }));
599
  renderFromState(cleanData); saveDraft();
600
  });
601
  }
602
 
603
+ // --- INTERACTIVE ELEMENTS ---
604
  function createBubbleHTML(data) {
605
+ const b = document.createElement('div');
606
+ b.className = data.classes || 'speech-bubble speech tail-bottom';
607
  b.style.left = data.left; b.style.top = data.top;
608
+ if(data.width) b.style.width = data.width; if(data.height) b.style.height = data.height;
 
 
609
  const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || ''; b.appendChild(textSpan);
610
  ['nw', 'ne', 'sw', 'se'].forEach(dir => { const handle = document.createElement('div'); handle.className = `resize-handle ${dir}`; handle.onmousedown = (e) => startResize(e, dir); b.appendChild(handle); });
611
  b.onmousedown = (e) => { if(e.target.classList.contains('resize-handle')) return; e.stopPropagation(); selectBubble(b); isDragging = true; startX = e.clientX; startY = e.clientY; initX = b.offsetLeft; initY = b.offsetTop; };
612
+ b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
613
  return b;
614
  }
615
 
 
 
 
 
 
 
 
 
 
616
  function editBubbleText(bubble) {
617
  if (currentlyEditing) return; currentlyEditing = bubble;
618
  const textSpan = bubble.querySelector('.bubble-text'); const textarea = document.createElement('textarea');
 
622
  textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); } });
623
  }
624
 
625
+ document.addEventListener('mousemove', (e) => { if(isDragging && selectedBubble) { selectedBubble.style.left = (initX + e.clientX - startX) + 'px'; selectedBubble.style.top = (initY + e.clientY - startY) + 'px'; } if(isResizing && selectedBubble) { resizeBubble(e); } });
626
+ document.addEventListener('mouseup', () => { if(isDragging || isResizing) saveDraft(); isDragging = false; isResizing = false; });
627
+ function startResize(e, dir) { e.preventDefault(); e.stopPropagation(); isResizing = true; resizeHandle = dir; const rect = selectedBubble.getBoundingClientRect(); originalWidth = rect.width; originalHeight = rect.height; originalMouseX = e.clientX; originalMouseY = e.clientY; }
628
+ function resizeBubble(e) { if (!isResizing || !selectedBubble) return; const dx = e.clientX - originalMouseX; const dy = e.clientY - originalMouseY; if(resizeHandle.includes('e')) selectedBubble.style.width = (originalWidth + dx)+'px'; if(resizeHandle.includes('s')) selectedBubble.style.height = (originalHeight + dy)+'px'; }
629
+
630
+ function selectBubble(el) { if(selectedBubble) selectedBubble.classList.remove('selected'); if(selectedPanel) { selectedPanel.classList.remove('selected'); selectedPanel = null; } selectedBubble = el; el.classList.add('selected'); }
631
+ function selectPanel(el) { if(selectedPanel) selectedPanel.classList.remove('selected'); if(selectedBubble) { selectedBubble.classList.remove('selected'); selectedBubble = null; } selectedPanel = el; el.classList.add('selected'); }
632
+ function addBubble() { if(!selectedPanel) return alert("Select a panel first"); const b = createBubbleHTML({ text: "Text", left: "50px", top: "30px", classes: "speech-bubble speech tail-bottom" }); selectedPanel.appendChild(b); selectBubble(b); saveDraft(); }
633
+ function deleteBubble() { if(!selectedBubble) return alert("Select a bubble"); selectedBubble.remove(); selectedBubble=null; saveDraft(); }
634
+
635
+ function replacePanelImage() {
636
+ if(!selectedPanel) return alert("Select a panel");
637
+ const inp = document.getElementById('image-uploader');
638
+ inp.onchange = async (e) => {
639
+ const fd = new FormData(); fd.append('image', e.target.files[0]);
640
+ const img = selectedPanel.querySelector('img');
641
+ const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd});
642
+ const d = await r.json();
643
+ if(d.success) { img.src = `/frames/${d.new_filename}?sid=${sid}`; saveDraft(); }
644
+ inp.value = '';
645
+ };
646
+ inp.click();
647
+ }
648
+
649
+ async function adjustFrame(dir) {
650
+ if(isProcessing) return; // LOCK
651
+ if(!selectedPanel) return alert("Select a panel");
652
+ const img = selectedPanel.querySelector('img');
653
+ let fname = img.src.split('/').pop().split('?')[0];
654
+
655
+ setProcessing(true); // ENABLE LOCK
656
+ img.style.opacity = '0.5';
657
+
658
+ try {
659
+ const r = await fetch(`/regenerate_frame?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, direction:dir}) });
660
+ const d = await r.json();
661
+ if(d.success) { img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`; }
662
+ else { alert('Error: ' + d.message); }
663
+ } catch(e) { console.error(e); }
664
+
665
+ img.style.opacity = '1';
666
+ setProcessing(false); // RELEASE LOCK
667
+ saveDraft();
668
+ }
669
+
670
+ async function gotoTimestamp() {
671
+ if(isProcessing) return; // LOCK
672
+ if(!selectedPanel) return alert("Select a panel");
673
+ let v = document.getElementById('timestamp-input').value.trim();
674
+ if(!v) return;
675
+ if(v.includes(':')) { let p = v.split(':'); v = parseInt(p[0]) * 60 + parseFloat(p[1]); } else { v = parseFloat(v); }
676
+ if(isNaN(v)) return alert("Invalid time");
677
+
678
+ const img = selectedPanel.querySelector('img');
679
+ let fname = img.src.split('/').pop().split('?')[0];
680
+
681
+ setProcessing(true); // ENABLE LOCK
682
+ img.style.opacity = '0.5';
683
+
684
+ try {
685
+ const r = await fetch(`/goto_timestamp?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, timestamp:v}) });
686
+ const d = await r.json();
687
+ if(d.success) { img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`; document.getElementById('timestamp-input').value = ''; }
688
+ else { alert('Error: ' + d.message); }
689
+ } catch(e) { console.error(e); }
690
+
691
+ img.style.opacity = '1';
692
+ setProcessing(false); // RELEASE LOCK
693
+ saveDraft();
694
+ }
695
+
696
+ async function exportComic() {
697
+ const pgs = document.querySelectorAll('.comic-page');
698
+ if(pgs.length === 0) return alert("No pages found");
699
+ alert(`Exporting ${pgs.length} page(s)...`);
700
+ for(let i = 0; i < pgs.length; i++) {
701
+ try { const u = await htmlToImage.toPng(pgs[i], {pixelRatio: 2}); const a = document.createElement('a'); a.href = u; a.download = `Comic-Page-${i+1}.png`; a.click(); }
702
+ catch(err) { console.error(err); alert(`Failed to export page ${i+1}`); }
703
+ }
704
+ }
705
+
706
+ function goBackToUpload() {
707
+ if(confirm('Go home? Unsaved changes will be lost.')) {
708
+ document.getElementById('editor-container').style.display = 'none';
709
+ document.getElementById('upload-container').style.display = 'flex';
710
+ document.getElementById('loading-view').style.display = 'none';
711
+ }
712
+ }
713
  </script>
714
  </body>
715
  </html>
 
726
  if 'file' not in request.files or not request.files['file'].filename:
727
  return jsonify({'success': False, 'message': 'No file selected'}), 400
728
 
729
+ # GET PAGE COUNT FROM FORM
730
+ target_pages = request.form.get('target_pages', 4)
731
+
732
  f = request.files['file']
733
  gen = EnhancedComicGenerator(sid)
734
  gen.cleanup()
735
  f.save(gen.video_path)
736
  gen.write_status("Starting...", 5)
737
 
738
+ threading.Thread(target=gen.run, args=(target_pages,)).start()
739
  return jsonify({'success': True, 'message': 'Generation started.'})
740
 
741
  @app.route('/status')
 
760
  sid = request.args.get('sid')
761
  d = request.get_json()
762
  gen = EnhancedComicGenerator(sid)
 
763
  return jsonify(regen_frame_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], d['direction']))
764
 
765
  @app.route('/goto_timestamp', methods=['POST'])
 
767
  sid = request.args.get('sid')
768
  d = request.get_json()
769
  gen = EnhancedComicGenerator(sid)
 
770
  return jsonify(get_frame_at_ts_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], float(d['timestamp'])))
771
 
772
  @app.route('/replace_panel', methods=['POST'])