tester343 commited on
Commit
012f1e5
·
verified ·
1 Parent(s): ef4a2fa

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +591 -551
app_enhanced.py CHANGED
@@ -1,4 +1,4 @@
1
- import spaces # <--- CRITICAL: MUST BE THE FIRST IMPORT
2
  import os
3
  import time
4
  import threading
@@ -28,22 +28,22 @@ def gpu_warmup():
28
  # ======================================================
29
  def bubble(dialog="", bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, emotion='normal', type='speech'):
30
  return {
31
- 'dialog': dialog,
32
- 'bubble_offset_x': int(bubble_offset_x),
33
  'bubble_offset_y': int(bubble_offset_y),
34
- 'lip_x': int(lip_x),
35
- 'lip_y': int(lip_y),
36
  'emotion': emotion,
37
  'type': type,
38
  'tail_pos': '50%',
39
  'classes': f'speech-bubble {type} tail-bottom'
40
  }
41
 
42
- def panel(image=""):
43
  return {'image': image}
44
 
45
  class Page:
46
- def __init__(self, panels, bubbles):
47
  self.panels = panels
48
  self.bubbles = bubbles
49
 
@@ -54,7 +54,7 @@ logging.basicConfig(level=logging.INFO)
54
  logger = logging.getLogger(__name__)
55
 
56
  app = Flask(__name__)
57
- BASE_USER_DIR = "userdata"
58
  SAVED_COMICS_DIR = "saved_comics"
59
 
60
  os.makedirs(BASE_USER_DIR, exist_ok=True)
@@ -70,7 +70,6 @@ def generate_save_code(length=8):
70
  # ======================================================
71
  # 🧠 GLOBAL GPU FUNCTIONS
72
  # ======================================================
73
-
74
  @spaces.GPU(duration=300)
75
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
76
  print(f"🚀 GPU Task Started: {video_path} | Pages: {target_pages}")
@@ -84,7 +83,7 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
84
  from backend.subtitles.subs_real import get_real_subtitles
85
  from backend.ai_bubble_placement import ai_bubble_placer
86
  from backend.ai_enhanced_core import face_detector
87
-
88
  # 1. Analyze Video
89
  cap = cv2.VideoCapture(video_path)
90
  if not cap.isOpened(): raise Exception("Cannot open video")
@@ -92,7 +91,7 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
92
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
93
  duration = total_frames / fps
94
  cap.release()
95
-
96
  # 2. Subtitles Generation
97
  user_srt = os.path.join(user_dir, 'subs.srt')
98
  try:
@@ -104,15 +103,15 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
104
 
105
  with open(user_srt, 'r', encoding='utf-8') as f:
106
  all_subs = list(srt.parse(f.read()))
107
-
108
  # 3. Smart Keyframe Selection
109
  valid_subs = [s for s in all_subs if s.content.strip()]
