tester343 commited on
Commit
1ba33d5
·
verified ·
1 Parent(s): 35cfc09

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +617 -538
app_enhanced.py CHANGED
@@ -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:
@@ -103,16 +102,19 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
103
  with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
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 +130,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 +145,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)
@@ -208,17 +210,17 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
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)
@@ -242,7 +244,7 @@ def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
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,559 +299,636 @@ 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
-
388
- /* THOUGHT BUBBLE CSS (Fixed Rotation) */
389
- .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
390
- .speech-bubble.thought::before { display:none; }
391
- .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
392
- .thought-dot-1 { width: 20px; height: 20px; }
393
- .thought-dot-2 { width: 12px; height: 12px; }
394
-
395
- /* Thought Tail Positions */
396
- .speech-bubble.thought.pos-bl .thought-dot-1 { left: 20px; bottom: -20px; }
397
- .speech-bubble.thought.pos-bl .thought-dot-2 { left: 10px; bottom: -32px; }
398
-
399
- .speech-bubble.thought.pos-br .thought-dot-1 { right: 20px; bottom: -20px; }
400
- .speech-bubble.thought.pos-br .thought-dot-2 { right: 10px; bottom: -32px; }
401
-
402
- .speech-bubble.thought.pos-tr .thought-dot-1 { right: 20px; top: -20px; }
403
- .speech-bubble.thought.pos-tr .thought-dot-2 { right: 10px; top: -32px; }
404
-
405
- .speech-bubble.thought.pos-tl .thought-dot-1 { left: 20px; top: -20px; }
406
- .speech-bubble.thought.pos-tl .thought-dot-2 { left: 10px; top: -32px; }
407
-
408
- .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%); }
409
- .speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; }
410
-
411
- .resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; z-index: 11; }
412
- .speech-bubble.selected .resize-handle { display: block; }
413
- .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
414
- .resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
415
- .resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
416
- .resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
417
-
418
- /* CONTROLS */
419
- .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; }
420
- .edit-controls h4 { margin: 0 0 10px 0; color: #4ECDC4; text-align: center; }
421
- .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
422
- .control-group label { font-size: 11px; font-weight: bold; display: block; margin-bottom: 3px; }
423
- 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; }
424
- .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
425
- .color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
426
- .slider-container { display: flex; align-items: center; gap: 5px; margin-top: 5px; }
427
- .slider-container label { min-width: 40px; font-size: 11px; }
428
- .action-btn { background: #4CAF50; color: white; }
429
- .reset-btn { background: #e74c3c; color: white; }
430
- .secondary-btn { background: #f39c12; color: white; }
431
- .export-btn { background: #2196F3; color: white; }
432
- .save-btn { background: #9b59b6; color: white; }
433
-
434
- /* MODAL */
435
- .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; }
436
- .modal-content { background: white; padding: 30px; border-radius: 12px; max-width: 400px; width: 90%; text-align: center; }
437
- .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; }
438
- .modal-content button { background: #3498db; color: white; border: none; padding: 12px 30px; border-radius: 8px; cursor: pointer; font-weight: bold; margin: 5px; }
439
- </style>
440
- </head>
441
- <body>
442
- <div id="upload-container">
443
- <div class="upload-box">
444
- <h1>🎬 Enhanced Comic Generator</h1>
445
- <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
446
- <label for="file-upload" class="file-label">📁 Choose Video File</label>
447
- <span id="fn" style="margin-bottom:10px; display:block; color:#666;">No file selected</span>
448
-
449
- <div class="page-input-group">
450
- <label>📚 Total Comic Pages:</label>
451
- <input type="number" id="page-count" value="4" min="1" max="15" placeholder="e.g. 4 (Video will be divided evenly)">
452
- <small style="color:#666; font-size:11px; display:block; margin-top:5px;">System calculates ~4 panels per page.</small>
453
- </div>
454
-
455
- <button class="submit-btn" onclick="upload()">🚀 Generate Comic</button>
456
- <button id="restore-draft-btn" class="restore-btn" style="display:none; margin-top:15px;" onclick="restoreDraft()">📂 Restore Unsaved Draft</button>
457
-
458
- <div class="load-section">
459
- <h3>📥 Load Saved Comic</h3>
460
- <div class="load-input-group">
461
- <input type="text" id="load-code-input" placeholder="SAVE CODE" maxlength="8" style="text-transform:uppercase;">
462
- <button onclick="loadSavedComic()">Load</button>
463
- </div>
464
- </div>
465
- <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
466
- <div class="loader" style="margin:0 auto;"></div>
467
- <p id="status-text" style="margin-top:10px;">Starting...</p>
468
  </div>
469
  </div>
 
 
 
 
470
  </div>
471
-
472
- <div id="editor-container">
473
- <div class="comic-wrapper" id="comic-container"></div>
474
- <input type="file" id="image-uploader" style="display: none;" accept="image/*">
475
- <div class="edit-controls">
476
- <h4>✏️ Interactive Editor</h4>
477
-
478
- <div class="control-group">
479
- <label>💾 Save & Load:</label>
480
- <button onclick="saveComic()" class="save-btn">💾 Save Comic</button>
481
- <div id="current-save-code" style="display:none; margin-top:5px; text-align:center;">
482
- <span id="display-save-code" style="font-weight:bold; background:#eee; padding:2px 5px; border-radius:3px;"></span>
483
- <button onclick="copyCode()" style="padding:2px; width:auto; font-size:10px;">Copy</button>
484
- </div>
 
 
485
  </div>
486
-
487
- <div class="control-group">
488
- <label>💬 Bubble Styling:</label>
489
- <select id="bubble-type-select" onchange="changeBubbleType(this.value)" disabled>
490
- <option value="speech">Speech</option>
491
- <option value="thought">Thought</option>
492
- <option value="reaction">Reaction (Shout)</option>
493
- <option value="narration">Narration (Box)</option>
494
- </select>
495
- <select id="font-select" onchange="changeFont(this.value)" disabled>
496
- <option value="'Comic Neue', cursive">Comic Neue</option>
497
- <option value="'Bangers', cursive">Bangers</option>
498
- <option value="'Gloria Hallelujah', cursive">Gloria</option>
499
- <option value="'Lato', sans-serif">Lato</option>
500
- </select>
501
- <div class="color-grid">
502
- <div><label>Text</label><input type="color" id="bubble-text-color" value="#ffffff" disabled></div>
503
- <div><label>Fill</label><input type="color" id="bubble-fill-color" value="#4ECDC4" disabled></div>
504
- </div>
505
- <div class="button-grid">
506
- <button onclick="addBubble()" class="action-btn">Add</button>
507
- <button onclick="deleteBubble()" class="reset-btn">Delete</button>
508
- </div>
509
  </div>
510
-
511
- <div class="control-group" id="tail-controls" style="display:none;">
512
- <label>📐 Tail Adjustment:</label>
513
- <button onclick="rotateTail()" class="secondary-btn">🔄 Rotate Side</button>
514
- <div class="slider-container">
515
- <label>Pos:</label>
516
- <input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)">
517
- </div>
518
  </div>
519
-
520
- <div class="control-group">
521
- <label>🖼️ Panel Tools:</label>
522
- <button onclick="replacePanelImage()" class="action-btn">🖼️ Replace Image</button>
523
- <div class="button-grid">
524
- <button onclick="adjustFrame('backward')" class="secondary-btn" id="prev-btn">⬅️ Prev</button>
525
- <button onclick="adjustFrame('forward')" class="action-btn" id="next-btn">Next ➡️</button>
526
- </div>
527
- <div class="timestamp-controls">
528
- <input type="text" id="timestamp-input" placeholder="mm:ss or secs">
529
- <button onclick="gotoTimestamp()" class="action-btn" id="go-btn">Go</button>
530
- </div>
531
  </div>
532
-
533
- <div class="control-group">
534
- <label>🔍 Zoom & Pan:</label>
535
- <div class="button-grid">
536
- <button onclick="resetPanelTransform()" class="secondary-btn">Reset</button>
537
- <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled oninput="handleZoom(this)">
538
- </div>
 
539
  </div>
540
-
541
- <div class="control-group">
542
- <button onclick="exportComic()" class="export-btn">📥 Export as PNG</button>
543
- <button onclick="goBackToUpload()" class="reset-btn" style="margin-top:10px;">🏠 Home</button>
544
  </div>
545
  </div>
546
- </div>
547
-
548
- <div class="modal-overlay" id="save-modal">
549
- <div class="modal-content">
550
- <h2>✅ Comic Saved!</h2>
551
- <div class="code" id="modal-save-code">XXXXXXXX</div>
552
- <button onclick="copyModalCode()">📋 Copy Code</button>
553
- <button class="close-btn" onclick="closeModal()">Close</button>
 
 
 
 
554
  </div>
555
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
 
557
- <script>
558
- 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);}); }
559
- let sid = localStorage.getItem('comic_sid') || genUUID();
560
- localStorage.setItem('comic_sid', sid);
561
-
562
- let currentSaveCode = null;
563
- let isProcessing = false;
564
- let interval, selectedBubble = null, selectedPanel = null;
565
- let isDragging = false, isResizing = false, isPanning = false;
566
- let startX, startY, initX, initY, panStartX, panStartY, panStartTx, panStartTy;
567
- let resizeHandle, originalWidth, originalHeight, originalMouseX, originalMouseY;
568
- let currentlyEditing = null;
569
-
570
- if(localStorage.getItem('comic_draft_'+sid)) document.getElementById('restore-draft-btn').style.display = 'block';
571
-
572
- function showSaveModal(code) { document.getElementById('modal-save-code').textContent = code; document.getElementById('save-modal').style.display = 'flex'; }
573
- function closeModal() { document.getElementById('save-modal').style.display = 'none'; }
574
- function copyModalCode() { navigator.clipboard.writeText(document.getElementById('modal-save-code').textContent).then(() => alert('Code copied!')); }
575
- function copyCode() { if(currentSaveCode) navigator.clipboard.writeText(currentSaveCode).then(() => alert('Code copied!')); }
576
-
577
- function setProcessing(busy) {
578
- isProcessing = busy;
579
- const btns = ['prev-btn', 'next-btn', 'go-btn'];
580
- btns.forEach(id => {
581
- const el = document.getElementById(id);
582
- if(el) { el.disabled = busy; el.style.opacity = busy ? '0.5' : '1'; el.innerText = busy ? '⏳' : el.getAttribute('data-txt') || el.innerText; }
583
- });
584
- }
585
 
586
- async function saveComic() {
587
- const state = getCurrentState();
588
- if(!state || state.length === 0) { alert('No comic to save!'); return; }
589
- try {
590
- const r = await fetch(`/save_comic?sid=${sid}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ pages: state, savedAt: new Date().toISOString() }) });
591
- const d = await r.json();
592
- 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(); }
593
- else { alert('Failed to save: ' + d.message); }
594
- } catch(e) { console.error(e); alert('Error saving comic'); }
595
  }
 
 
 
596
 
597
- async function loadSavedComic() {
598
- const code = document.getElementById('load-code-input').value.trim().toUpperCase();
599
- if(!code || code.length < 4) { alert('Invalid code'); return; }
600
- try {
601
- const r = await fetch(`/load_comic/${code}`);
602
- const d = await r.json();
603
- 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(); }
604
- else { alert('Load failed: ' + d.message); }
605
- } catch(e) { console.error(e); alert('Error loading comic.'); }
606
- }
607
 
608
- function restoreDraft() {
609
- try {
610
- const state = JSON.parse(localStorage.getItem('comic_draft_'+sid));
611
- if(state.saveCode) { currentSaveCode = state.saveCode; document.getElementById('display-save-code').textContent = state.saveCode; document.getElementById('current-save-code').style.display = 'block'; }
612
- renderFromState(state.pages || state);
613
- document.getElementById('upload-container').style.display = 'none';
614
- document.getElementById('editor-container').style.display = 'block';
615
- } catch(e) { console.error(e); alert("Failed to restore."); }
616
  }
617
-
618
- function getCurrentState() {
619
- const pages = [];
620
- document.querySelectorAll('.comic-page').forEach(p => {
621
- const panels = [];
622
- p.querySelectorAll('.panel').forEach(pan => {
623
- const img = pan.querySelector('img');
624
- const bubbles = [];
625
- pan.querySelectorAll('.speech-bubble').forEach(b => {
626
- const textEl = b.querySelector('.bubble-text');
627
- bubbles.push({
628
- text: textEl ? textEl.textContent : '',
629
- left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
630
- classes: b.className, type: b.dataset.type, font: b.style.fontFamily,
631
- tailPos: b.style.getPropertyValue('--tail-pos'),
632
- colors: { fill: b.style.getPropertyValue('--bubble-fill-color'), text: b.style.getPropertyValue('--bubble-text-color') }
633
- });
634
- });
635
- panels.push({
636
- src: img.src,
637
- zoom: img.dataset.zoom, tx: img.dataset.translateX, ty: img.dataset.translateY,
638
- bubbles: bubbles
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
639
  });
640
  });
641
- pages.push({ panels: panels });
642
- });
643
- return pages;
644
- }
645
-
646
- function saveDraft() { localStorage.setItem('comic_draft_'+sid, JSON.stringify({ pages: getCurrentState(), saveCode: currentSaveCode, savedAt: new Date().toISOString() })); }
647
-
648
- function renderFromState(pagesData) {
649
- const con = document.getElementById('comic-container'); con.innerHTML = '';
650
- pagesData.forEach((page, pageIdx) => {
651
- const pageWrapper = document.createElement('div'); pageWrapper.className = 'page-wrapper';
652
- const pageTitle = document.createElement('h2'); pageTitle.className = 'page-title'; pageTitle.textContent = `Page ${pageIdx + 1}`;
653
- pageWrapper.appendChild(pageTitle);
654
- const div = document.createElement('div'); div.className = 'comic-page';
655
- const grid = document.createElement('div'); grid.className = 'comic-grid';
656
- page.panels.forEach((pan) => {
657
- const pDiv = document.createElement('div'); pDiv.className = 'panel';
658
- pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
659
- const img = document.createElement('img');
660
- img.src = pan.src.includes('?') ? pan.src : pan.src + `?sid=${sid}`;
661
- img.dataset.zoom = pan.zoom || 100; img.dataset.translateX = pan.tx || 0; img.dataset.translateY = pan.ty || 0;
662
- updateImageTransform(img);
663
- img.onmousedown = (e) => startPan(e, img);
664
- pDiv.appendChild(img);
665
- (pan.bubbles || []).forEach(bData => { pDiv.appendChild(createBubbleHTML(bData)); });
666
- grid.appendChild(pDiv);
667
  });
668
- div.appendChild(grid); pageWrapper.appendChild(div); con.appendChild(pageWrapper);
669
  });
670
- }
671
-
672
- async function upload() {
673
- const f = document.getElementById('file-upload').files[0];
674
- const pCount = document.getElementById('page-count').value;
675
- if(!f) return alert("Select a video");
676
- sid = genUUID(); localStorage.setItem('comic_sid', sid); currentSaveCode = null;
677
- document.querySelector('.upload-box').style.display='none';
678
- document.getElementById('loading-view').style.display='flex';
679
- const fd = new FormData(); fd.append('file', f); fd.append('target_pages', pCount);
680
- const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
681
- if(r.ok) interval = setInterval(checkStatus, 2000);
682
- else { alert("Upload failed"); location.reload(); }
683
- }
684
-
685
- async function checkStatus() {
686
- try {
687
- const r = await fetch(`/status?sid=${sid}`); const d = await r.json();
688
- document.getElementById('status-text').innerText = d.message;
689
- if(d.progress >= 100) { clearInterval(interval); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='block'; loadNewComic(); }
690
- else if (d.progress < 0) { clearInterval(interval); document.getElementById('status-text').textContent = "Error: " + d.message; document.querySelector('.loader').style.display = 'none'; }
691
- } catch(e) {}
692
- }
693
-
694
- function loadNewComic() {
695
- fetch(`/output/pages.json?sid=${sid}`).then(r=>r.json()).then(data => {
696
- const cleanData = data.map((p, pi) => ({
697
- panels: p.panels.map((pan, j) => ({
698
- src: `/frames/${pan.image}?sid=${sid}`,
699
- bubbles: (p.bubbles && p.bubbles[j] && p.bubbles[j].dialog) ? [{
700
- text: p.bubbles[j].dialog,
701
- left: (p.bubbles[j].bubble_offset_x || 50) + 'px',
702
- top: (p.bubbles[j].bubble_offset_y || 20) + 'px',
703
- type: (p.bubbles[j].type || 'speech'),
704
- classes: `speech-bubble ${p.bubbles[j].type || 'speech'} tail-bottom`
705
- }] : []
706
- }))
707
- }));
708
- renderFromState(cleanData); saveDraft();
709
  });
710
- }
711
-
712
- function createBubbleHTML(data) {
713
- const b = document.createElement('div');
714
- const type = data.type || 'speech';
715
- b.className = data.classes || `speech-bubble ${type} tail-bottom`;
716
- if (type === 'thought' && !b.className.includes('pos-')) b.className += ' pos-bl'; // Default position for thought
717
-
718
- b.dataset.type = type;
719
- b.style.left = data.left; b.style.top = data.top;
720
- if(data.width) b.style.width = data.width; if(data.height) b.style.height = data.height;
721
- if(data.font) b.style.fontFamily = data.font;
722
- if(data.colors) { b.style.setProperty('--bubble-fill-color', data.colors.fill || '#4ECDC4'); b.style.setProperty('--bubble-text-color', data.colors.text || '#ffffff'); }
723
- if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
724
-
725
- const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || ''; b.appendChild(textSpan);
726
-
727
- 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); } }
728
-
729
- ['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); });
730
-
731
- b.onmousedown = (e) => {
732
- if(e.target.classList.contains('resize-handle')) return;
733
- e.stopPropagation(); selectBubble(b); isDragging = true; startX = e.clientX; startY = e.clientY; initX = b.offsetLeft; initY = b.offsetTop;
734
- };
735
- b.onclick = (e) => { e.stopPropagation(); };
736
- b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
737
- return b;
738
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
739
 
740
- function editBubbleText(bubble) {
741
- if (currentlyEditing) return; currentlyEditing = bubble;
742
- const textSpan = bubble.querySelector('.bubble-text'); const textarea = document.createElement('textarea');
743
- textarea.value = textSpan.textContent; bubble.appendChild(textarea); textSpan.style.display = 'none'; textarea.focus();
744
- const finishEditing = () => { textSpan.textContent = textarea.value; textarea.remove(); textSpan.style.display = ''; currentlyEditing = null; saveDraft(); };
745
- textarea.addEventListener('blur', finishEditing, { once: true });
746
- textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); } });
747
- }
748
 
749
- document.addEventListener('mousemove', (e) => {
750
- if(isDragging && selectedBubble) { selectedBubble.style.left = (initX + e.clientX - startX) + 'px'; selectedBubble.style.top = (initY + e.clientY - startY) + 'px'; }
751
- if(isResizing && selectedBubble) { resizeBubble(e); }
752
- if(isPanning && selectedPanel) { panImage(e); }
753
- });
754
- document.addEventListener('mouseup', () => { if(isDragging || isResizing || isPanning) saveDraft(); isDragging = false; isResizing = false; isPanning = false; });
755
-
756
- 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; }
757
- 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'; }
758
-
759
- function selectBubble(el) {
760
- if(selectedBubble) selectedBubble.classList.remove('selected');
761
- if(selectedPanel) { selectedPanel.classList.remove('selected'); selectedPanel = null; }
762
- selectedBubble = el; el.classList.add('selected');
763
- document.getElementById('bubble-type-select').disabled = false;
764
- document.getElementById('font-select').disabled = false;
765
- document.getElementById('bubble-text-color').disabled = false;
766
- document.getElementById('bubble-fill-color').disabled = false;
767
- document.getElementById('tail-controls').style.display = 'block';
768
- document.getElementById('bubble-type-select').value = el.dataset.type || 'speech';
769
- }
770
 
771
- function selectPanel(el) {
772
- if(selectedPanel) selectedPanel.classList.remove('selected');
773
- if(selectedBubble) { selectedBubble.classList.remove('selected'); selectedBubble = null; }
774
- selectedPanel = el; el.classList.add('selected');
775
- document.getElementById('zoom-slider').disabled = false;
776
- const img = el.querySelector('img');
777
- document.getElementById('zoom-slider').value = img.dataset.zoom || 100;
778
- document.getElementById('bubble-type-select').disabled = true;
779
- document.getElementById('font-select').disabled = true;
780
- document.getElementById('tail-controls').style.display = 'none';
781
- }
782
 
783
- function addBubble() {
784
- if(!selectedPanel) return alert("Select a panel first");
785
- const b = createBubbleHTML({ text: "Text", left: "50px", top: "30px", type: 'speech', classes: "speech-bubble speech tail-bottom" });
786
- selectedPanel.appendChild(b); selectBubble(b); saveDraft();
787
- }
788
 
789
- function deleteBubble() {
790
- if(!selectedBubble) return alert("Select a bubble");
791
- selectedBubble.remove(); selectedBubble=null; saveDraft();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
792
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
793
 
794
- function changeBubbleType(type) {
795
- if(!selectedBubble) return;
796
- selectedBubble.dataset.type = type;
797
- selectedBubble.className = 'speech-bubble ' + type + ' selected';
798
-
799
- // Default tail for thought is pos-bl if not set
800
- if(type === 'thought') selectedBubble.classList.add('pos-bl');
801
- else selectedBubble.classList.add('tail-bottom'); // Default for speech
802
-
803
- selectedBubble.querySelectorAll('.thought-dot').forEach(d=>d.remove());
804
- 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); } }
805
- saveDraft();
806
- }
807
 
808
- function changeFont(font) { if(!selectedBubble) return; selectedBubble.style.fontFamily = font; saveDraft(); }
 
 
 
 
 
 
 
 
 
809
 
810
- function rotateTail() {
811
- if(!selectedBubble) return;
812
- const type = selectedBubble.dataset.type;
813
-
814
- if(type === 'speech') {
815
- const positions = ['tail-bottom', 'tail-right', 'tail-top', 'tail-left'];
816
- let current = 0;
817
- positions.forEach((pos, i) => { if(selectedBubble.classList.contains(pos)) current = i; });
818
- selectedBubble.classList.remove(positions[current]);
819
- selectedBubble.classList.add(positions[(current + 1) % 4]);
820
- }
821
- else if (type === 'thought') {
822
- // Cycle specifically through the 4 CSS positions we defined
823
- const positions = ['pos-bl', 'pos-br', 'pos-tr', 'pos-tl'];
824
- let current = 0;
825
- positions.forEach((pos, i) => { if(selectedBubble.classList.contains(pos)) current = i; });
826
- selectedBubble.classList.remove(positions[current]);
827
- selectedBubble.classList.add(positions[(current + 1) % 4]);
828
- }
829
- saveDraft();
830
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
831
 
832
- function slideTail(v) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', v+'%'); saveDraft(); } }
833
-
834
- document.getElementById('bubble-text-color').addEventListener('input', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-text-color', e.target.value); saveDraft(); } });
835
- document.getElementById('bubble-fill-color').addEventListener('input', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-fill-color', e.target.value); saveDraft(); } });
 
836
 
837
- function handleZoom(el) { if(!selectedPanel) return; const img = selectedPanel.querySelector('img'); img.dataset.zoom = el.value; updateImageTransform(img); saveDraft(); }
838
- 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'); }
839
- 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); }
840
- 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); }
841
- 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(); }
 
 
 
842
 
843
- 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(); }
844
- 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(); }
845
- 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(); }
846
- 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 = ''; }); }
 
 
 
 
 
 
 
 
847
 
848
- 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'; } }
849
- </script>
850
- </body>
851
- </html>
852
- '''
 
 
 
 
 
 
 
853
 
854
  @app.route('/')
855
  def index():
@@ -864,7 +943,7 @@ def upload():
864
 
865
  # GET PAGE COUNT FROM FORM
866
  target_pages = request.form.get('target_pages', 4)
867
-
868
  f = request.files['file']
869
  gen = EnhancedComicGenerator(sid)
870
  gen.cleanup()
@@ -972,6 +1051,6 @@ def load_comic(code):
972
  return jsonify({'success': False, 'message': str(e)})
973
 
974
  if __name__ == '__main__':
975
- try: gpu_warmup()
976
  except: pass
977
  app.run(host='0.0.0.0', port=7860)
 
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:
 
102
  with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
103
 
104
  with open(user_srt, 'r', encoding='utf-8') as f:
105
+ try:
106
+ all_subs = list(srt.parse(f.read()))
107
+ except:
108
+ all_subs = []
109
+
110
  # 3. Smart Keyframe Selection
111
  valid_subs = [s for s in all_subs if s.content.strip()]
112
  raw_moments = [{'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
113
+
114
  if target_pages <= 0: target_pages = 1
115
  panels_per_page = 4
116
  total_panels_needed = target_pages * panels_per_page
117
+
118
  selected_moments = []
119
  if not raw_moments:
120
  times = np.linspace(1, duration-1, total_panels_needed)
 
130
  cap = cv2.VideoCapture(video_path)
131
  count = 0
132
  frame_files_ordered = []
133
+
134
  for i, moment in enumerate(selected_moments):
135
  mid = (moment['start'] + moment['end']) / 2
136
  if mid > duration: mid = duration - 1
 
145
  frame_files_ordered.append(fname)
146
  count += 1
147
  cap.release()
148
+
149
  with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
150
 
151
  # 5. Image Enhancement
152
  try: black_bar_crop()
153
  except: pass
154
+
155
  se = SimpleColorEnhancer()
156
  qe = QualityColorEnhancer()
157
+
158
  for f in frame_files_ordered:
159
  p = os.path.join(frames_dir, f)
160
  try: se.enhance_single(p, p)
 
210
  if not os.path.exists(metadata_path): return {"success": False, "message": "No metadata"}
211
  with open(metadata_path, 'r') as f: meta = json.load(f)
212
  if fname not in meta: return {"success": False, "message": "Frame not found"}
213
+
214
  t = meta[fname]['time'] if isinstance(meta[fname], dict) else meta[fname]
215
  cap = cv2.VideoCapture(video_path)
216
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
217
  offset = (1.0/fps) * (1 if direction == 'forward' else -1)
218
  new_t = max(0, t + offset)
219
+
220
  cap.set(cv2.CAP_PROP_POS_MSEC, new_t * 1000)
221
  ret, frame = cap.read()
222
  cap.release()
223
+
224
  if ret:
225
  p = os.path.join(frames_dir, fname)
226
  cv2.imwrite(p, frame)
 
244
  cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
245
  ret, frame = cap.read()
246
  cap.release()
247
+
248
  if ret:
249
  p = os.path.join(frames_dir, fname)
250
  cv2.imwrite(p, frame)
 
299
  # ======================================================
300
  # 🌐 ROUTES & FULL UI
301
  # ======================================================
 
302
  INDEX_HTML = '''
303
+ <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>🎬 Enhanced Comic Generator</title> <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script> <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Gloria+Hallelujah&family=Lato&display=swap" rel="stylesheet"> <style> * { box-sizing: border-box; } body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
304
+
305
+ #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
306
+ .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; }
307
+
308
+ #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 100px; }
309
+
310
+ h1 { color: #2c3e50; margin-bottom: 20px; font-weight: 600; }
311
+ .file-input { display: none; }
312
+ .file-label { display: block; padding: 15px; background: #2c3e50; color: white; border-radius: 8px; cursor: pointer; font-weight: bold; margin-bottom: 10px; transition:0.2s; }
313
+ .file-label:hover { background: #34495e; }
314
+
315
+ .page-input-group { margin: 20px 0; text-align: left; }
316
+ .page-input-group label { font-weight: bold; font-size: 14px; display: block; margin-bottom: 5px; color: #333; }
317
+ .page-input-group input { width: 100%; padding: 12px; border: 2px solid #ddd; border-radius: 8px; font-size: 16px; box-sizing: border-box; }
318
+
319
+ .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; }
320
+ .submit-btn:hover { background: #d35400; }
321
+ .restore-btn { margin-top: 10px; background: #27ae60; color: white; padding: 12px; width: 100%; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
322
+
323
+ .load-section { margin-top: 30px; padding-top: 20px; border-top: 2px solid #eee; }
324
+ .load-input-group { display: flex; gap: 10px; margin-top: 10px; }
325
+ .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; }
326
+ .load-input-group button { padding: 12px 20px; background: #3498db; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
327
+
328
+ .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; }
329
+ @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
330
+
331
+ /* COMIC LAYOUT */
332
+ .comic-wrapper { max-width: 1000px; margin: 0 auto; }
333
+ .page-wrapper { margin: 30px auto; display: flex; flex-direction: column; align-items: center; }
334
+ .page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
335
+ .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; }
336
+ .comic-grid { display: grid; grid-template-columns: 285px 285px; grid-template-rows: 185px 185px; gap: 10px; width: 100%; height: 100%; }
337
+ .panel { position: relative; overflow: hidden; border: 2px solid #000; background: #eee; cursor: pointer; }
338
+ .panel.selected { outline: 3px solid #2196F3; outline-offset: -3px; }
339
+ .panel img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.1s ease-out; transform-origin: center center; }
340
+ .panel img.pannable { cursor: grab; }
341
+ .panel img.panning { cursor: grabbing; }
342
+
343
+ /* SPEECH BUBBLES */
344
+ .speech-bubble {
345
+ position: absolute; display: flex; justify-content: center; align-items: center;
346
+ width: 150px; height: 80px; min-width: 50px; min-height: 30px; box-sizing: border-box;
347
+ z-index: 10; cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold;
348
+ font-size: 13px; text-align: center; overflow: visible;
349
+ --tail-pos: 50%;
350
+ }
351
+ /* FIX FOR MISMATCH: Ensure pre-wrap so spaces and newlines render identically */
352
+ .bubble-text { padding: 0.8em; word-wrap: break-word; white-space: pre-wrap; position: relative; z-index: 5; pointer-events: none; user-select: none; width: 100%; }
353
+
354
+ .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
355
+ .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; white-space: pre-wrap; }
356
+
357
+ /* SPEECH BUBBLE CSS (Tails) */
358
+ .speech-bubble.speech {
359
+ --b: 3em; --h: 1.8em; --t: 0.6; --p: var(--tail-pos, 50%); --r: 1.2em;
360
+ background: var(--bubble-fill-color, #4ECDC4);
361
+ color: var(--bubble-text-color, #fff);
362
+ padding: 1em;
363
+ 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);
364
+ }
365
+ .speech-bubble.speech:before {
366
+ content: ""; position: absolute; width: var(--b); height: var(--h);
367
+ background: inherit; border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
368
+ -webkit-mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
369
+ mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
370
+ }
371
+
372
+ .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))); }
373
+ .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); }
374
+ .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); }
375
+ .speech-bubble.speech.tail-left { border-radius: var(--r); }
376
+ .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; }
377
+ .speech-bubble.speech.tail-right { border-radius: var(--r); }
378
+ .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; }
379
+ /* THOUGHT BUBBLE CSS (Fixed Rotation) */
380
+ .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
381
+ .speech-bubble.thought::before { display:none; }
382
+ .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
383
+ .thought-dot-1 { width: 20px; height: 20px; }
384
+ .thought-dot-2 { width: 12px; height: 12px; }
385
+
386
+ /* Thought Tail Positions */
387
+ .speech-bubble.thought.pos-bl .thought-dot-1 { left: 20px; bottom: -20px; }
388
+ .speech-bubble.thought.pos-bl .thought-dot-2 { left: 10px; bottom: -32px; }
389
+
390
+ .speech-bubble.thought.pos-br .thought-dot-1 { right: 20px; bottom: -20px; }
391
+ .speech-bubble.thought.pos-br .thought-dot-2 { right: 10px; bottom: -32px; }
392
+
393
+ .speech-bubble.thought.pos-tr .thought-dot-1 { right: 20px; top: -20px; }
394
+ .speech-bubble.thought.pos-tr .thought-dot-2 { right: 10px; top: -32px; }
395
+
396
+ .speech-bubble.thought.pos-tl .thought-dot-1 { left: 20px; top: -20px; }
397
+ .speech-bubble.thought.pos-tl .thought-dot-2 { left: 10px; top: -32px; }
398
+
399
+ .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%); }
400
+ .speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; }
401
+
402
+ .resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; z-index: 11; }
403
+ .speech-bubble.selected .resize-handle { display: block; }
404
+ .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
405
+ .resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
406
+ .resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
407
+ .resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
408
+
409
+ /* CONTROLS */
410
+ .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; }
411
+ .edit-controls h4 { margin: 0 0 10px 0; color: #4ECDC4; text-align: center; }
412
+ .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
413
+ .control-group label { font-size: 11px; font-weight: bold; display: block; margin-bottom: 3px; }
414
+ 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; }
415
+ .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
416
+ .color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
417
+ .slider-container { display: flex; align-items: center; gap: 5px; margin-top: 5px; }
418
+ .slider-container label { min-width: 40px; font-size: 11px; }
419
+ .action-btn { background: #4CAF50; color: white; }
420
+ .reset-btn { background: #e74c3c; color: white; }
421
+ .secondary-btn { background: #f39c12; color: white; }
422
+ .export-btn { background: #2196F3; color: white; }
423
+ .save-btn { background: #9b59b6; color: white; }
424
+ .undo-btn { background: #7f8c8d; color: white; margin-bottom: 5px; }
425
+
426
+ /* MODAL */
427
+ .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; }
428
+ .modal-content { background: white; padding: 30px; border-radius: 12px; max-width: 400px; width: 90%; text-align: center; }
429
+ .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; }
430
+ .modal-content button { background: #3498db; color: white; border: none; padding: 12px 30px; border-radius: 8px; cursor: pointer; font-weight: bold; margin: 5px; }
431
+ </style>
432
+ </head> <body> <div id="upload-container"> <div class="upload-box"> <h1>🎬 Enhanced Comic Generator</h1> <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name"> <label for="file-upload" class="file-label">📁 Choose Video File</label> <span id="fn" style="margin-bottom:10px; display:block; color:#666;">No file selected</span>
433
+
434
+ <div class="page-input-group">
435
+ <label>📚 Total Comic Pages:</label>
436
+ <input type="number" id="page-count" value="4" min="1" max="15" placeholder="e.g. 4 (Video will be divided evenly)">
437
+ <small style="color:#666; font-size:11px; display:block; margin-top:5px;">System calculates ~4 panels per page.</small>
438
+ </div>
439
 
440
+ <button class="submit-btn" onclick="upload()">🚀 Generate Comic</button>
441
+ <button id="restore-draft-btn" class="restore-btn" style="display:none; margin-top:15px;" onclick="restoreDraft()">📂 Restore Unsaved Draft</button>
442
+
443
+ <div class="load-section">
444
+ <h3>📥 Load Saved Comic</h3>
445
+ <div class="load-input-group">
446
+ <input type="text" id="load-code-input" placeholder="SAVE CODE" maxlength="8" style="text-transform:uppercase;">
447
+ <button onclick="loadSavedComic()">Load</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  </div>
449
  </div>
450
+ <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
451
+ <div class="loader" style="margin:0 auto;"></div>
452
+ <p id="status-text" style="margin-top:10px;">Starting...</p>
453
+ </div>
454
  </div>
455
+ </div>
456
+
457
+ <div id="editor-container">
458
+ <div class="comic-wrapper" id="comic-container"></div>
459
+ <input type="file" id="image-uploader" style="display: none;" accept="image/*">
460
+ <div class="edit-controls">
461
+ <h4>✏️ Interactive Editor</h4>
462
+
463
+ <button onclick="undoLastAction()" class="undo-btn">↩️ Undo</button>
464
+
465
+ <div class="control-group">
466
+ <label>💾 Save & Load:</label>
467
+ <button onclick="saveComic()" class="save-btn">💾 Save Comic</button>
468
+ <div id="current-save-code" style="display:none; margin-top:5px; text-align:center;">
469
+ <span id="display-save-code" style="font-weight:bold; background:#eee; padding:2px 5px; border-radius:3px;"></span>
470
+ <button onclick="copyCode()" style="padding:2px; width:auto; font-size:10px;">Copy</button>
471
  </div>
472
+ </div>
473
+
474
+ <div class="control-group">
475
+ <label>💬 Bubble Styling:</label>
476
+ <select id="bubble-type-select" onchange="changeBubbleType(this.value)" disabled>
477
+ <option value="speech">Speech</option>
478
+ <option value="thought">Thought</option>
479
+ <option value="reaction">Reaction (Shout)</option>
480
+ <option value="narration">Narration (Box)</option>
481
+ </select>
482
+ <select id="font-select" onchange="changeFont(this.value)" disabled>
483
+ <option value="'Comic Neue', cursive">Comic Neue</option>
484
+ <option value="'Bangers', cursive">Bangers</option>
485
+ <option value="'Gloria Hallelujah', cursive">Gloria</option>
486
+ <option value="'Lato', sans-serif">Lato</option>
487
+ </select>
488
+ <div class="color-grid">
489
+ <div><label>Text</label><input type="color" id="bubble-text-color" value="#ffffff" disabled></div>
490
+ <div><label>Fill</label><input type="color" id="bubble-fill-color" value="#4ECDC4" disabled></div>
 
 
 
 
491
  </div>
492
+ <div class="button-grid">
493
+ <button onclick="addBubble()" class="action-btn">Add</button>
494
+ <button onclick="deleteBubble()" class="reset-btn">Delete</button>
 
 
 
 
 
495
  </div>
496
+ </div>
497
+
498
+ <div class="control-group" id="tail-controls" style="display:none;">
499
+ <label>📐 Tail Adjustment:</label>
500
+ <button onclick="rotateTail()" class="secondary-btn">🔄 Rotate Side</button>
501
+ <div class="slider-container">
502
+ <label>Pos:</label>
503
+ <input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)">
 
 
 
 
504
  </div>
505
+ </div>
506
+
507
+ <div class="control-group">
508
+ <label>🖼️ Panel Tools:</label>
509
+ <button onclick="replacePanelImage()" class="action-btn">🖼️ Replace Image</button>
510
+ <div class="button-grid">
511
+ <button onclick="adjustFrame('backward')" class="secondary-btn" id="prev-btn">⬅️ Prev</button>
512
+ <button onclick="adjustFrame('forward')" class="action-btn" id="next-btn">Next ➡️</button>
513
  </div>
514
+ <div class="timestamp-controls">
515
+ <input type="text" id="timestamp-input" placeholder="mm:ss or secs">
516
+ <button onclick="gotoTimestamp()" class="action-btn" id="go-btn">Go</button>
 
517
  </div>
518
  </div>
519
+
520
+ <div class="control-group">
521
+ <label>🔍 Zoom & Pan:</label>
522
+ <div class="button-grid">
523
+ <button onclick="resetPanelTransform()" class="secondary-btn">Reset</button>
524
+ <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled oninput="handleZoom(this)">
525
+ </div>
526
+ </div>
527
+
528
+ <div class="control-group">
529
+ <button onclick="exportComic()" class="export-btn">📥 Export as PNG</button>
530
+ <button onclick="goBackToUpload()" class="reset-btn" style="margin-top:10px;">🏠 Home</button>
531
  </div>
532
  </div>
533
+ </div>
534
+
535
+ <div class="modal-overlay" id="save-modal">
536
+ <div class="modal-content">
537
+ <h2>✅ Comic Saved!</h2>
538
+ <div class="code" id="modal-save-code">XXXXXXXX</div>
539
+ <button onclick="copyModalCode()">📋 Copy Code</button>
540
+ <button class="close-btn" onclick="closeModal()">Close</button>
541
+ </div>
542
+ </div>
543
+
544
+ <script>
545
+ 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);}); }
546
+ let sid = localStorage.getItem('comic_sid') || genUUID();
547
+ localStorage.setItem('comic_sid', sid);
548
 
549
+ let currentSaveCode = null;
550
+ let isProcessing = false;
551
+ let interval, selectedBubble = null, selectedPanel = null;
552
+ let isDragging = false, isResizing = false, isPanning = false;
553
+ let startX, startY, initX, initY, panStartX, panStartY, panStartTx, panStartTy;
554
+ let resizeHandle, originalWidth, originalHeight, originalMouseX, originalMouseY;
555
+ let currentlyEditing = null;
556
+
557
+ // UNDO SYSTEM
558
+ let historyStack = [];
559
+ let historyIndex = -1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
 
561
+ function addToHistory() {
562
+ // If we are in the middle of history and add new action, cut off the future
563
+ if (historyIndex < historyStack.length - 1) {
564
+ historyStack = historyStack.slice(0, historyIndex + 1);
 
 
 
 
 
565
  }
566
+ const state = JSON.stringify(getCurrentState());
567
+ // Avoid duplicate states
568
+ if (historyStack.length > 0 && historyStack[historyStack.length - 1] === state) return;
569
 
570
+ historyStack.push(state);
571
+ historyIndex++;
 
 
 
 
 
 
 
 
572
 
573
+ // Limit stack size
574
+ if (historyStack.length > 30) {
575
+ historyStack.shift();
576
+ historyIndex--;
 
 
 
 
577
  }
578
+ }
579
+
580
+ function undoLastAction() {
581
+ if (historyIndex > 0) {
582
+ historyIndex--;
583
+ const previousState = JSON.parse(historyStack[historyIndex]);
584
+ renderFromState(previousState);
585
+ saveDraft(false); // Save to local storage without adding to history stack
586
+ }
587
+ }
588
+
589
+ if(localStorage.getItem('comic_draft_'+sid)) document.getElementById('restore-draft-btn').style.display = 'block';
590
+
591
+ function showSaveModal(code) { document.getElementById('modal-save-code').textContent = code; document.getElementById('save-modal').style.display = 'flex'; }
592
+ function closeModal() { document.getElementById('save-modal').style.display = 'none'; }
593
+ function copyModalCode() { navigator.clipboard.writeText(document.getElementById('modal-save-code').textContent).then(() => alert('Code copied!')); }
594
+ function copyCode() { if(currentSaveCode) navigator.clipboard.writeText(currentSaveCode).then(() => alert('Code copied!')); }
595
+
596
+ function setProcessing(busy) {
597
+ isProcessing = busy;
598
+ const btns = ['prev-btn', 'next-btn', 'go-btn'];
599
+ btns.forEach(id => {
600
+ const el = document.getElementById(id);
601
+ if(el) { el.disabled = busy; el.style.opacity = busy ? '0.5' : '1'; el.innerText = busy ? '⏳' : el.getAttribute('data-txt') || el.innerText; }
602
+ });
603
+ }
604
+ async function saveComic() {
605
+ const state = getCurrentState();
606
+ if(!state || state.length === 0) { alert('No comic to save!'); return; }
607
+ try {
608
+ const r = await fetch(`/save_comic?sid=${sid}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ pages: state, savedAt: new Date().toISOString() }) });
609
+ const d = await r.json();
610
+ 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(false); }
611
+ else { alert('Failed to save: ' + d.message); }
612
+ } catch(e) { console.error(e); alert('Error saving comic'); }
613
+ }
614
+
615
+ async function loadSavedComic() {
616
+ const code = document.getElementById('load-code-input').value.trim().toUpperCase();
617
+ if(!code || code.length < 4) { alert('Invalid code'); return; }
618
+ try {
619
+ const r = await fetch(`/load_comic/${code}`);
620
+ const d = await r.json();
621
+ 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(true); }
622
+ else { alert('Load failed: ' + d.message); }
623
+ } catch(e) { console.error(e); alert('Error loading comic.'); }
624
+ }
625
+
626
+ function restoreDraft() {
627
+ try {
628
+ const state = JSON.parse(localStorage.getItem('comic_draft_'+sid));
629
+ if(state.saveCode) { currentSaveCode = state.saveCode; document.getElementById('display-save-code').textContent = state.saveCode; document.getElementById('current-save-code').style.display = 'block'; }
630
+ renderFromState(state.pages || state);
631
+ document.getElementById('upload-container').style.display = 'none';
632
+ document.getElementById('editor-container').style.display = 'block';
633
+ addToHistory(); // Initialize history
634
+ } catch(e) { console.error(e); alert("Failed to restore."); }
635
+ }
636
+
637
+ function getCurrentState() {
638
+ const pages = [];
639
+ document.querySelectorAll('.comic-page').forEach(p => {
640
+ const panels = [];
641
+ p.querySelectorAll('.panel').forEach(pan => {
642
+ const img = pan.querySelector('img');
643
+ const bubbles = [];
644
+ pan.querySelectorAll('.speech-bubble').forEach(b => {
645
+ const textEl = b.querySelector('.bubble-text');
646
+ // Compute styles to fix mismatch
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.replace(' selected', ''),
651
+ type: b.dataset.type, font: b.style.fontFamily,
652
+ tailPos: b.style.getPropertyValue('--tail-pos'),
653
+ colors: { fill: b.style.getPropertyValue('--bubble-fill-color'), text: b.style.getPropertyValue('--bubble-text-color') }
654
  });
655
  });
656
+ panels.push({
657
+ src: img.src,
658
+ zoom: img.dataset.zoom, tx: img.dataset.translateX, ty: img.dataset.translateY,
659
+ bubbles: bubbles
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
660
  });
 
661
  });
662
+ pages.push({ panels: panels });
663
+ });
664
+ return pages;
665
+ }
666
+
667
+ function saveDraft(recordHistory = true) {
668
+ if(recordHistory) addToHistory();
669
+ localStorage.setItem('comic_draft_'+sid, JSON.stringify({ pages: getCurrentState(), saveCode: currentSaveCode, savedAt: new Date().toISOString() }));
670
+ }
671
+
672
+ function renderFromState(pagesData) {
673
+ const con = document.getElementById('comic-container'); con.innerHTML = '';
674
+ pagesData.forEach((page, pageIdx) => {
675
+ const pageWrapper = document.createElement('div'); pageWrapper.className = 'page-wrapper';
676
+ const pageTitle = document.createElement('h2'); pageTitle.className = 'page-title'; pageTitle.textContent = `Page ${pageIdx + 1}`;
677
+ pageWrapper.appendChild(pageTitle);
678
+ const div = document.createElement('div'); div.className = 'comic-page';
679
+ const grid = document.createElement('div'); grid.className = 'comic-grid';
680
+ page.panels.forEach((pan) => {
681
+ const pDiv = document.createElement('div'); pDiv.className = 'panel';
682
+ pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
683
+ const img = document.createElement('img');
684
+ img.src = pan.src.includes('?') ? pan.src : pan.src + `?sid=${sid}`;
685
+ img.dataset.zoom = pan.zoom || 100; img.dataset.translateX = pan.tx || 0; img.dataset.translateY = pan.ty || 0;
686
+ updateImageTransform(img);
687
+ img.onmousedown = (e) => startPan(e, img);
688
+ pDiv.appendChild(img);
689
+ (pan.bubbles || []).forEach(bData => { pDiv.appendChild(createBubbleHTML(bData)); });
690
+ grid.appendChild(pDiv);
 
 
 
 
 
 
 
 
 
 
691
  });
692
+ div.appendChild(grid); pageWrapper.appendChild(div); con.appendChild(pageWrapper);
693
+ });
694
+ // Clear selections
695
+ selectedBubble = null;
696
+ selectedPanel = null;
697
+ document.getElementById('bubble-type-select').disabled = true;
698
+ document.getElementById('font-select').disabled = true;
699
+ }
700
+
701
+ async function upload() {
702
+ const f = document.getElementById('file-upload').files[0];
703
+ const pCount = document.getElementById('page-count').value;
704
+ if(!f) return alert("Select a video");
705
+ sid = genUUID(); localStorage.setItem('comic_sid', sid); currentSaveCode = null;
706
+ document.querySelector('.upload-box').style.display='none';
707
+ document.getElementById('loading-view').style.display='flex';
708
+ const fd = new FormData(); fd.append('file', f); fd.append('target_pages', pCount);
709
+ const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
710
+ if(r.ok) interval = setInterval(checkStatus, 2000);
711
+ else { alert("Upload failed"); location.reload(); }
712
+ }
713
+
714
+ async function checkStatus() {
715
+ try {
716
+ const r = await fetch(`/status?sid=${sid}`); const d = await r.json();
717
+ document.getElementById('status-text').innerText = d.message;
718
+ if(d.progress >= 100) { clearInterval(interval); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='block'; loadNewComic(); }
719
+ else if (d.progress < 0) { clearInterval(interval); document.getElementById('status-text').textContent = "Error: " + d.message; document.querySelector('.loader').style.display = 'none'; }
720
+ } catch(e) {}
721
+ }
722
+
723
+ function loadNewComic() {
724
+ fetch(`/output/pages.json?sid=${sid}`).then(r=>r.json()).then(data => {
725
+ const cleanData = data.map((p, pi) => ({
726
+ panels: p.panels.map((pan, j) => ({
727
+ src: `/frames/${pan.image}?sid=${sid}`,
728
+ bubbles: (p.bubbles && p.bubbles[j] && p.bubbles[j].dialog) ? [{
729
+ text: p.bubbles[j].dialog,
730
+ left: (p.bubbles[j].bubble_offset_x || 50) + 'px',
731
+ top: (p.bubbles[j].bubble_offset_y || 20) + 'px',
732
+ type: (p.bubbles[j].type || 'speech'),
733
+ classes: `speech-bubble ${p.bubbles[j].type || 'speech'} tail-bottom`
734
+ }] : []
735
+ }))
736
+ }));
737
+ renderFromState(cleanData); saveDraft(true);
738
+ });
739
+ }
740
+
741
+ function createBubbleHTML(data) {
742
+ const b = document.createElement('div');
743
+ const type = data.type || 'speech';
744
+ b.className = data.classes || `speech-bubble ${type} tail-bottom`;
745
+ if (type === 'thought' && !b.className.includes('pos-')) b.className += ' pos-bl';
746
 
747
+ b.dataset.type = type;
748
+ b.style.left = data.left; b.style.top = data.top;
749
+ if(data.width) b.style.width = data.width; if(data.height) b.style.height = data.height;
750
+ if(data.font) b.style.fontFamily = data.font;
751
+ if(data.colors) { b.style.setProperty('--bubble-fill-color', data.colors.fill || '#4ECDC4'); b.style.setProperty('--bubble-text-color', data.colors.text || '#ffffff'); }
752
+ if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
 
 
753
 
754
+ const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || ''; b.appendChild(textSpan);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
755
 
756
+ 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); } }
 
 
 
 
 
 
 
 
 
 
757
 