110
  raw_moments = [{'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
111
-
112
  if target_pages <= 0: target_pages = 1
113
  panels_per_page = 4
114
  total_panels_needed = target_pages * panels_per_page
115
-
116
  selected_moments = []
117
  if not raw_moments:
118
  times = np.linspace(1, duration-1, total_panels_needed)
@@ -128,7 +127,7 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
128
  cap = cv2.VideoCapture(video_path)
129
  count = 0
130
  frame_files_ordered = []
131
-
132
  for i, moment in enumerate(selected_moments):
133
  mid = (moment['start'] + moment['end']) / 2
134
  if mid > duration: mid = duration - 1
@@ -143,16 +142,16 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
143
  frame_files_ordered.append(fname)
144
  count += 1
145
  cap.release()
146
-
147
  with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
148
 
149
  # 5. Image Enhancement
150
  try: black_bar_crop()
151
  except: pass
152
-
153
  se = SimpleColorEnhancer()
154
  qe = QualityColorEnhancer()
155
-
156
  for f in frame_files_ordered:
157
  p = os.path.join(frames_dir, f)
158
  try: se.enhance_single(p, p)
@@ -204,21 +203,21 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
204
  import cv2
205
  import json
206
  from backend.simple_color_enhancer import SimpleColorEnhancer
207
-
208
  if not os.path.exists(metadata_path): return {"success": False, "message": "No metadata"}
209
  with open(metadata_path, 'r') as f: meta = json.load(f)
210
  if fname not in meta: return {"success": False, "message": "Frame not found"}
211
-
212
  t = meta[fname]['time'] if isinstance(meta[fname], dict) else meta[fname]
213
  cap = cv2.VideoCapture(video_path)
214
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
215
  offset = (1.0/fps) * (1 if direction == 'forward' else -1)
216
  new_t = max(0, t + offset)
217
-
218
  cap.set(cv2.CAP_PROP_POS_MSEC, new_t * 1000)
219
  ret, frame = cap.read()
220
  cap.release()
221
-
222
  if ret:
223
  p = os.path.join(frames_dir, fname)
224
  cv2.imwrite(p, frame)
@@ -237,12 +236,12 @@ def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
237
  import cv2
238
  import json
239
  from backend.simple_color_enhancer import SimpleColorEnhancer
240
-
241
  cap = cv2.VideoCapture(video_path)
242
  cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
243
  ret, frame = cap.read()
244
  cap.release()
245
-
246
  if ret:
247
  p = os.path.join(frames_dir, fname)
248
  cv2.imwrite(p, frame)
@@ -297,556 +296,597 @@ class EnhancedComicGenerator:
297
  # ======================================================
298
  # 🌐 ROUTES & FULL UI
299
  # ======================================================
300
-
301
  INDEX_HTML = '''
302
  <!DOCTYPE html>
303
- <html lang="en">
304
- <head>
305
- <meta charset="UTF-8">
306
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
307
- <title>🎬 Enhanced Comic Generator</title>
308
- <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
309
- <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Gloria+Hallelujah&family=Lato&display=swap" rel="stylesheet">
310
- <style>
311
- * { box-sizing: border-box; }
312
- body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
313
-
314
- #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
315
- .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; }
316
-
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-group { margin: 20px 0; text-align: left; }
325
- .page-input-group label { font-weight: bold; font-size: 14px; display: block; margin-bottom: 5px; color: #333; }
326
- .page-input-group input { width: 100%; padding: 12px; border: 2px solid #ddd; border-radius: 8px; font-size: 16px; box-sizing: border-box; }
327
-
328
- .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; }
329
- .submit-btn:hover { background: #d35400; }
330
- .restore-btn { margin-top: 10px; background: #27ae60; color: white; padding: 12px; width: 100%; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
331
-
332
- .load-section { margin-top: 30px; padding-top: 20px; border-top: 2px solid #eee; }
333
- .load-input-group { display: flex; gap: 10px; margin-top: 10px; }
334
- .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; }
335
- .load-input-group button { padding: 12px 20px; background: #3498db; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
336
-
337
- .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; }
338
- @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
339
-
340
- /* COMIC LAYOUT */
341
- .comic-wrapper { max-width: 1000px; margin: 0 auto; }
342
- .page-wrapper { margin: 30px auto; display: flex; flex-direction: column; align-items: center; }
343
- .page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
344
- .comic-page { background: white; width: 600px; height: 400px; box-shadow: 0 4px 10px rgba(0,0,0,0.1); position: relative; overflow: hidden; border: 2px solid #000; padding: 10px; }
345
- .comic-grid { display: grid; grid-template-columns: 285px 285px; grid-template-rows: 185px 185px; gap: 10px; width: 100%; height: 100%; }
346
- .panel { position: relative; overflow: hidden; border: 2px solid #000; background: #eee; cursor: pointer; }
347
- .panel.selected { outline: 3px solid #2196F3; outline-offset: -3px; }
348
- .panel img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.1s ease-out; transform-origin: center center; }
349
- .panel img.pannable { cursor: grab; }
350
- .panel img.panning { cursor: grabbing; }
351
-
352
- /* SPEECH BUBBLES */
353
- .speech-bubble {
354
- position: absolute; display: flex; justify-content: center; align-items: center;
355
- width: 150px; height: 80px; min-width: 50px; min-height: 30px; box-sizing: border-box;
356
- z-index: 10; cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold;
357
- font-size: 13px; text-align: center; overflow: visible;
358
- --tail-pos: 50%;
359
- }
360
- /* POINTER EVENTS NONE ON TEXT TO FIX SELECTION BUG */
361
- .bubble-text { padding: 0.8em; word-wrap: break-word; position: relative; z-index: 5; pointer-events: none; user-select: none; }
362
- .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
363
- .speech-bubble textarea { position: absolute; top:0; left:0; width:100%; height:100%; box-sizing:border-box; border:1px solid #4CAF50; background:rgba(255,255,255,0.95); text-align:center; padding:8px; z-index:102; resize:none; font: inherit; }
364
-
365
- /* SPEECH BUBBLE CSS (Tails) */
366
- .speech-bubble.speech {
367
- --b: 3em; --h: 1.8em; --t: 0.6; --p: var(--tail-pos, 50%); --r: 1.2em;
368
- background: var(--bubble-fill-color, #4ECDC4);
369
- color: var(--bubble-text-color, #fff);
370
- padding: 1em;
371
- 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);
372
- }
373
- .speech-bubble.speech:before {
374
- content: ""; position: absolute; width: var(--b); height: var(--h);
375
- background: inherit; border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
376
- -webkit-mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
377
- mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
378
- }
379
-
380
- .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))); }
381
- .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); }
382
- .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); }
383
- .speech-bubble.speech.tail-left { border-radius: var(--r); }
384
- .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; }
385
- .speech-bubble.speech.tail-right { border-radius: var(--r); }
386
- .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; }
387
- /* THOUGHT BUBBLE CSS (Fixed Rotation) */
388
- .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
389
- .speech-bubble.thought::before { display:none; }
390
- .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
391
- .thought-dot-1 { width: 20px; height: 20px; }
392
- .thought-dot-2 { width: 12px; height: 12px; }
393
-
394
- /* Thought Tail Positions */
395
- .speech-bubble.thought.pos-bl .thought-dot-1 { left: 20px; bottom: -20px; }
396
- .speech-bubble.thought.pos-bl .thought-dot-2 { left: 10px; bottom: -32px; }
397
-
398
- .speech-bubble.thought.pos-br .thought-dot-1 { right: 20px; bottom: -20px; }
399
- .speech-bubble.thought.pos-br .thought-dot-2 { right: 10px; bottom: -32px; }
400
-
401
- .speech-bubble.thought.pos-tr .thought-dot-1 { right: 20px; top: -20px; }
402
- .speech-bubble.thought.pos-tr .thought-dot-2 { right: 10px; top: -32px; }
403
-
404
- .speech-bubble.thought.pos-tl .thought-dot-1 { left: 20px; top: -20px; }
405
- .speech-bubble.thought.pos-tl .thought-dot-2 { left: 10px; top: -32px; }
406
-
407
- .speech-bubble.reaction { background: #FFD700; border: 3px solid #E53935; color: #D32F2F; font-weight: 900; text-transform: uppercase; clip-path: polygon(0% 25%, 17% 21%, 17% 0%, 31% 16%, 50% 4%, 69% 16%, 83% 0%, 83% 21%, 100% 25%, 85% 45%, 95% 62%, 82% 79%, 100% 97%, 79% 89%, 60% 98%, 46% 82%, 27% 95%, 15% 78%, 5% 62%, 15% 45%); }
408
- .speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; }
409
-
410
- .resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; z-index: 11; }
411
- .speech-bubble.selected .resize-handle { display: block; }
412
- .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
413
- .resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
414
- .resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
415
- .resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
416
-
417
- /* CONTROLS */
418
- .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; }
419
- .edit-controls h4 { margin: 0 0 10px 0; color: #4ECDC4; text-align: center; }
420
- .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
421
- .control-group label { font-size: 11px; font-weight: bold; display: block; margin-bottom: 3px; }
422
- 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; }
423
- .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
424
- .color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
425
- .slider-container { display: flex; align-items: center; gap: 5px; margin-top: 5px; }
426
- .slider-container label { min-width: 40px; font-size: 11px; }
427
- .action-btn { background: #4CAF50; color: white; }
428
- .reset-btn { background: #e74c3c; color: white; }
429
- .secondary-btn { background: #f39c12; color: white; }
430
- .export-btn { background: #2196F3; color: white; }
431
- .save-btn { background: #9b59b6; color: white; }
432
-
433
- /* MODAL */
434
- .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; }
435
- .modal-content { background: white; padding: 30px; border-radius: 12px; max-width: 400px; width: 90%; text-align: center; }
436
- .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; }
437
- .modal-content button { background: #3498db; color: white; border: none; padding: 12px 30px; border-radius: 8px; cursor: pointer; font-weight: bold; margin: 5px; }
438
- </style>
439
- </head>
440
- <body>
441
- <div id="upload-container">
442
- <div class="upload-box">
443
- <h1>🎬 Enhanced Comic Generator</h1>
444
- <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
445
- <label for="file-upload" class="file-label">📁 Choose Video File</label>
446
- <span id="fn" style="margin-bottom:10px; display:block; color:#666;">No file selected</span>
447
-
448
- <div class="page-input-group">
449
- <label>📚 Total Comic Pages:</label>
450
- <input type="number" id="page-count" value="4" min="1" max="15" placeholder="e.g. 4 (Video will be divided evenly)">
451
- <small style="color:#666; font-size:11px; display:block; margin-top:5px;">System calculates ~4 panels per page.</small>
452
- </div>
453
-
454
- <button class="submit-btn" onclick="upload()">🚀 Generate Comic</button>
455
- <button id="restore-draft-btn" class="restore-btn" style="display:none; margin-top:15px;" onclick="restoreDraft()">📂 Restore Unsaved Draft</button>
456
-
457
- <div class="load-section">
458
- <h3>📥 Load Saved Comic</h3>
459
- <div class="load-input-group">
460
- <input type="text" id="load-code-input" placeholder="SAVE CODE" maxlength="8" style="text-transform:uppercase;">
461
- <button onclick="loadSavedComic()">Load</button>
462
- </div>
463
- </div>
464
- <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
465
- <div class="loader" style="margin:0 auto;"></div>
466
- <p id="status-text" style="margin-top:10px;">Starting...</p>
467
  </div>
468
  </div>
 
 
 
 
469
  </div>
470
-
471
- <div id="editor-container">
472
- <div class="comic-wrapper" id="comic-container"></div>
473
- <input type="file" id="image-uploader" style="display: none;" accept="image/*">
474
- <div class="edit-controls">
475
- <h4>✏️ Interactive Editor</h4>
476
-
477
- <div class="control-group">
478
- <label>💾 Save & Load:</label>
479
- <button onclick="saveComic()" class="save-btn">💾 Save Comic</button>
480
- <div id="current-save-code" style="display:none; margin-top:5px; text-align:center;">
481
- <span id="display-save-code" style="font-weight:bold; background:#eee; padding:2px 5px; border-radius:3px;"></span>
482
- <button onclick="copyCode()" style="padding:2px; width:auto; font-size:10px;">Copy</button>
483
- </div>
484
  </div>
485
-
486
- <div class="control-group">
487
- <label>💬 Bubble Styling:</label>
488
- <select id="bubble-type-select" onchange="changeBubbleType(this.value)" disabled>
489
- <option value="speech">Speech</option>
490
- <option value="thought">Thought</option>
491
- <option value="reaction">Reaction (Shout)</option>
492
- <option value="narration">Narration (Box)</option>
493
- </select>
494
- <select id="font-select" onchange="changeFont(this.value)" disabled>
495
- <option value="'Comic Neue', cursive">Comic Neue</option>
496
- <option value="'Bangers', cursive">Bangers</option>
497
- <option value="'Gloria Hallelujah', cursive">Gloria</option>
498
- <option value="'Lato', sans-serif">Lato</option>
499
- </select>
500
- <div class="color-grid">
501
- <div><label>Text</label><input type="color" id="bubble-text-color" value="#ffffff" disabled></div>
502
- <div><label>Fill</label><input type="color" id="bubble-fill-color" value="#4ECDC4" disabled></div>
503
- </div>
504
- <div class="button-grid">
505
- <button onclick="addBubble()" class="action-btn">Add</button>
506
- <button onclick="deleteBubble()" class="reset-btn">Delete</button>
507
- </div>
 
 
 
 
 
 
 
508
  </div>
509
-
510
- <div class="control-group" id="tail-controls" style="display:none;">
511
- <label>📐 Tail Adjustment:</label>
512
- <button onclick="rotateTail()" class="secondary-btn">🔄 Rotate Side</button>
513
- <div class="slider-container">
514
- <label>Pos:</label>
515
- <input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)">
516
- </div>
517
  </div>
518
-
519
- <div class="control-group">
520
- <label>🖼️ Panel Tools:</label>
521
- <button onclick="replacePanelImage()" class="action-btn">🖼️ Replace Image</button>
522
- <div class="button-grid">
523
- <button onclick="adjustFrame('backward')" class="secondary-btn" id="prev-btn">⬅️ Prev</button>
524
- <button onclick="adjustFrame('forward')" class="action-btn" id="next-btn">Next ➡️</button>
525
- </div>
526
- <div class="timestamp-controls">
527
- <input type="text" id="timestamp-input" placeholder="mm:ss or secs">
528
- <button onclick="gotoTimestamp()" class="action-btn" id="go-btn">Go</button>
529
- </div>
530
  </div>
531
-
532
- <div class="control-group">
533
- <label>🔍 Zoom & Pan:</label>
534
- <div class="button-grid">
535
- <button onclick="resetPanelTransform()" class="secondary-btn">Reset</button>
536
- <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled oninput="handleZoom(this)">
537
- </div>
 
538
  </div>
539
-
540
- <div class="control-group">
541
- <button onclick="exportComic()" class="export-btn">📥 Export as PNG</button>
542
- <button onclick="goBackToUpload()" class="reset-btn" style="margin-top:10px;">🏠 Home</button>
543
  </div>
544
  </div>
545
- </div>
546
-
547
- <div class="modal-overlay" id="save-modal">
548
- <div class="modal-content">
549
- <h2>✅ Comic Saved!</h2>
550
- <div class="code" id="modal-save-code">XXXXXXXX</div>
551
- <button onclick="copyModalCode()">📋 Copy Code</button>
552
- <button class="close-btn" onclick="closeModal()">Close</button>
 
 
 
 
553
  </div>
554
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
555
 
556
- <script>
557
- 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);}); }
558
- let sid = localStorage.getItem('comic_sid') || genUUID();
559
- localStorage.setItem('comic_sid', sid);
560
-
561
- let currentSaveCode = null;
562
- let isProcessing = false;
563
- let interval, selectedBubble = null, selectedPanel = null;
564
- let isDragging = false, isResizing = false, isPanning = false;
565
- let startX, startY, initX, initY, panStartX, panStartY, panStartTx, panStartTy;
566
- let resizeHandle, originalWidth, originalHeight, originalMouseX, originalMouseY;
567
- let currentlyEditing = null;
568
-
569
- if(localStorage.getItem('comic_draft_'+sid)) document.getElementById('restore-draft-btn').style.display = 'block';
570
-
571
- function showSaveModal(code) { document.getElementById('modal-save-code').textContent = code; document.getElementById('save-modal').style.display = 'flex'; }
572
- function closeModal() { document.getElementById('save-modal').style.display = 'none'; }
573
- function copyModalCode() { navigator.clipboard.writeText(document.getElementById('modal-save-code').textContent).then(() => alert('Code copied!')); }
574
- function copyCode() { if(currentSaveCode) navigator.clipboard.writeText(currentSaveCode).then(() => alert('Code copied!')); }
575
-
576
- function setProcessing(busy) {
577
- isProcessing = busy;
578
- const btns = ['prev-btn', 'next-btn', 'go-btn'];
579
- btns.forEach(id => {
580
- const el = document.getElementById(id);
581
- if(el) { el.disabled = busy; el.style.opacity = busy ? '0.5' : '1'; el.innerText = busy ? '⏳' : el.getAttribute('data-txt') || el.innerText; }
582
- });
583
- }
584
- async function saveComic() {
585
- const state = getCurrentState();
586
- if(!state || state.length === 0) { alert('No comic to save!'); return; }
587
- try {
588
- const r = await fetch(`/save_comic?sid=${sid}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ pages: state, savedAt: new Date().toISOString() }) });
589
- const d = await r.json();
590
- 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(); }
591
- else { alert('Failed to save: ' + d.message); }
592
- } catch(e) { console.error(e); alert('Error saving comic'); }
593
- }
594
-
595
- async function loadSavedComic() {
596
- const code = document.getElementById('load-code-input').value.trim().toUpperCase();
597
- if(!code || code.length < 4) { alert('Invalid code'); return; }
598
- try {
599
- const r = await fetch(`/load_comic/${code}`);
600
- const d = await r.json();
601
- 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(); }
602
- else { alert('Load failed: ' + d.message); }
603
- } catch(e) { console.error(e); alert('Error loading comic.'); }
604
- }
605
-
606
- function restoreDraft() {
607
- try {
608
- const state = JSON.parse(localStorage.getItem('comic_draft_'+sid));
609
- if(state.saveCode) { currentSaveCode = state.saveCode; document.getElementById('display-save-code').textContent = state.saveCode; document.getElementById('current-save-code').style.display = 'block'; }
610
- renderFromState(state.pages || state);
611
- document.getElementById('upload-container').style.display = 'none';
612
- document.getElementById('editor-container').style.display = 'block';
613
- } catch(e) { console.error(e); alert("Failed to restore."); }
614
- }
615
-
616
- function getCurrentState() {
617
- const pages = [];
618
- document.querySelectorAll('.comic-page').forEach(p => {
619
- const panels = [];
620
- p.querySelectorAll('.panel').forEach(pan => {
621
- const img = pan.querySelector('img');
622
- const bubbles = [];
623
- pan.querySelectorAll('.speech-bubble').forEach(b => {
624
- const textEl = b.querySelector('.bubble-text');
625
- bubbles.push({
626
- text: textEl ? textEl.textContent : '',
627
- left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
628
- classes: b.className, type: b.dataset.type, font: b.style.fontFamily,
629
- tailPos: b.style.getPropertyValue('--tail-pos'),
630
- colors: { fill: b.style.getPropertyValue('--bubble-fill-color'), text: b.style.getPropertyValue('--bubble-text-color') }
631
- });
632
- });
633
- panels.push({
634
- src: img.src,
635
- zoom: img.dataset.zoom, tx: img.dataset.translateX, ty: img.dataset.translateY,
636
- bubbles: bubbles
637
  });
638
  });
639
- pages.push({ panels: panels });
640
- });
641
- return pages;
642
- }
643
-
644
- function saveDraft() { localStorage.setItem('comic_draft_'+sid, JSON.stringify({ pages: getCurrentState(), saveCode: currentSaveCode, savedAt: new Date().toISOString() })); }
645
-
646
- function renderFromState(pagesData) {
647
- const con = document.getElementById('comic-container'); con.innerHTML = '';
648
- pagesData.forEach((page, pageIdx) => {
649
- const pageWrapper = document.createElement('div'); pageWrapper.className = 'page-wrapper';
650
- const pageTitle = document.createElement('h2'); pageTitle.className = 'page-title'; pageTitle.textContent = `Page ${pageIdx + 1}`;
651
- pageWrapper.appendChild(pageTitle);
652
- const div = document.createElement('div'); div.className = 'comic-page';
653
- const grid = document.createElement('div'); grid.className = 'comic-grid';
654
- page.panels.forEach((pan) => {
655
- const pDiv = document.createElement('div'); pDiv.className = 'panel';
656
- pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
657
- const img = document.createElement('img');
658
- img.src = pan.src.includes('?') ? pan.src : pan.src + `?sid=${sid}`;
659
- img.dataset.zoom = pan.zoom || 100; img.dataset.translateX = pan.tx || 0; img.dataset.translateY = pan.ty || 0;
660
- updateImageTransform(img);
661
- img.onmousedown = (e) => startPan(e, img);
662
- pDiv.appendChild(img);
663
- (pan.bubbles || []).forEach(bData => { pDiv.appendChild(createBubbleHTML(bData)); });
664
- grid.appendChild(pDiv);
665
  });
666
- div.appendChild(grid); pageWrapper.appendChild(div); con.appendChild(pageWrapper);
667
- });
668
- }
669
-
670
- async function upload() {
671
- const f = document.getElementById('file-upload').files[0];
672
- const pCount = document.getElementById('page-count').value;
673
- if(!f) return alert("Select a video");
674
- sid = genUUID(); localStorage.setItem('comic_sid', sid); currentSaveCode = null;
675
- document.querySelector('.upload-box').style.display='none';
676
- document.getElementById('loading-view').style.display='flex';
677
- const fd = new FormData(); fd.append('file', f); fd.append('target_pages', pCount);
678
- const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
679
- if(r.ok) interval = setInterval(checkStatus, 2000);
680
- else { alert("Upload failed"); location.reload(); }
681
- }
682
-
683
- async function checkStatus() {
684
- try {
685
- const r = await fetch(`/status?sid=${sid}`); const d = await r.json();
686
- document.getElementById('status-text').innerText = d.message;
687
- if(d.progress >= 100) { clearInterval(interval); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='block'; loadNewComic(); }
688
- else if (d.progress < 0) { clearInterval(interval); document.getElementById('status-text').textContent = "Error: " + d.message; document.querySelector('.loader').style.display = 'none'; }
689
- } catch(e) {}
690
- }
691
-
692
- function loadNewComic() {
693
- fetch(`/output/pages.json?sid=${sid}`).then(r=>r.json()).then(data => {
694
- const cleanData = data.map((p, pi) => ({
695
- panels: p.panels.map((pan, j) => ({
696
- src: `/frames/${pan.image}?sid=${sid}`,
697
- bubbles: (p.bubbles && p.bubbles[j] && p.bubbles[j].dialog) ? [{
698
- text: p.bubbles[j].dialog,
699
- left: (p.bubbles[j].bubble_offset_x || 50) + 'px',
700
- top: (p.bubbles[j].bubble_offset_y || 20) + 'px',
701
- type: (p.bubbles[j].type || 'speech'),
702
- classes: `speech-bubble ${p.bubbles[j].type || 'speech'} tail-bottom`
703
- }] : []
704
- }))
705
- }));
706
- renderFromState(cleanData); saveDraft();
707
  });
708
- }
709
-
710
- function createBubbleHTML(data) {
711
- const b = document.createElement('div');
712
- const type = data.type || 'speech';
713
- b.className = data.classes || `speech-bubble ${type} tail-bottom`;
714
- if (type === 'thought' && !b.className.includes('pos-')) b.className += ' pos-bl'; // Default position for thought
715
-
716
- b.dataset.type = type;
717
- b.style.left = data.left; b.style.top = data.top;
718
- if(data.width) b.style.width = data.width; if(data.height) b.style.height = data.height;
719
- if(data.font) b.style.fontFamily = data.font;
720
- if(data.colors) { b.style.setProperty('--bubble-fill-color', data.colors.fill || '#4ECDC4'); b.style.setProperty('--bubble-text-color', data.colors.text || '#ffffff'); }
721
- if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
722
-
723
- const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || ''; b.appendChild(textSpan);
724
-
725
- if(type === 'thought') { for(let i=1; i<=2; i++){ const d = document.createElement('div'); d.className = `thought-dot thought-dot-${i}`; b.appendChild(d); } }
726
-
727
- ['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); });
728
-
729
- b.onmousedown = (e) => {
730
- if(e.target.classList.contains('resize-handle')) return;
731
- e.stopPropagation(); selectBubble(b); isDragging = true; startX = e.clientX; startY = e.clientY; initX = b.offsetLeft; initY = b.offsetTop;
732
- };
733
- b.onclick = (e) => { e.stopPropagation(); };
734
- b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
735
- return b;
736
- }
737
-
738
- function editBubbleText(bubble) {
739
- if (currentlyEditing) return; currentlyEditing = bubble;
740
- const textSpan = bubble.querySelector('.bubble-text'); const textarea = document.createElement('textarea');
741
- textarea.value = textSpan.textContent; bubble.appendChild(textarea); textSpan.style.display = 'none'; textarea.focus();
742
- const finishEditing = () => { textSpan.textContent = textarea.value; textarea.remove(); textSpan.style.display = ''; currentlyEditing = null; saveDraft(); };
743
- textarea.addEventListener('blur', finishEditing, { once: true });
744
- textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); } });
745
- }
746
-
747
- document.addEventListener('mousemove', (e) => {
748
- if(isDragging && selectedBubble) { selectedBubble.style.left = (initX + e.clientX - startX) + 'px'; selectedBubble.style.top = (initY + e.clientY - startY) + 'px'; }
749
- if(isResizing && selectedBubble) { resizeBubble(e); }
750
- if(isPanning && selectedPanel) { panImage(e); }
751
  });
752
- document.addEventListener('mouseup', () => { if(isDragging || isResizing || isPanning) saveDraft(); isDragging = false; isResizing = false; isPanning = false; });
753
-
754
- 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; }
755
- 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'; }
756
-
757
- function selectBubble(el) {
758
- if(selectedBubble) selectedBubble.classList.remove('selected');
759
- if(selectedPanel) { selectedPanel.classList.remove('selected'); selectedPanel = null; }
760
- selectedBubble = el; el.classList.add('selected');
761
- document.getElementById('bubble-type-select').disabled = false;
762
- document.getElementById('font-select').disabled = false;
763
- document.getElementById('bubble-text-color').disabled = false;
764
- document.getElementById('bubble-fill-color').disabled = false;
765
- document.getElementById('tail-controls').style.display = 'block';
766
- document.getElementById('bubble-type-select').value = el.dataset.type || 'speech';
767
- }
768
-
769
- function selectPanel(el) {
770
- if(selectedPanel) selectedPanel.classList.remove('selected');
771
- if(selectedBubble) { selectedBubble.classList.remove('selected'); selectedBubble = null; }
772
- selectedPanel = el; el.classList.add('selected');
773
- document.getElementById('zoom-slider').disabled = false;
774
- const img = el.querySelector('img');
775
- document.getElementById('zoom-slider').value = img.dataset.zoom || 100;
776
- document.getElementById('bubble-type-select').disabled = true;
777
- document.getElementById('font-select').disabled = true;
778
- document.getElementById('tail-controls').style.display = 'none';
779
- }
780
-
781
- function addBubble() {
782
- if(!selectedPanel) return alert("Select a panel first");
783
- const b = createBubbleHTML({ text: "Text", left: "50px", top: "30px", type: 'speech', classes: "speech-bubble speech tail-bottom" });
784
- selectedPanel.appendChild(b); selectBubble(b); saveDraft();
785
- }
786
-
787
- function deleteBubble() {
788
- if(!selectedBubble) return alert("Select a bubble");
789
- selectedBubble.remove(); selectedBubble=null; saveDraft();
790
- }
791
-
792
- function changeBubbleType(type) {
793
- if(!selectedBubble) return;
794
- selectedBubble.dataset.type = type;
795
- selectedBubble.className = 'speech-bubble ' + type + ' selected';
796
 
797
- // Default tail for thought is pos-bl if not set
798
- if(type === 'thought') selectedBubble.classList.add('pos-bl');
799
- else selectedBubble.classList.add('tail-bottom'); // Default for speech
 
800
 
801
- selectedBubble.querySelectorAll('.thought-dot').forEach(d=>d.remove());
802
- if(type === 'thought') { for(let i=1; i<=2; i++){ const d = document.createElement('div'); d.className = `thought-dot thought-dot-${i}`; selectedBubble.appendChild(d); } }
803
- saveDraft();
804
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
805
 
806
- function changeFont(font) { if(!selectedBubble) return; selectedBubble.style.fontFamily = font; saveDraft(); }
 
807
 
808
- function rotateTail() {
809
- if(!selectedBubble) return;
810
- const type = selectedBubble.dataset.type;
811
-
812
- if(type === 'speech') {
813
- const positions = ['tail-bottom', 'tail-right', 'tail-top', 'tail-left'];
814
- let current = 0;
815
- positions.forEach((pos, i) => { if(selectedBubble.classList.contains(pos)) current = i; });
816
- selectedBubble.classList.remove(positions[current]);
817
- selectedBubble.classList.add(positions[(current + 1) % 4]);
818
- }
819
- else if (type === 'thought') {
820
- // Cycle specifically through the 4 CSS positions we defined
821
- const positions = ['pos-bl', 'pos-br', 'pos-tr', 'pos-tl'];
822
- let current = 0;
823
- positions.forEach((pos, i) => { if(selectedBubble.classList.contains(pos)) current = i; });
824
- selectedBubble.classList.remove(positions[current]);
825
- selectedBubble.classList.add(positions[(current + 1) % 4]);
826
- }
827
- saveDraft();
 
 
 
 
828
  }
829
-
830
- function slideTail(v) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', v+'%'); saveDraft(); } }
831
-
832
- document.getElementById('bubble-text-color').addEventListener('input', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-text-color', e.target.value); saveDraft(); } });
833
- document.getElementById('bubble-fill-color').addEventListener('input', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-fill-color', e.target.value); saveDraft(); } });
834
-
835
- function handleZoom(el) { if(!selectedPanel) return; const img = selectedPanel.querySelector('img'); img.dataset.zoom = el.value; updateImageTransform(img); saveDraft(); }
836
- 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'); }
837
- 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); }
838
- 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); }
839
- function resetPanelTransform() { if(!selectedPanel) return alert("Select a panel"); const img = selectedPanel.querySelector('img'); img.dataset.zoom = 100; img.dataset.translateX = 0; img.dataset.translateY = 0; document.getElementById('zoom-slider').value = 100; updateImageTransform(img); saveDraft(); }
840
-
841
- function replacePanelImage() { if(!selectedPanel) return alert("Select a panel"); const inp = document.getElementById('image-uploader'); inp.onchange = async (e) => { const fd = new FormData(); fd.append('image', e.target.files[0]); const img = selectedPanel.querySelector('img'); const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd}); const d = await r.json(); if(d.success) { img.src = `/frames/${d.new_filename}?sid=${sid}`; saveDraft(); } inp.value = ''; }; inp.click(); }
842
- async function adjustFrame(dir) { if(isProcessing) return; if(!selectedPanel) return alert("Select a panel"); const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0]; setProcessing(true); img.style.opacity = '0.5'; try { const r = await fetch(`/regenerate_frame?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, direction:dir}) }); const d = await r.json(); if(d.success) { img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`; } else { alert('Error: ' + d.message); } } catch(e) { console.error(e); } img.style.opacity = '1'; setProcessing(false); saveDraft(); }
843
- async function gotoTimestamp() { if(isProcessing) return; if(!selectedPanel) return alert("Select a panel"); let v = document.getElementById('timestamp-input').value.trim(); if(!v) return; if(v.includes(':')) { let p = v.split(':'); v = parseInt(p[0]) * 60 + parseFloat(p[1]); } else { v = parseFloat(v); } if(isNaN(v)) return alert("Invalid time"); const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0]; setProcessing(true); img.style.opacity = '0.5'; try { const r = await fetch(`/goto_timestamp?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, timestamp:v}) }); const d = await r.json(); if(d.success) { img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`; document.getElementById('timestamp-input').value = ''; } else { alert('Error: ' + d.message); } } catch(e) { console.error(e); } img.style.opacity = '1'; setProcessing(false); saveDraft(); }
844
- async function exportComic() { const pgs = document.querySelectorAll('.comic-page'); if(pgs.length === 0) return alert("No pages found"); alert(`Exporting ${pgs.length} page(s)...`); 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'; }); for(let i = 0; i < pgs.length; i++) { 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(); } catch(err) { console.error(err); alert(`Failed to export page ${i+1}`); } } bubbles.forEach(b => { b.style.width = ''; b.style.height = ''; }); }
845
-
846
- function goBackToUpload() { if(confirm('Go home? Unsaved changes will be lost.')) { document.getElementById('editor-container').style.display = 'none'; document.getElementById('upload-container').style.display = 'flex'; document.getElementById('loading-view').style.display = 'none'; } }
847
- </script>
848
- </body>
849
- </html>
 
 
850
  '''
851
 
852
  @app.route('/')
@@ -862,7 +902,7 @@ def upload():
862
 
863
  # GET PAGE COUNT FROM FORM
864
  target_pages = request.form.get('target_pages', 4)
865
-
866
  f = request.files['file']
867
  gen = EnhancedComicGenerator(sid)
868
  gen.cleanup()
@@ -970,6 +1010,6 @@ def load_comic(code):
970
  return jsonify({'success': False, 'message': str(e)})
971
 
972
  if __name__ == '__main__':
973
- try: gpu_warmup()
974
  except: pass
975
  app.run(host='0.0.0.0', port=7860)
 
1
+ import spaces # <--- CRITICAL: MUST BE THE FIRST IMPORT
2
  import os
3
  import time
4
  import threading
 
28
  # ======================================================
29
  def bubble(dialog="", bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, emotion='normal', type='speech'):
30
  return {
31
+ 'dialog': dialog,
32
+ 'bubble_offset_x': int(bubble_offset_x),
33
  'bubble_offset_y': int(bubble_offset_y),
34
+ 'lip_x': int(lip_x),
35
+ 'lip_y': int(lip_y),
36
  'emotion': emotion,
37
  'type': type,
38
  'tail_pos': '50%',
39
  'classes': f'speech-bubble {type} tail-bottom'
40
  }
41
 
42
+ def panel(image=""):
43
  return {'image': image}
44
 
45
  class Page:
46
+ def __init__(self, panels, bubbles):
47
  self.panels = panels
48
  self.bubbles = bubbles
49
 
 
54
  logger = logging.getLogger(__name__)
55
 
56
  app = Flask(__name__)
57
+ BASE_USER_DIR = "userdata"
58
  SAVED_COMICS_DIR = "saved_comics"
59
 
60
  os.makedirs(BASE_USER_DIR, exist_ok=True)
 
70
  # ======================================================
71
  # 🧠 GLOBAL GPU FUNCTIONS
72
  # ======================================================
 
73
  @spaces.GPU(duration=300)
74
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
75
  print(f"🚀 GPU Task Started: {video_path} | Pages: {target_pages}")
 
83
  from backend.subtitles.subs_real import get_real_subtitles
84
  from backend.ai_bubble_placement import ai_bubble_placer
85
  from backend.ai_enhanced_core import face_detector
86
+
87
  # 1. Analyze Video
88
  cap = cv2.VideoCapture(video_path)
89
  if not cap.isOpened(): raise Exception("Cannot open video")
 
91
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
92
  duration = total_frames / fps
93
  cap.release()
94
+
95
  # 2. Subtitles Generation
96
  user_srt = os.path.join(user_dir, 'subs.srt')
97
  try:
 
103
 
104
  with open(user_srt, 'r', encoding='utf-8') as f:
105
  all_subs = list(srt.parse(f.read()))
106
+
107
  # 3. Smart Keyframe Selection
108
  valid_subs = [s for s in all_subs if s.content.strip()]
109
  raw_moments = [{'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
110
+
111
  if target_pages <= 0: target_pages = 1
112
  panels_per_page = 4
113
  total_panels_needed = target_pages * panels_per_page
114
+
115
  selected_moments = []
116
  if not raw_moments:
117
  times = np.linspace(1, duration-1, total_panels_needed)
 
127
  cap = cv2.VideoCapture(video_path)
128
  count = 0
129
  frame_files_ordered = []
130
+
131
  for i, moment in enumerate(selected_moments):
132
  mid = (moment['start'] + moment['end']) / 2
133
  if mid > duration: mid = duration - 1
 
142
  frame_files_ordered.append(fname)
143
  count += 1
144
  cap.release()
145
+
146
  with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
147
 
148
  # 5. Image Enhancement
149
  try: black_bar_crop()
150
  except: pass
151
+
152
  se = SimpleColorEnhancer()
153
  qe = QualityColorEnhancer()
154
+
155
  for f in frame_files_ordered:
156
  p = os.path.join(frames_dir, f)
157
  try: se.enhance_single(p, p)
 
203
  import cv2
204
  import json
205
  from backend.simple_color_enhancer import SimpleColorEnhancer
206
+
207
  if not os.path.exists(metadata_path): return {"success": False, "message": "No metadata"}
208
  with open(metadata_path, 'r') as f: meta = json.load(f)
209
  if fname not in meta: return {"success": False, "message": "Frame not found"}
210
+
211
  t = meta[fname]['time'] if isinstance(meta[fname], dict) else meta[fname]
212
  cap = cv2.VideoCapture(video_path)
213
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
214
  offset = (1.0/fps) * (1 if direction == 'forward' else -1)
215
  new_t = max(0, t + offset)
216
+
217
  cap.set(cv2.CAP_PROP_POS_MSEC, new_t * 1000)
218
  ret, frame = cap.read()
219
  cap.release()
220
+
221
  if ret:
222
  p = os.path.join(frames_dir, fname)
223
  cv2.imwrite(p, frame)
 
236
  import cv2
237
  import json
238
  from backend.simple_color_enhancer import SimpleColorEnhancer
239
+
240
  cap = cv2.VideoCapture(video_path)
241
  cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
242
  ret, frame = cap.read()
243
  cap.release()
244
+
245
  if ret:
246
  p = os.path.join(frames_dir, fname)
247
  cv2.imwrite(p, frame)
 
296
  # ======================================================
297
  # 🌐 ROUTES & FULL UI
298
  # ======================================================
 
299
  INDEX_HTML = '''
300
  <!DOCTYPE html>
301
+ <html lang="en">
302
+ <head>
303
+ <meta charset="UTF-8">
304
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
305
+ <title>🎬 Enhanced Comic Generator</title>
306
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
307
+ <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Gloria+Hallelujah&family=Lato&display=swap" rel="stylesheet">
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-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
313
+ .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; }
314
+
315
+ #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 100px; }
316
+
317
+ h1 { color: #2c3e50; margin-bottom: 20px; font-weight: 600; }
318
+ .file-input { display: none; }
319
+ .file-label { display: block; padding: 15px; background: #2c3e50; color: white; border-radius: 8px; cursor: pointer; font-weight: bold; margin-bottom: 10px; transition:0.2s; }
320
+ .file-label:hover { background: #34495e; }
321
+
322
+ .page-input-group { margin: 20px 0; text-align: left; }
323
+ .page-input-group label { font-weight: bold; font-size: 14px; display: block; margin-bottom: 5px; color: #333; }
324
+ .page-input-group input { width: 100%; padding: 12px; border: 2px solid #ddd; border-radius: 8px; font-size: 16px; box-sizing: border-box; }
325
+
326
+ .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; }
327
+ .submit-btn:hover { background: #d35400; }
328
+ .restore-btn { margin-top: 10px; background: #27ae60; color: white; padding: 12px; width: 100%; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
329
+
330
+ .load-section { margin-top: 30px; padding-top: 20px; border-top: 2px solid #eee; }
331
+ .load-input-group { display: flex; gap: 10px; margin-top: 10px; }
332
+ .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; }
333
+ .load-input-group button { padding: 12px 20px; background: #3498db; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
334
+
335
+ .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; }
336
+ @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
337
+
338
+ /* COMIC LAYOUT */
339
+ .comic-wrapper { max-width: 1000px; margin: 0 auto; }
340
+ .page-wrapper { margin: 30px auto; display: flex; flex-direction: column; align-items: center; }
341
+ .page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
342
+ .comic-page { background: white; width: 600px; height: 400px; box-shadow: 0 4px 10px rgba(0,0,0,0.1); position: relative; overflow: hidden; border: 2px solid #000; padding: 10px; }
343
+
344
+ /* DYNAMIC TEMPLATE GRIDS */
345
+ .comic-grid { display: grid; gap: 10px; width: 100%; height: 100%; transition: all 0.3s ease; }
346
+
347
+ /* Template: Classic (2x2) */
348
+ .comic-grid.t-classic { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
349
+
350
+ /* Template: Cinematic (4 Vertical Strips) */
351
+ .comic-grid.t-cinematic { grid-template-columns: 1fr; grid-template-rows: 1fr 1fr 1fr 1fr; }
352
+
353
+ /* Template: Featured Top (1 Large Top, 3 Small Bottom) */
354
+ .comic-grid.t-feat-top { grid-template-columns: 1fr 1fr 1fr; grid-template-rows: 2fr 1fr; }
355
+ .comic-grid.t-feat-top .panel:nth-child(1) { grid-column: span 3; }
356
+
357
+ /* Template: Side Focus (1 Large Left, 3 Stacked Right) */
358
+ .comic-grid.t-side { grid-template-columns: 1.5fr 1fr; grid-template-rows: 1fr 1fr 1fr; }
359
+ .comic-grid.t-side .panel:nth-child(1) { grid-row: span 3; }
360
+
361
+ .panel { position: relative; overflow: hidden; border: 2px solid #000; background: #eee; cursor: pointer; }
362
+ .panel.selected { outline: 3px solid #2196F3; outline-offset: -3px; }
363
+ .panel img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.1s ease-out; transform-origin: center center; }
364
+ .panel img.pannable { cursor: grab; }
365
+ .panel img.panning { cursor: grabbing; }
366
+
367
+ /* SPEECH BUBBLES */
368
+ .speech-bubble {
369
+ position: absolute; display: flex; justify-content: center; align-items: center;
370
+ width: 150px; height: 80px; min-width: 50px; min-height: 30px; box-sizing: border-box;
371
+ z-index: 10; cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold;
372
+ font-size: 13px; text-align: center; overflow: visible;
373
+ --tail-pos: 50%;
374
+ }
375
+ .bubble-text { padding: 0.8em; word-wrap: break-word; position: relative; z-index: 5; pointer-events: none; user-select: none; }
376
+ .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
377
+ .speech-bubble textarea { position: absolute; top:0; left:0; width:100%; height:100%; box-sizing:border-box; border:1px solid #4CAF50; background:rgba(255,255,255,0.95); text-align:center; padding:8px; z-index:102; resize:none; font: inherit; }
378
+
379
+ /* SPEECH BUBBLE CSS (Tails) */
380
+ .speech-bubble.speech {
381
+ --b: 3em; --h: 1.8em; --t: 0.6; --p: var(--tail-pos, 50%); --r: 1.2em;
382
+ background: var(--bubble-fill-color, #4ECDC4);
383
+ color: var(--bubble-text-color, #fff);
384
+ padding: 1em;
385
+ 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);
386
+ }
387
+ .speech-bubble.speech:before {
388
+ content: ""; position: absolute; width: var(--b); height: var(--h);
389
+ background: inherit; border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
390
+ -webkit-mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
391
+ mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
392
+ }
393
+
394
+ .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))); }
395
+ .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); }
396
+ .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); }
397
+ .speech-bubble.speech.tail-left { border-radius: var(--r); }
398
+ .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; }
399
+ .speech-bubble.speech.tail-right { border-radius: var(--r); }
400
+ .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; }
401
+
402
+ .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
403
+ .speech-bubble.thought::before { display:none; }
404
+ .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
405
+ .thought-dot-1 { width: 20px; height: 20px; }
406
+ .thought-dot-2 { width: 12px; height: 12px; }
407
+ .speech-bubble.thought.pos-bl .thought-dot-1 { left: 20px; bottom: -20px; } .speech-bubble.thought.pos-bl .thought-dot-2 { left: 10px; bottom: -32px; }
408
+ .speech-bubble.thought.pos-br .thought-dot-1 { right: 20px; bottom: -20px; } .speech-bubble.thought.pos-br .thought-dot-2 { right: 10px; bottom: -32px; }
409
+ .speech-bubble.thought.pos-tr .thought-dot-1 { right: 20px; top: -20px; } .speech-bubble.thought.pos-tr .thought-dot-2 { right: 10px; top: -32px; }
410
+ .speech-bubble.thought.pos-tl .thought-dot-1 { left: 20px; top: -20px; } .speech-bubble.thought.pos-tl .thought-dot-2 { left: 10px; top: -32px; }
411
+
412
+ .speech-bubble.reaction { background: #FFD700; border: 3px solid #E53935; color: #D32F2F; font-weight: 900; text-transform: uppercase; clip-path: polygon(0% 25%, 17% 21%, 17% 0%, 31% 16%, 50% 4%, 69% 16%, 83% 0%, 83% 21%, 100% 25%, 85% 45%, 95% 62%, 82% 79%, 100% 97%, 79% 89%, 60% 98%, 46% 82%, 27% 95%, 15% 78%, 5% 62%, 15% 45%); }
413
+ .speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; }
414
+
415
+ .resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; z-index: 11; }
416
+ .speech-bubble.selected .resize-handle { display: block; }
417
+ .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
418
+ .resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
419
+ .resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
420
+ .resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
421
+
422
+ /* CONTROLS */
423
+ .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; }
424
+ .edit-controls h4 { margin: 0 0 10px 0; color: #4ECDC4; text-align: center; }
425
+ .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
426
+ .control-group label { font-size: 11px; font-weight: bold; display: block; margin-bottom: 3px; }
427
+ 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; }
428
+ .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
429
+ .color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
430
+ .slider-container { display: flex; align-items: center; gap: 5px; margin-top: 5px; }
431
+ .slider-container label { min-width: 40px; font-size: 11px; }
432
+ .action-btn { background: #4CAF50; color: white; }
433
+ .reset-btn { background: #e74c3c; color: white; }
434
+ .secondary-btn { background: #f39c12; color: white; }
435
+ .export-btn { background: #2196F3; color: white; }
436
+ .save-btn { background: #9b59b6; color: white; }
437
+
438
+ /* MODAL */
439
+ .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; }
440
+ .modal-content { background: white; padding: 30px; border-radius: 12px; max-width: 400px; width: 90%; text-align: center; }
441
+ .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; }
442
+ .modal-content button { background: #3498db; color: white; border: none; padding: 12px 30px; border-radius: 8px; cursor: pointer; font-weight: bold; margin: 5px; }
443
+ </style>
444
+ </head>
445
+ <body>
446
+ <div id="upload-container">
447
+ <div class="upload-box">
448
+ <h1>🎬 Enhanced Comic Generator</h1>
449
+ <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
450
+ <label for="file-upload" class="file-label">📁 Choose Video File</label>
451
+ <span id="fn" style="margin-bottom:10px; display:block; color:#666;">No file selected</span>
452
+ <div class="page-input-group">
453
+ <label>📚 Total Comic Pages:</label>
454
+ <input type="number" id="page-count" value="4" min="1" max="15" placeholder="e.g. 4">
455
+ <small style="color:#666; font-size:11px; display:block; margin-top:5px;">System calculates ~4 panels per page.</small>
456
+ </div>
457
+ <button class="submit-btn" onclick="upload()">🚀 Generate Comic</button>
458
+ <button id="restore-draft-btn" class="restore-btn" style="display:none; margin-top:15px;" onclick="restoreDraft()">📂 Restore Unsaved Draft</button>
459
+ <div class="load-section">
460
+ <h3>📥 Load Saved Comic</h3>
461
+ <div class="load-input-group">
462
+ <input type="text" id="load-code-input" placeholder="SAVE CODE" maxlength="8" style="text-transform:uppercase;">
463
+ <button onclick="loadSavedComic()">Load</button>
 
464
  </div>
465
  </div>
466
+ <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
467
+ <div class="loader" style="margin:0 auto;"></div>
468
+ <p id="status-text" style="margin-top:10px;">Starting...</p>
469
+ </div>
470
  </div>
471
+ </div>
472
+
473
+ <div id="editor-container">
474
+ <div class="comic-wrapper" id="comic-container"></div>
475
+ <input type="file" id="image-uploader" style="display: none;" accept="image/*">
476
+ <div class="edit-controls">
477
+ <h4>✏️ Interactive Editor</h4>
478
+
479
+ <div class="control-group">
480
+ <label>💾 Save & Load:</label>
481
+ <button onclick="saveComic()" class="save-btn">💾 Save Comic</button>
482
+ <div id="current-save-code" style="display:none; margin-top:5px; text-align:center;">
483
+ <span id="display-save-code" style="font-weight:bold; background:#eee; padding:2px 5px; border-radius:3px;"></span>
484
+ <button onclick="copyCode()" style="padding:2px; width:auto; font-size:10px;">Copy</button>
485
  </div>
486
+ </div>
487
+
488
+ <!-- NEW TEMPLATE OPTIONS -->
489
+ <div class="control-group">
490
+ <label>📐 Page Layout (Template):</label>
491
+ <select id="template-select" onchange="changeTemplate(this.value)">
492
+ <option value="t-classic">Classic (2x2)</option>
493
+ <option value="t-feat-top">Featured Top (1-3)</option>
494
+ <option value="t-side">Side Focus (Left)</option>
495
+ <option value="t-cinematic">Cinematic (Vertical)</option>
496
+ </select>
497
+ </div>
498
+
499
+ <div class="control-group">
500
+ <label>💬 Bubble Styling:</label>
501
+ <select id="bubble-type-select" onchange="changeBubbleType(this.value)" disabled>
502
+ <option value="speech">Speech</option>
503
+ <option value="thought">Thought</option>
504
+ <option value="reaction">Reaction (Shout)</option>
505
+ <option value="narration">Narration (Box)</option>
506
+ </select>
507
+ <select id="font-select" onchange="changeFont(this.value)" disabled>
508
+ <option value="'Comic Neue', cursive">Comic Neue</option>
509
+ <option value="'Bangers', cursive">Bangers</option>
510
+ <option value="'Gloria Hallelujah', cursive">Gloria</option>
511
+ <option value="'Lato', sans-serif">Lato</option>
512
+ </select>
513
+ <div class="color-grid">
514
+ <div><label>Text</label><input type="color" id="bubble-text-color" value="#ffffff" disabled></div>
515
+ <div><label>Fill</label><input type="color" id="bubble-fill-color" value="#4ECDC4" disabled></div>
516
  </div>
517
+ <div class="button-grid">
518
+ <button onclick="addBubble()" class="action-btn">Add</button>
519
+ <button onclick="deleteBubble()" class="reset-btn">Delete</button>
 
 
 
 
 
520
  </div>
521
+ </div>
522
+
523
+ <div class="control-group" id="tail-controls" style="display:none;">
524
+ <label>📐 Tail Adjustment:</label>
525
+ <button onclick="rotateTail()" class="secondary-btn">🔄 Rotate Side</button>
526
+ <div class="slider-container">
527
+ <label>Pos:</label>
528
+ <input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)">
 
 
 
 
529
  </div>
530
+ </div>
531
+
532
+ <div class="control-group">
533
+ <label>🖼️ Panel Tools:</label>
534
+ <button onclick="replacePanelImage()" class="action-btn">🖼️ Replace Image</button>
535
+ <div class="button-grid">
536
+ <button onclick="adjustFrame('backward')" class="secondary-btn" id="prev-btn">⬅️ Prev</button>
537
+ <button onclick="adjustFrame('forward')" class="action-btn" id="next-btn">Next ➡️</button>
538
  </div>
539
+ <div class="timestamp-controls">
540
+ <input type="text" id="timestamp-input" placeholder="mm:ss or secs">
541
+ <button onclick="gotoTimestamp()" class="action-btn" id="go-btn">Go</button>
 
542
  </div>
543
  </div>
544
+
545
+ <div class="control-group">
546
+ <label>🔍 Zoom & Pan:</label>
547
+ <div class="button-grid">
548
+ <button onclick="resetPanelTransform()" class="secondary-btn">Reset</button>
549
+ <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled oninput="handleZoom(this)">
550
+ </div>
551
+ </div>
552
+
553
+ <div class="control-group">
554
+ <button onclick="exportComic()" class="export-btn">📥 Export as PNG</button>
555
+ <button onclick="goBackToUpload()" class="reset-btn" style="margin-top:10px;">🏠 Home</button>
556
  </div>
557
  </div>
558
+ </div>
559
+
560
+ <div class="modal-overlay" id="save-modal">
561
+ <div class="modal-content">
562
+ <h2>✅ Comic Saved!</h2>
563
+ <div class="code" id="modal-save-code">XXXXXXXX</div>
564
+ <button onclick="copyModalCode()">📋 Copy Code</button>
565
+ <button class="close-btn" onclick="closeModal()">Close</button>
566
+ </div>
567
+ </div>
568
+
569
+ <script>
570
+ 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);}); }
571
+ let sid = localStorage.getItem('comic_sid') || genUUID();
572
+ localStorage.setItem('comic_sid', sid);
573
 
574
+ let currentSaveCode = null;
575
+ let isProcessing = false;
576
+ let interval, selectedBubble = null, selectedPanel = null;
577
+ let isDragging = false, isResizing = false, isPanning = false;
578
+ let startX, startY, initX, initY, panStartX, panStartY, panStartTx, panStartTy;
579
+ let resizeHandle, originalWidth, originalHeight, originalMouseX, originalMouseY;
580
+ let currentlyEditing = null;
581
+
582
+ if(localStorage.getItem('comic_draft_'+sid)) document.getElementById('restore-draft-btn').style.display = 'block';
583
+
584
+ function showSaveModal(code) { document.getElementById('modal-save-code').textContent = code; document.getElementById('save-modal').style.display = 'flex'; }
585
+ function closeModal() { document.getElementById('save-modal').style.display = 'none'; }
586
+ function copyModalCode() { navigator.clipboard.writeText(document.getElementById('modal-save-code').textContent).then(() => alert('Code copied!')); }
587
+ function copyCode() { if(currentSaveCode) navigator.clipboard.writeText(currentSaveCode).then(() => alert('Code copied!')); }
588
+
589
+ function setProcessing(busy) {
590
+ isProcessing = busy;
591
+ const btns = ['prev-btn', 'next-btn', 'go-btn'];
592
+ btns.forEach(id => {
593
+ const el = document.getElementById(id);
594
+ if(el) { el.disabled = busy; el.style.opacity = busy ? '0.5' : '1'; el.innerText = busy ? '⏳' : el.getAttribute('data-txt') || el.innerText; }
595
+ });
596
+ }
597
+ async function saveComic() {
598
+ const state = getCurrentState();
599
+ if(!state || state.length === 0) { alert('No comic to save!'); return; }
600
+ try {
601
+ const r = await fetch(`/save_comic?sid=${sid}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ pages: state, savedAt: new Date().toISOString() }) });
602
+ const d = await r.json();
603
+ 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(); }
604
+ else { alert('Failed to save: ' + d.message); }
605
+ } catch(e) { console.error(e); alert('Error saving comic'); }
606
+ }
607
+
608
+ async function loadSavedComic() {
609
+ const code = document.getElementById('load-code-input').value.trim().toUpperCase();
610
+ if(!code || code.length < 4) { alert('Invalid code'); return; }
611
+ try {
612
+ const r = await fetch(`/load_comic/${code}`);
613
+ const d = await r.json();
614
+ 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(); }
615
+ else { alert('Load failed: ' + d.message); }
616
+ } catch(e) { console.error(e); alert('Error loading comic.'); }
617
+ }
618
+
619
+ function restoreDraft() {
620
+ try {
621
+ const state = JSON.parse(localStorage.getItem('comic_draft_'+sid));
622
+ if(state.saveCode) { currentSaveCode = state.saveCode; document.getElementById('display-save-code').textContent = state.saveCode; document.getElementById('current-save-code').style.display = 'block'; }
623
+ renderFromState(state.pages || state);
624
+ document.getElementById('upload-container').style.display = 'none';
625
+ document.getElementById('editor-container').style.display = 'block';
626
+ } catch(e) { console.error(e); alert("Failed to restore."); }
627
+ }
628
+
629
+ function getCurrentState() {
630
+ const pages = [];
631
+ document.querySelectorAll('.comic-page').forEach(p => {
632
+ // Get Grid Class
633
+ const grid = p.querySelector('.comic-grid');
634
+ let template = 't-classic';
635
+ if(grid) {
636
+ if(grid.classList.contains('t-feat-top')) template = 't-feat-top';
637
+ else if(grid.classList.contains('t-side')) template = 't-side';
638
+ else if(grid.classList.contains('t-cinematic')) template = 't-cinematic';
639
+ }
640
+
641
+ const panels = [];
642
+ p.querySelectorAll('.panel').forEach(pan => {
643
+ const img = pan.querySelector('img');
644
+ const bubbles = [];
645
+ pan.querySelectorAll('.speech-bubble').forEach(b => {
646
+ const textEl = b.querySelector('.bubble-text');
647
+ bubbles.push({
648
+ text: textEl ? textEl.textContent : '',
649
+ left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
650
+ classes: b.className, type: b.dataset.type, font: b.style.fontFamily,
651
+ tailPos: b.style.getPropertyValue('--tail-pos'),
652
+ colors: { fill: b.style.getPropertyValue('--bubble-fill-color'), text: b.style.getPropertyValue('--bubble-text-color') }
 
 
653
  });
654
  });
655
+ panels.push({
656
+ src: img.src,
657
+ zoom: img.dataset.zoom, tx: img.dataset.translateX, ty: img.dataset.translateY,
658
+ bubbles: bubbles
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
659
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
660
  });
661
+ pages.push({ panels: panels, template: template });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
662
  });
663
+ return pages;
664
+ }
665
+
666
+ function saveDraft() { localStorage.setItem('comic_draft_'+sid, JSON.stringify({ pages: getCurrentState(), saveCode: currentSaveCode, savedAt: new Date().toISOString() })); }
667
+
668
+ // FUNCTION ADDED: Change Template
669
+ function changeTemplate(tpl) {
670
+ document.querySelectorAll('.comic-grid').forEach(g => {
671
+ // Remove existing template classes
672
+ g.classList.remove('t-classic', 't-feat-top', 't-side', 't-cinematic');
673
+ // Add new
674
+ g.classList.add(tpl);
675
+ });
676
+ saveDraft();
677
+ }
678
+
679
+ function renderFromState(pagesData) {
680
+ const con = document.getElementById('comic-container'); con.innerHTML = '';
681
+ pagesData.forEach((page, pageIdx) => {
682
+ const pageWrapper = document.createElement('div'); pageWrapper.className = 'page-wrapper';
683
+ const pageTitle = document.createElement('h2'); pageTitle.className = 'page-title'; pageTitle.textContent = `Page ${pageIdx + 1}`;
684
+ pageWrapper.appendChild(pageTitle);
685
+ const div = document.createElement('div'); div.className = 'comic-page';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
686
 
687
+ const grid = document.createElement('div');
688
+ // Apply saved template or default
689
+ const tpl = page.template || 't-classic';
690
+ grid.className = `comic-grid ${tpl}`;
691
 
692
+ // Set dropdown to current template (of first page)
693
+ if(pageIdx === 0) document.getElementById('template-select').value = tpl;
694
+
695
+ page.panels.forEach((pan) => {
696
+ const pDiv = document.createElement('div'); pDiv.className = 'panel';
697
+ pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
698
+ const img = document.createElement('img');
699
+ img.src = pan.src.includes('?') ? pan.src : pan.src + `?sid=${sid}`;
700
+ img.dataset.zoom = pan.zoom || 100; img.dataset.translateX = pan.tx || 0; img.dataset.translateY = pan.ty || 0;
701
+ updateImageTransform(img);
702
+ img.onmousedown = (e) => startPan(e, img);
703
+ pDiv.appendChild(img);
704
+ (pan.bubbles || []).forEach(bData => { pDiv.appendChild(createBubbleHTML(bData)); });
705
+ grid.appendChild(pDiv);
706
+ });
707
+ div.appendChild(grid); pageWrapper.appendChild(div); con.appendChild(pageWrapper);
708
+ });
709
+ }
710
+
711
+ async function upload() {
712
+ const f = document.getElementById('file-upload').files[0];
713
+ const pCount = document.getElementById('page-count').value;
714
+ if(!f) return alert("Select a video");
715
+ sid = genUUID(); localStorage.setItem('comic_sid', sid); currentSaveCode = null;
716
+ document.querySelector('.upload-box').style.display='none';
717
+ document.getElementById('loading-view').style.display='flex';
718
+ const fd = new FormData(); fd.append('file', f); fd.append('target_pages', pCount);
719
+ const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
720
+ if(r.ok) interval = setInterval(checkStatus, 2000);
721
+ else { alert("Upload failed"); location.reload(); }
722
+ }
723
+
724
+ async function checkStatus() {
725
+ try {
726
+ const r = await fetch(`/status?sid=${sid}`); const d = await r.json();
727
+ document.getElementById('status-text').innerText = d.message;
728
+ if(d.progress >= 100) { clearInterval(interval); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='block'; loadNewComic(); }
729
+ else if (d.progress < 0) { clearInterval(interval); document.getElementById('status-text').textContent = "Error: " + d.message; document.querySelector('.loader').style.display = 'none'; }
730
+ } catch(e) {}
731
+ }
732
+
733
+ function loadNewComic() {
734
+ fetch(`/output/pages.json?sid=${sid}`).then(r=>r.json()).then(data => {
735
+ const cleanData = data.map((p, pi) => ({
736
+ template: 't-classic',
737
+ panels: p.panels.map((pan, j) => ({
738
+ src: `/frames/${pan.image}?sid=${sid}`,
739
+ bubbles: (p.bubbles && p.bubbles[j] && p.bubbles[j].dialog) ? [{
740
+ text: p.bubbles[j].dialog,
741
+ left: (p.bubbles[j].bubble_offset_x || 50) + 'px',
742
+ top: (p.bubbles[j].bubble_offset_y || 20) + 'px',
743
+ type: (p.bubbles[j].type || 'speech'),
744
+ classes: `speech-bubble ${p.bubbles[j].type || 'speech'} tail-bottom`
745
+ }] : []
746
+ }))
747
+ }));
748
+ renderFromState(cleanData); saveDraft();
749
+ });
750
+ }
751
+
752
+ function createBubbleHTML(data) {
753
+ const b = document.createElement('div');
754
+ const type = data.type || 'speech';
755
+ b.className = data.classes || `speech-bubble ${type} tail-bottom`;
756
+ if (type === 'thought' && !b.className.includes('pos-')) b.className += ' pos-bl';
757
+
758
+ b.dataset.type = type;
759
+ b.style.left = data.left; b.style.top = data.top;
760
+ if(data.width) b.style.width = data.width; if(data.height) b.style.height = data.height;
761
+ if(data.font) b.style.fontFamily = data.font;
762
+ if(data.colors) { b.style.setProperty('--bubble-fill-color', data.colors.fill || '#4ECDC4'); b.style.setProperty('--bubble-text-color', data.colors.text || '#ffffff'); }
763
+ if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
764
+
765
+ const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || ''; b.appendChild(textSpan);
766
+
767
+ if(type === 'thought') { for(let i=1; i<=2; i++){ const d = document.createElement('div'); d.className = `thought-dot thought-dot-${i}`; b.appendChild(d); } }
768
+
769
+ ['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); });
770
+
771
+ b.onmousedown = (e) => {
772
+ if(e.target.classList.contains('resize-handle')) return;
773
+ e.stopPropagation(); selectBubble(b); isDragging = true; startX = e.clientX; startY = e.clientY; initX = b.offsetLeft; initY = b.offsetTop;
774
+ };
775
+ b.onclick = (e) => { e.stopPropagation(); };
776
+ b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
777
+ return b;
778
+ }
779
+
780
+ function editBubbleText(bubble) {
781
+ if (currentlyEditing) return; currentlyEditing = bubble;
782
+ const textSpan = bubble.querySelector('.bubble-text'); const textarea = document.createElement('textarea');
783
+ textarea.value = textSpan.textContent; bubble.appendChild(textarea); textSpan.style.display = 'none'; textarea.focus();
784
+ const finishEditing = () => { textSpan.textContent = textarea.value; textarea.remove(); textSpan.style.display = ''; currentlyEditing = null; saveDraft(); };
785
+ textarea.addEventListener('blur', finishEditing, { once: true });
786
+ textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); } });
787
+ }
788
+
789
+ document.addEventListener('mousemove', (e) => {
790
+ if(isDragging && selectedBubble) { selectedBubble.style.left = (initX + e.clientX - startX) + 'px'; selectedBubble.style.top = (initY + e.clientY - startY) + 'px'; }
791
+ if(isResizing && selectedBubble) { resizeBubble(e); }
792
+ if(isPanning && selectedPanel) { panImage(e); }
793
+ });
794
+ document.addEventListener('mouseup', () => { if(isDragging || isResizing || isPanning) saveDraft(); isDragging = false; isResizing = false; isPanning = false; });
795
+
796
+ 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; }
797
+ 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'; }
798
+
799
+ function selectBubble(el) {
800
+ if(selectedBubble) selectedBubble.classList.remove('selected');
801
+ if(selectedPanel) { selectedPanel.classList.remove('selected'); selectedPanel = null; }
802
+ selectedBubble = el; el.classList.add('selected');
803
+ document.getElementById('bubble-type-select').disabled = false;
804
+ document.getElementById('font-select').disabled = false;
805
+ document.getElementById('bubble-text-color').disabled = false;
806
+ document.getElementById('bubble-fill-color').disabled = false;
807
+ document.getElementById('tail-controls').style.display = 'block';
808
+ document.getElementById('bubble-type-select').value = el.dataset.type || 'speech';
809
+ }
810
+
811
+ function selectPanel(el) {
812
+ if(selectedPanel) selectedPanel.classList.remove('selected');
813
+ if(selectedBubble) { selectedBubble.classList.remove('selected'); selectedBubble = null; }
814
+ selectedPanel = el; el.classList.add('selected');
815
+ document.getElementById('zoom-slider').disabled = false;
816
+ const img = el.querySelector('img');
817
+ document.getElementById('zoom-slider').value = img.dataset.zoom || 100;
818
+ document.getElementById('bubble-type-select').disabled = true;
819
+ document.getElementById('font-select').disabled = true;
820
+ document.getElementById('tail-controls').style.display = 'none';
821
+ }
822
+
823
+ function addBubble() {
824
+ if(!selectedPanel) return alert("Select a panel first");
825
+ const b = createBubbleHTML({ text: "Text", left: "50px", top: "30px", type: 'speech', classes: "speech-bubble speech tail-bottom" });
826
+ selectedPanel.appendChild(b); selectBubble(b); saveDraft();
827
+ }
828
+
829
+ function deleteBubble() {
830
+ if(!selectedBubble) return alert("Select a bubble");
831
+ selectedBubble.remove(); selectedBubble=null; saveDraft();
832
+ }
833
+
834
+ function changeBubbleType(type) {
835
+ if(!selectedBubble) return;
836
+ selectedBubble.dataset.type = type;
837
+ selectedBubble.className = 'speech-bubble ' + type + ' selected';
838
 
839
+ if(type === 'thought') selectedBubble.classList.add('pos-bl');
840
+ else selectedBubble.classList.add('tail-bottom');
841
 
842
+ selectedBubble.querySelectorAll('.thought-dot').forEach(d=>d.remove());
843
+ if(type === 'thought') { for(let i=1; i<=2; i++){ const d = document.createElement('div'); d.className = `thought-dot thought-dot-${i}`; selectedBubble.appendChild(d); } }
844
+ saveDraft();
845
+ }
846
+
847
+ function changeFont(font) { if(!selectedBubble) return; selectedBubble.style.fontFamily = font; saveDraft(); }
848
+
849
+ function rotateTail() {
850
+ if(!selectedBubble) return;
851
+ const type = selectedBubble.dataset.type;
852
+
853
+ if(type === 'speech') {
854
+ const positions = ['tail-bottom', 'tail-right', 'tail-top', 'tail-left'];
855
+ let current = 0;
856
+ positions.forEach((pos, i) => { if(selectedBubble.classList.contains(pos)) current = i; });
857
+ selectedBubble.classList.remove(positions[current]);
858
+ selectedBubble.classList.add(positions[(current + 1) % 4]);
859
+ }
860
+ else if (type === 'thought') {
861
+ const positions = ['pos-bl', 'pos-br', 'pos-tr', 'pos-tl'];
862
+ let current = 0;
863
+ positions.forEach((pos, i) => { if(selectedBubble.classList.contains(pos)) current = i; });
864
+ selectedBubble.classList.remove(positions[current]);
865
+ selectedBubble.classList.add(positions[(current + 1) % 4]);
866
  }
867
+ saveDraft();
868
+ }
869
+
870
+ function slideTail(v) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', v+'%'); saveDraft(); } }
871
+
872
+ document.getElementById('bubble-text-color').addEventListener('input', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-text-color', e.target.value); saveDraft(); } });
873
+ document.getElementById('bubble-fill-color').addEventListener('input', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-fill-color', e.target.value); saveDraft(); } });
874
+
875
+ function handleZoom(el) { if(!selectedPanel) return; const img = selectedPanel.querySelector('img'); img.dataset.zoom = el.value; updateImageTransform(img); saveDraft(); }
876
+ 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'); }
877
+ 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); }
878
+ 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); }
879
+ function resetPanelTransform() { if(!selectedPanel) return alert("Select a panel"); const img = selectedPanel.querySelector('img'); img.dataset.zoom = 100; img.dataset.translateX = 0; img.dataset.translateY = 0; document.getElementById('zoom-slider').value = 100; updateImageTransform(img); saveDraft(); }
880
+
881
+ function replacePanelImage() { if(!selectedPanel) return alert("Select a panel"); const inp = document.getElementById('image-uploader'); inp.onchange = async (e) => { const fd = new FormData(); fd.append('image', e.target.files[0]); const img = selectedPanel.querySelector('img'); const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd}); const d = await r.json(); if(d.success) { img.src = `/frames/${d.new_filename}?sid=${sid}`; saveDraft(); } inp.value = ''; }; inp.click(); }
882
+ async function adjustFrame(dir) { if(isProcessing) return; if(!selectedPanel) return alert("Select a panel"); const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0]; setProcessing(true); img.style.opacity = '0.5'; try { const r = await fetch(`/regenerate_frame?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, direction:dir}) }); const d = await r.json(); if(d.success) { img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`; } else { alert('Error: ' + d.message); } } catch(e) { console.error(e); } img.style.opacity = '1'; setProcessing(false); saveDraft(); }
883
+ async function gotoTimestamp() { if(isProcessing) return; if(!selectedPanel) return alert("Select a panel"); let v = document.getElementById('timestamp-input').value.trim(); if(!v) return; if(v.includes(':')) { let p = v.split(':'); v = parseInt(p[0]) * 60 + parseFloat(p[1]); } else { v = parseFloat(v); } if(isNaN(v)) return alert("Invalid time"); const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0]; setProcessing(true); img.style.opacity = '0.5'; try { const r = await fetch(`/goto_timestamp?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, timestamp:v}) }); const d = await r.json(); if(d.success) { img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`; document.getElementById('timestamp-input').value = ''; } else { alert('Error: ' + d.message); } } catch(e) { console.error(e); } img.style.opacity = '1'; setProcessing(false); saveDraft(); }
884
+ async function exportComic() { const pgs = document.querySelectorAll('.comic-page'); if(pgs.length === 0) return alert("No pages found"); alert(`Exporting ${pgs.length} page(s)...`); 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'; }); for(let i = 0; i < pgs.length; i++) { 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(); } catch(err) { console.error(err); alert(`Failed to export page ${i+1}`); } } bubbles.forEach(b => { b.style.width = ''; b.style.height = ''; }); }
885
+
886
+ function goBackToUpload() { if(confirm('Go home? Unsaved changes will be lost.')) { document.getElementById('editor-container').style.display = 'none'; document.getElementById('upload-container').style.display = 'flex'; document.getElementById('loading-view').style.display = 'none'; } }
887
+ </script>
888
+ </body>
889
+ </html>
890
  '''
891
 
892
  @app.route('/')
 
902
 
903
  # GET PAGE COUNT FROM FORM
904
  target_pages = request.form.get('target_pages', 4)
905
+
906
  f = request.files['file']
907
  gen = EnhancedComicGenerator(sid)
908
  gen.cleanup()
 
1010
  return jsonify({'success': False, 'message': str(e)})
1011
 
1012
  if __name__ == '__main__':
1013
+ try: gpu_warmup()
1014
  except: pass
1015
  app.run(host='0.0.0.0', port=7860)