758
+ ['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); });
 
 
 
 
759
 
760
+ b.onmousedown = (e) => {
761
+ if(e.target.classList.contains('resize-handle')) return;
762
+ e.stopPropagation(); selectBubble(b); isDragging = true; startX = e.clientX; startY = e.clientY; initX = b.offsetLeft; initY = b.offsetTop;
763
+ };
764
+ b.onclick = (e) => { e.stopPropagation(); };
765
+ b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
766
+ return b;
767
+ }
768
+
769
+ function editBubbleText(bubble) {
770
+ if (currentlyEditing) return; currentlyEditing = bubble;
771
+ const textSpan = bubble.querySelector('.bubble-text'); const textarea = document.createElement('textarea');
772
+ textarea.value = textSpan.textContent; bubble.appendChild(textarea); textSpan.style.display = 'none'; textarea.focus();
773
+ const finishEditing = () => {
774
+ textSpan.textContent = textarea.value; textarea.remove(); textSpan.style.display = ''; currentlyEditing = null;
775
+ saveDraft(true);
776
+ };
777
+ textarea.addEventListener('blur', finishEditing, { once: true });
778
+ textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); } });
779
+ }
780
+
781
+ document.addEventListener('mousemove', (e) => {
782
+ if(isDragging && selectedBubble) { selectedBubble.style.left = (initX + e.clientX - startX) + 'px'; selectedBubble.style.top = (initY + e.clientY - startY) + 'px'; }
783
+ if(isResizing && selectedBubble) { resizeBubble(e); }
784
+ if(isPanning && selectedPanel) { panImage(e); }
785
+ });
786
+
787
+ // Save draft and add to history on mouseup ONLY if action occurred
788
+ document.addEventListener('mouseup', () => {
789
+ if(isDragging || isResizing || isPanning) {
790
+ saveDraft(true);
791
  }
792
+ isDragging = false; isResizing = false; isPanning = false;
793
+ });
794
+
795
+ 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; }
796
+ 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'; }
797
+
798
+ function selectBubble(el) {
799
+ if(selectedBubble) selectedBubble.classList.remove('selected');
800
+ if(selectedPanel) { selectedPanel.classList.remove('selected'); selectedPanel = null; }
801
+ selectedBubble = el; el.classList.add('selected');
802
+ document.getElementById('bubble-type-select').disabled = false;
803
+ document.getElementById('font-select').disabled = false;
804
+ document.getElementById('bubble-text-color').disabled = false;
805
+ document.getElementById('bubble-fill-color').disabled = false;
806
+ document.getElementById('tail-controls').style.display = 'block';
807
+ document.getElementById('bubble-type-select').value = el.dataset.type || 'speech';
808
+ }
809
+
810
+ function selectPanel(el) {
811
+ if(selectedPanel) selectedPanel.classList.remove('selected');
812
+ if(selectedBubble) { selectedBubble.classList.remove('selected'); selectedBubble = null; }
813
+ selectedPanel = el; el.classList.add('selected');
814
+ document.getElementById('zoom-slider').disabled = false;
815
+ const img = el.querySelector('img');
816
+ document.getElementById('zoom-slider').value = img.dataset.zoom || 100;
817
+ document.getElementById('bubble-type-select').disabled = true;
818
+ document.getElementById('font-select').disabled = true;
819
+ document.getElementById('tail-controls').style.display = 'none';
820
+ }
821
+
822
+ function addBubble() {
823
+ if(!selectedPanel) return alert("Select a panel first");
824
+ const b = createBubbleHTML({ text: "Text", left: "50px", top: "30px", type: 'speech', classes: "speech-bubble speech tail-bottom" });
825
+ selectedPanel.appendChild(b); selectBubble(b); saveDraft(true);
826
+ }
827
+
828
+ function deleteBubble() {
829
+ if(!selectedBubble) return alert("Select a bubble");
830
+ selectedBubble.remove(); selectedBubble=null; saveDraft(true);
831
+ }
832
+
833
+ function changeBubbleType(type) {
834
+ if(!selectedBubble) return;
835
+ selectedBubble.dataset.type = type;
836
+ selectedBubble.className = 'speech-bubble ' + type + ' selected';
837
 
838
+ // Default tail for thought is pos-bl if not set
839
+ if(type === 'thought') selectedBubble.classList.add('pos-bl');
840
+ else selectedBubble.classList.add('tail-bottom'); // Default for speech
 
 
 
 
 
 
 
 
 
 
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(true);
845
+ }
846
+
847
+ function changeFont(font) { if(!selectedBubble) return; selectedBubble.style.fontFamily = font; saveDraft(true); }
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(true);
868
+ }
869
+
870
+ function slideTail(v) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', v+'%'); saveDraft(true); } }
871
+
872
+ document.getElementById('bubble-text-color').addEventListener('change', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-text-color', e.target.value); saveDraft(true); } });
873
+ document.getElementById('bubble-fill-color').addEventListener('change', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-fill-color', e.target.value); saveDraft(true); } });
874
+
875
+ function handleZoom(el) { if(!selectedPanel) return; const img = selectedPanel.querySelector('img'); img.dataset.zoom = el.value; updateImageTransform(img); }
876
+ // Add history on zoom release
877
+ document.getElementById('zoom-slider').addEventListener('change', () => saveDraft(true));
878
+
879
+ 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'); }
880
+ 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); }
881
+ 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); }
882
+ 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(true); }
883
+
884
+ 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(true); } inp.value = ''; }; inp.click(); }
885
+ 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(true); }
886
+ 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(true); }
887
+
888
+ async function exportComic() {
889
+ const pgs = document.querySelectorAll('.comic-page');
890
+ if(pgs.length === 0) return alert("No pages found");
891
 
892
+ // Remove selection highlights before export
893
+ if(selectedBubble) selectedBubble.classList.remove('selected');
894
+ if(selectedPanel) selectedPanel.classList.remove('selected');
895
+
896
+ alert(`Exporting ${pgs.length} page(s)...`);
897
 
898
+ // Fix for mismatch during export: Lock dimensions
899
+ const bubbles = document.querySelectorAll('.speech-bubble');
900
+ bubbles.forEach(b => {
901
+ const rect = b.getBoundingClientRect();
902
+ // We use style directly to prevent layout shifts
903
+ b.style.width = rect.width + 'px';
904
+ b.style.height = rect.height + 'px';
905
+ });
906
 
907
+ for(let i = 0; i < pgs.length; i++) {
908
+ try {
909
+ const u = await htmlToImage.toPng(pgs[i], { pixelRatio: 2 });
910
+ const a = document.createElement('a');
911
+ a.href = u;
912
+ a.download = `Comic-Page-${i+1}.png`;
913
+ a.click();
914
+ } catch(err) {
915
+ console.error(err);
916
+ alert(`Failed to export page ${i+1}`);
917
+ }
918
+ }
919
 
920
+ // Clean up styles
921
+ bubbles.forEach(b => {
922
+ // Only reset if they weren't manually sized, but since users resize manually,
923
+ // we should leave them be or reset only if they were auto-sized.
924
+ // In this specific app, we rely on the state re-render or just leaving them
925
+ // as they are usually pixel defined anyway.
926
+ });
927
+ }
928
+
929
+ 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'; } }
930
+ </script>
931
+ </body> </html> '''
932
 
933
  @app.route('/')
934
  def index():
 
943
 
944
  # GET PAGE COUNT FROM FORM
945
  target_pages = request.form.get('target_pages', 4)
946
+
947
  f = request.files['file']
948
  gen = EnhancedComicGenerator(sid)
949
  gen.cleanup()
 
1051
  return jsonify({'success': False, 'message': str(e)})
1052
 
1053
  if __name__ == '__main__':
1054
+ try: gpu_warmup()
1055
  except: pass
1056
  app.run(host='0.0.0.0', port=7860)