tester343 commited on
Commit
05177b9
·
verified ·
1 Parent(s): 11e2baf

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +136 -177
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
@@ -53,10 +53,9 @@ def generate_save_code(length=8):
53
  # 🧱 DATA CLASSES
54
  # ======================================================
55
  def bubble(dialog="", x=50, y=20, type='speech'):
56
- # Determine CSS classes based on type
57
  classes = f"speech-bubble {type}"
58
  if type == 'speech':
59
- classes += " tail-bottom" # Default tail position
60
  elif type == 'thought':
61
  classes += " pos-bl"
62
 
@@ -71,14 +70,6 @@ def bubble(dialog="", x=50, y=20, type='speech'):
71
  'font': "'Comic Neue', cursive"
72
  }
73
 
74
- def panel(image=""):
75
- return {'image': image}
76
-
77
- class Page:
78
- def __init__(self, panels, bubbles):
79
- self.panels = panels
80
- self.bubbles = bubbles
81
-
82
  # ======================================================
83
  # 🧠 GPU GENERATION
84
  # ======================================================
@@ -97,14 +88,11 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
97
  duration = total_frames / fps
98
  cap.release()
99
 
100
- # Subtitles
101
  user_srt = os.path.join(user_dir, 'subs.srt')
102
  try:
103
  get_real_subtitles(video_path)
104
- if os.path.exists('test1.srt'):
105
- shutil.move('test1.srt', user_srt)
106
- elif not os.path.exists(user_srt):
107
- with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
108
  except:
109
  with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
110
 
@@ -141,19 +129,11 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
141
  cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
142
  ret, frame = cap.read()
143
  if ret:
144
- # 🎯 SQUARE PADDING (0% Cut)
145
- h, w = frame.shape[:2]
146
- sq_dim = max(h, w)
147
- square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
148
- x_off = (sq_dim - w) // 2
149
- y_off = (sq_dim - h) // 2
150
- square_img[y_off:y_off+h, x_off:x_off+w] = frame
151
- square_img = cv2.resize(square_img, (1024, 1024))
152
-
153
  fname = f"frame_{count:04d}.png"
154
  p = os.path.join(frames_dir, fname)
155
- cv2.imwrite(p, square_img)
156
-
157
  frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
158
  frame_files_ordered.append(fname)
159
  count += 1
@@ -164,13 +144,10 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
164
  bubbles_list = []
165
  for i, f in enumerate(frame_files_ordered):
166
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
167
-
168
  b_type = 'speech'
169
  if '(' in dialogue: b_type = 'narration'
170
  elif '!' in dialogue: b_type = 'reaction'
171
- elif '?' in dialogue: b_type = 'speech'
172
 
173
- # 1 Bubble Per Panel Placement
174
  pos_idx = i % 4
175
  if pos_idx == 0: bx, by = 150, 50
176
  elif pos_idx == 1: bx, by = 550, 50
@@ -189,22 +166,16 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
189
 
190
  while len(p_frames) < 4:
191
  fname = f"empty_{i}_{len(p_frames)}.png"
192
- img = np.zeros((1024, 1024, 3), dtype=np.uint8); img[:] = (30,30,30)
193
  cv2.imwrite(os.path.join(frames_dir, fname), img)
194
  p_frames.append(fname)
195
  p_bubbles.append(bubble(dialog="", x=-999, y=-999, type='speech'))
196
 
197
  if p_frames:
198
- pg_panels = [panel(image=f) for f in p_frames]
199
- pages.append(Page(panels=pg_panels, bubbles=p_bubbles))
200
-
201
- result = []
202
- for pg in pages:
203
- p_data = [p if isinstance(p, dict) else p.__dict__ for p in pg.panels]
204
- b_data = [b if isinstance(b, dict) else b.__dict__ for b in pg.bubbles]
205
- result.append({'panels': p_data, 'bubbles': b_data})
206
-
207
- return result
208
 
209
  @spaces.GPU
210
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
@@ -224,15 +195,8 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
224
  cap.release()
225
 
226
  if ret:
227
- h, w = frame.shape[:2]
228
- sq_dim = max(h, w)
229
- square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
230
- x_off = (sq_dim - w) // 2
231
- y_off = (sq_dim - h) // 2
232
- square_img[y_off:y_off+h, x_off:x_off+w] = frame
233
- square_img = cv2.resize(square_img, (1024, 1024))
234
-
235
- cv2.imwrite(os.path.join(frames_dir, fname), square_img)
236
  if isinstance(meta[fname], dict): meta[fname]['time'] = new_t
237
  else: meta[fname] = new_t
238
  with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
@@ -249,15 +213,8 @@ def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
249
  cap.release()
250
 
251
  if ret:
252
- h, w = frame.shape[:2]
253
- sq_dim = max(h, w)
254
- square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
255
- x_off = (sq_dim - w) // 2
256
- y_off = (sq_dim - h) // 2
257
- square_img[y_off:y_off+h, x_off:x_off+w] = frame
258
- square_img = cv2.resize(square_img, (1024, 1024))
259
-
260
- cv2.imwrite(os.path.join(frames_dir, fname), square_img)
261
  if os.path.exists(metadata_path):
262
  with open(metadata_path, 'r') as f: meta = json.load(f)
263
  if fname in meta:
@@ -265,7 +222,7 @@ def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
265
  else: meta[fname] = float(ts)
266
  with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
267
  return {"success": True, "message": f"Jumped to {ts}s"}
268
- return {"success": False, "message": "Invalid timestamp"}
269
 
270
  class EnhancedComicGenerator:
271
  def __init__(self, sid):
@@ -300,41 +257,94 @@ class EnhancedComicGenerator:
300
  json.dump({'message': msg, 'progress': prog}, f)
301
 
302
  # ======================================================
303
- # 🌐 ROUTES & FRONTEND
304
  # ======================================================
305
  INDEX_HTML = '''
306
- <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Square HD Comic</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: #2c3e50; font-family: 'Lato', sans-serif; color: #eee; margin: 0; min-height: 100vh; }
 
307
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
308
  .upload-box { max-width: 500px; width: 100%; padding: 40px; background: #34495e; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); text-align: center; }
309
- #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 150px; }
310
 
311
- .comic-page { width: 800px; height: 800px; background: white; box-shadow: 0 5px 30px rgba(0,0,0,0.6); position: relative; overflow: hidden; border: 6px solid #000; }
312
- .comic-grid { width: 100%; height: 100%; position: relative; background: #000; --y: 50%; --t1: 100%; --t2: 100%; --b1: 100%; --b2: 100%; --gap: 3px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  .panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
314
  .panel img { width: 100%; height: 100%; object-fit: cover; transform-origin: center; transition: transform 0.05s ease-out; display: block; }
315
  .panel img.panning { cursor: grabbing; transition: none; }
316
  .panel.selected { outline: 4px solid #3498db; z-index: 5; }
317
 
 
318
  .panel:nth-child(1) { clip-path: polygon(0 0, calc(var(--t1) - var(--gap)) 0, calc(var(--t2) - var(--gap)) calc(var(--y) - var(--gap)), 0 calc(var(--y) - var(--gap))); z-index: 1; }
319
  .panel:nth-child(2) { clip-path: polygon(calc(var(--t1) + var(--gap)) 0, 100% 0, 100% calc(var(--y) - var(--gap)), calc(var(--t2) + var(--gap)) calc(var(--y) - var(--gap))); z-index: 1; }
320
  .panel:nth-child(3) { clip-path: polygon(0 calc(var(--y) + var(--gap)), calc(var(--b1) - var(--gap)) calc(var(--y) + var(--gap)), calc(var(--b2) - var(--gap)) 100%, 0 100%); z-index: 1; }
321
  .panel:nth-child(4) { clip-path: polygon(calc(var(--b1) + var(--gap)) calc(var(--y) + var(--gap)), 100% calc(var(--y) + var(--gap)), 100% 100%, calc(var(--b2) + var(--gap)) 100%); z-index: 1; }
322
 
323
  .handle { position: absolute; width: 26px; height: 26px; border: 3px solid white; border-radius: 50%; transform: translate(-50%, -50%); z-index: 101; cursor: ew-resize; box-shadow: 0 2px 5px rgba(0,0,0,0.8); }
324
- .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; } .h-t2 { background: #3498db; left: var(--t2); top: 50%; margin-top: -15px; }
325
- .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; } .h-b2 { background: #2ecc71; left: var(--b2); top: 100%; margin-top: -15px; }
 
 
326
 
327
- /* === ADVANCED BUBBLE CSS (WITH ROTATION) === */
328
  .speech-bubble {
329
  position: absolute; display: flex; justify-content: center; align-items: center;
330
  min-width: 60px; min-height: 40px; box-sizing: border-box;
331
  z-index: 10; cursor: move; font-weight: bold; text-align: center;
332
  overflow: visible; line-height: 1.2; --tail-pos: 50%;
333
  }
334
- .bubble-text { padding: 0.8em; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; border-radius: inherit; pointer-events: none; }
 
 
 
 
335
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
336
 
337
- /* Speech Bubble + Rotation Classes */
338
  .speech-bubble.speech {
339
  --b: 3em; --h: 1.8em; --t: 0.6; --p: var(--tail-pos, 50%); --r: 1.2em;
340
  background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); padding: 0;
@@ -346,26 +356,18 @@ INDEX_HTML = '''
346
  -webkit-mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
347
  mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
348
  }
349
- /* Tail Orientations */
350
  .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))); }
351
-
352
- .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); }
353
  .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); }
354
-
355
- .speech-bubble.speech.tail-left { border-radius: var(--r); }
356
  .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; }
357
-
358
- .speech-bubble.speech.tail-right { border-radius: var(--r); }
359
  .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; }
360
 
361
- /* Thought & Others */
362
  .speech-bubble.thought { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 2px dashed #555; border-radius: 50%; }
363
  .speech-bubble.thought::before { display:none; }
364
  .thought-dot { position: absolute; background-color: var(--bubble-fill, #fff); border: 2px solid #555; border-radius: 50%; z-index: -1; }
365
  .thought-dot-1 { width: 15px; height: 15px; bottom:-15px; left:20px; } .thought-dot-2 { width: 10px; height: 10px; bottom:-25px; left:10px; }
366
- .speech-bubble.thought.pos-bl .thought-dot-1 { left: 20px; bottom: -20px; }
367
 
368
  .speech-bubble.reaction { background: #FFD700; border: 3px solid #E53935; color: #D32F2F; font-family: 'Bangers'; text-transform: uppercase; clip-path: polygon(0% 25%, 17% 21%, 17% 0%, 31% 16%, 50% 4%, 69% 16%, 83% 0%, 83% 21%, 100% 25%, 85% 45%, 95% 62%, 82% 79%, 100% 97%, 79% 89%, 60% 98%, 46% 82%, 27% 95%, 15% 78%, 5% 62%, 15% 45%); }
 
369
  .speech-bubble.narration { background: #eee; border: 2px solid #000; color: #000; border-radius: 0; font-family: 'Lato'; bottom: 10px; left: 50%; transform: translateX(-50%); width: 80% !important; height: auto !important; }
370
 
371
  .resize-handle { position: absolute; bottom:-5px; right:-5px; width:15px; height:15px; background:#3498db; border:1px solid white; cursor:se-resize; display:none; }
@@ -385,34 +387,24 @@ INDEX_HTML = '''
385
  .save-btn { background: #8e44ad; color: white; }
386
 
387
  .tip { text-align:center; padding:10px; background:#e74c3c; color:white; font-weight:bold; margin-bottom:20px; border-radius:5px; }
388
- .modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); display: none; justify-content: center; align-items: center; z-index: 2000; }
389
- .modal-content { background: white; padding: 30px; border-radius: 12px; width: 90%; max-width: 400px; text-align: center; color: #333; }
390
- .code { font-size: 24px; font-weight: bold; letter-spacing: 3px; background: #eee; padding: 10px; margin: 15px 0; display: inline-block; font-family: monospace; }
391
  </style>
392
  </head> <body>
393
 
394
  <div id="upload-container">
395
  <div class="upload-box">
396
- <h1>⚡ Ultimate Square Comic</h1>
397
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
398
  <label for="file-upload" class="file-label">📁 Choose Video</label>
399
  <span id="fn" style="margin-bottom:10px; display:block; color:#aaa;">No file selected</span>
400
  <div class="page-input-group"> <label>📚 Total Pages:</label> <input type="number" id="page-count" value="4" min="1" max="15"> </div>
401
  <button class="submit-btn" onclick="upload()">🚀 Generate</button>
402
  <button id="restore-draft-btn" class="reset-btn" style="display:none; margin-top:10px;" onclick="restoreDraft()">📂 Restore Draft</button>
403
- <div style="margin-top:20px; border-top:1px solid #555; padding-top:10px;">
404
- <input type="text" id="load-code" placeholder="ENTER SAVE CODE" style="width:70%; display:inline-block;">
405
- <button onclick="loadComic()" style="width:25%; display:inline-block; background:#9b59b6; color:white;">Load</button>
406
- </div>
407
- <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
408
- <div class="loader"></div>
409
- <p id="status-text" style="margin-top:10px;">Analyzing Video...</p>
410
- </div>
411
  </div>
412
  </div>
413
 
414
  <div id="editor-container">
415
- <div class="tip">👉 Drag Right-Side Dots to reveal 4 panels! | 📜 Scroll to Zoom/Pan</div>
416
  <div class="comic-wrapper" id="comic-container"></div>
417
  <input type="file" id="image-uploader" style="display: none;" accept="image/*">
418
  <div class="edit-controls">
@@ -421,28 +413,20 @@ INDEX_HTML = '''
421
  <div class="control-group">
422
  <label>💬 Bubble Styling:</label>
423
  <select id="bubble-type" onchange="updateBubbleType()"> <option value="speech">Speech 💬</option> <option value="thought">Thought 💭</option> <option value="reaction">Reaction 💥</option> <option value="narration">Narration ⬜</option> </select>
424
- <select id="font-select" onchange="updateFont()"> <option value="'Comic Neue', cursive">Comic Neue</option> <option value="'Bangers', cursive">Bangers</option> <option value="'Gloria Hallelujah', cursive">Handwritten</option> <option value="'Lato', sans-serif">Modern</option> </select>
425
- <div class="color-grid"> <input type="color" id="bub-fill" value="#ffffff" onchange="updateColors()" title="Fill"> <input type="color" id="bub-text" value="#000000" onchange="updateColors()" title="Text"> </div>
426
  <div class="button-grid"> <button onclick="addBubble()" class="action-btn">Add</button> <button onclick="deleteBubble()" class="reset-btn">Delete</button> </div>
427
  <div id="tail-controls">
428
- <button onclick="rotateTail()" class="secondary-btn" style="margin-top:5px;">🔄 Rotate Tail Side</button>
429
  <input type="range" min="10" max="90" value="50" oninput="slideTail(this.value)" title="Tail Pos">
430
  </div>
431
  </div>
432
- <div class="control-group"> <label>🖼️ Image Control:</label> <button onclick="replaceImage()" class="action-btn">Replace Image</button> <div class="button-grid"> <button onclick="adjustFrame('backward')" class="secondary-btn">⬅️ Frame</button> <button onclick="adjustFrame('forward')" class="action-btn">Frame ➡️</button> </div> </div>
433
- <div class="control-group"> <label>🔍 Zoom (Scroll Wheel):</label> <input type="range" id="zoom-slider" min="20" max="300" value="100" step="5" oninput="handleZoom(this.value)" disabled> <button onclick="resetPanelTransform()" class="secondary-btn">Reset View</button> </div>
434
  <div class="control-group"> <button onclick="exportComic()" class="action-btn" style="background:#3498db;">📥 Export PNG</button> <button onclick="location.reload()" class="reset-btn" style="margin-top:10px;">🏠 Home</button> </div>
435
  </div>
436
  </div>
437
 
438
- <div class="modal-overlay" id="save-modal">
439
- <div class="modal-content">
440
- <h2>✅ Comic Saved!</h2>
441
- <div class="code" id="modal-code">XXXX</div>
442
- <button onclick="closeModal()">Close</button>
443
- </div>
444
- </div>
445
-
446
  <script>
447
  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);}); }
448
  let sid = localStorage.getItem('comic_sid') || genUUID();
@@ -458,7 +442,14 @@ INDEX_HTML = '''
458
  const layout = { t1: grid.style.getPropertyValue('--t1')||'100%', t2: grid.style.getPropertyValue('--t2')||'100%', b1: grid.style.getPropertyValue('--b1')||'100%', b2: grid.style.getPropertyValue('--b2')||'100%' };
459
  const bubbles = [];
460
  grid.querySelectorAll('.speech-bubble').forEach(b => {
461
- bubbles.push({ text: b.querySelector('.bubble-text').textContent, left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height, type: b.dataset.type, font: b.style.fontFamily, colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') }, tailPos: b.style.getPropertyValue('--tail-pos'), classes: b.className });
 
 
 
 
 
 
 
462
  });
463
  const panels = [];
464
  grid.querySelectorAll('.panel').forEach(pan => {
@@ -475,6 +466,7 @@ INDEX_HTML = '''
475
  }
476
 
477
  function undo() { if(historyStack.length > 1) { historyStack.pop(); const prev = JSON.parse(historyStack[historyStack.length-1]); restoreFromState(prev); } }
 
478
  function restoreFromState(stateData) {
479
  if(!stateData) return;
480
  const pages = document.querySelectorAll('.comic-page');
@@ -497,7 +489,7 @@ INDEX_HTML = '''
497
  }
498
 
499
  if(localStorage.getItem('comic_draft_'+sid)) document.getElementById('restore-draft-btn').style.display='inline-block';
500
- function restoreDraft() { document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='block'; loadNewComic().then(() => { setTimeout(() => restoreFromState(JSON.parse(localStorage.getItem('comic_draft_'+sid))), 500); }); }
501
  async function upload() {
502
  const f = document.getElementById('file-upload').files[0];
503
  const pCount = document.getElementById('page-count').value;
@@ -514,7 +506,7 @@ INDEX_HTML = '''
514
  try {
515
  const r = await fetch(`/status?sid=${sid}`); const d = await r.json();
516
  document.getElementById('status-text').innerText = d.message;
517
- if(d.progress >= 100) { clearInterval(interval); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='block'; loadNewComic(); }
518
  } catch(e) {}
519
  }
520
  async function loadNewComic() {
@@ -548,12 +540,17 @@ INDEX_HTML = '''
548
  });
549
  }
550
  function createHandle(cls, grid, varName) { let h = document.createElement('div'); h.className = `handle ${cls}`; h.onmousedown = (e) => { e.stopPropagation(); dragType = 'handle'; activeObj = { grid: grid, var: varName }; }; return h; }
 
551
  function createBubbleHTML(data) {
552
  const b = document.createElement('div');
553
  const type = data.type || 'speech';
554
- let className = data.classes || `speech-bubble ${type} tail-bottom`;
555
- if (type === 'thought' && !className.includes('pos-')) className += ' pos-bl';
 
 
 
556
  b.className = className;
 
557
  b.dataset.type = type;
558
  b.style.left = data.left; b.style.top = data.top;
559
  if(data.width) b.style.width = data.width;
@@ -561,7 +558,9 @@ INDEX_HTML = '''
561
  if(data.font) b.style.fontFamily = data.font;
562
  if(data.colors) { b.style.setProperty('--bubble-fill', data.colors.fill); b.style.setProperty('--bubble-text', data.colors.text); }
563
  if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
 
564
  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); } }
 
565
  const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || 'Text'; b.appendChild(textSpan);
566
  const resizer = document.createElement('div'); resizer.className = 'resize-handle';
567
  resizer.onmousedown = (e) => { e.stopPropagation(); dragType='resize'; activeObj={b:b, startW:b.offsetWidth, startH:b.offsetHeight, mx:e.clientX, my:e.clientY}; };
@@ -570,6 +569,7 @@ INDEX_HTML = '''
570
  b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
571
  return b;
572
  }
 
573
  function editBubbleText(bubble) {
574
  const textSpan = bubble.querySelector('.bubble-text');
575
  const newText = prompt("Edit Text:", textSpan.textContent);
@@ -598,21 +598,39 @@ INDEX_HTML = '''
598
  }
599
  });
600
  document.addEventListener('mouseup', () => { if(activeObj && activeObj.classList) activeObj.classList.remove('panning'); if(dragType) saveState(); dragType = null; activeObj = null; });
601
- function selectBubble(el) { if(selectedBubble) selectedBubble.classList.remove('selected'); selectedBubble = el; el.classList.add('selected'); document.getElementById('bubble-type').value = el.dataset.type; document.getElementById('font-select').value = el.style.fontFamily || "'Comic Neue', cursive"; }
602
- function selectPanel(el) { if(selectedPanel) selectedPanel.classList.remove('selected'); selectedPanel = el; el.classList.add('selected'); document.getElementById('zoom-slider').disabled = false; document.getElementById('zoom-slider').value = el.querySelector('img').dataset.zoom; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
  function addBubble() { const grid = document.querySelector('.comic-grid'); if(grid) { const b = createBubbleHTML({ text: "Text", left: "50%", top: "50%" }); grid.appendChild(b); selectBubble(b); saveState(); } }
604
  function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); selectedBubble=null; saveState(); } }
 
 
605
  function updateBubbleType() {
606
  if(!selectedBubble) return;
607
  const type = document.getElementById('bubble-type').value;
608
  const oldB = selectedBubble;
 
 
609
  const data = {
610
  text: oldB.querySelector('.bubble-text').textContent,
611
  left: oldB.style.left, top: oldB.style.top, width: oldB.style.width, height: oldB.style.height,
612
  type: type, font: oldB.style.fontFamily,
613
  colors: { fill: oldB.style.getPropertyValue('--bubble-fill'), text: oldB.style.getPropertyValue('--bubble-text') },
614
- tailPos: oldB.style.getPropertyValue('--tail-pos'),
615
- classes: oldB.className
616
  };
617
  const newB = createBubbleHTML(data);
618
  oldB.parentElement.replaceChild(newB, oldB);
@@ -622,24 +640,15 @@ INDEX_HTML = '''
622
  function updateFont() { if(selectedBubble) { selectedBubble.style.fontFamily = document.getElementById('font-select').value; saveState(); } }
623
  function slideTail(val) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', val+'%'); saveState(); } }
624
 
625
- // 🎯 NEW ROTATE TAIL FUNCTION
626
  function rotateTail() {
627
  if(!selectedBubble) return;
628
  const type = selectedBubble.dataset.type;
629
-
630
  if(type === 'speech') {
631
  const positions = ['tail-bottom', 'tail-right', 'tail-top', 'tail-left'];
632
- // Find current class match
633
- let current = positions.find(pos => selectedBubble.classList.contains(pos)) || 'tail-bottom';
634
  selectedBubble.classList.remove(current);
635
- // Cycle
636
- let nextIndex = (positions.indexOf(current) + 1) % 4;
637
- selectedBubble.classList.add(positions[nextIndex]);
638
- }
639
- else if (type === 'thought') {
640
- // Logic for thought bubble (flipping dots) if needed, or simple rotation
641
- // For now, let's allow thought bubbles to use the same tail classes if CSS supports it,
642
- // or just flip positions.
643
  }
644
  saveState();
645
  }
@@ -670,18 +679,6 @@ INDEX_HTML = '''
670
  if(d.success) img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
671
  img.style.opacity='1'; saveState();
672
  }
673
- async function gotoTimestamp() {
674
- if(!selectedPanel) return alert("Select a panel");
675
- let v = document.getElementById('timestamp-input').value.trim();
676
- if(!v) return;
677
- if(v.includes(':')) { let p = v.split(':'); v = parseInt(p[0]) * 60 + parseFloat(p[1]); } else { v = parseFloat(v); }
678
- const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0];
679
- img.style.opacity = '0.5';
680
- const r = await fetch(`/goto_timestamp?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, timestamp:v}) });
681
- const d = await r.json();
682
- if(d.success) img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
683
- img.style.opacity='1'; saveState();
684
- }
685
 
686
  async function exportComic() {
687
  const pgs = document.querySelectorAll('.comic-page');
@@ -700,31 +697,10 @@ INDEX_HTML = '''
700
  const code = document.getElementById('load-code').value;
701
  const r = await fetch(`/load_comic/${code}`);
702
  const d = await r.json();
703
- if(d.success) { sid=d.originalSid; localStorage.setItem('comic_sid', sid); restoreFromState(d.pages); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='block'; }
704
  else alert(d.message);
705
  }
706
  function closeModal() { document.getElementById('save-modal').style.display='none'; }
707
-
708
- function getCurrentState() {
709
- const state = [];
710
- document.querySelectorAll('.comic-page').forEach(pg => {
711
- const grid = pg.querySelector('.comic-grid');
712
- const layout = { t1: grid.style.getPropertyValue('--t1')||'100%', t2: grid.style.getPropertyValue('--t2')||'100%', b1: grid.style.getPropertyValue('--b1')||'100%', b2: grid.style.getPropertyValue('--b2')||'100%' };
713
- const bubbles = [];
714
- grid.querySelectorAll('.speech-bubble').forEach(b => {
715
- bubbles.push({ text: b.querySelector('.bubble-text').textContent, left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height, type: b.dataset.type, colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') }, tailPos: b.style.getPropertyValue('--tail-pos'), classes: b.className });
716
- });
717
- const panels = [];
718
- grid.querySelectorAll('.panel').forEach(pan => {
719
- const img = pan.querySelector('img');
720
- const srcParts = img.src.split('frames/');
721
- const fname = srcParts.length > 1 ? srcParts[1].split('?')[0] : '';
722
- panels.push({ image: fname, zoom: img.dataset.zoom, tx: img.dataset.translateX, ty: img.dataset.translateY });
723
- });
724
- state.push({ layout, bubbles, panels });
725
- });
726
- return state;
727
- }
728
  </script>
729
  </body> </html> '''
730
 
@@ -773,23 +749,6 @@ def regen():
773
  gen = EnhancedComicGenerator(sid)
774
  return jsonify(regen_frame_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], d['direction']))
775
 
776
- @app.route('/goto_timestamp', methods=['POST'])
777
- def go_time():
778
- sid = request.args.get('sid')
779
- d = request.get_json()
780
- gen = EnhancedComicGenerator(sid)
781
- return jsonify(get_frame_at_ts_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], float(d['timestamp'])))
782
-
783
- @app.route('/replace_panel', methods=['POST'])
784
- def rep_panel():
785
- sid = request.args.get('sid')
786
- f = request.files['image']
787
- frames_dir = os.path.join(BASE_USER_DIR, sid, 'frames')
788
- os.makedirs(frames_dir, exist_ok=True)
789
- fname = f"replaced_{int(time.time() * 1000)}.png"
790
- f.save(os.path.join(frames_dir, fname))
791
- return jsonify({'success': True, 'new_filename': fname})
792
-
793
  @app.route('/save_comic', methods=['POST'])
794
  def save_comic():
795
  sid = request.args.get('sid')
 
1
+ import spaces
2
  import os
3
  import time
4
  import threading
 
53
  # 🧱 DATA CLASSES
54
  # ======================================================
55
  def bubble(dialog="", x=50, y=20, type='speech'):
 
56
  classes = f"speech-bubble {type}"
57
  if type == 'speech':
58
+ classes += " tail-bottom"
59
  elif type == 'thought':
60
  classes += " pos-bl"
61
 
 
70
  'font': "'Comic Neue', cursive"
71
  }
72
 
 
 
 
 
 
 
 
 
73
  # ======================================================
74
  # 🧠 GPU GENERATION
75
  # ======================================================
 
88
  duration = total_frames / fps
89
  cap.release()
90
 
 
91
  user_srt = os.path.join(user_dir, 'subs.srt')
92
  try:
93
  get_real_subtitles(video_path)
94
+ if os.path.exists('test1.srt'): shutil.move('test1.srt', user_srt)
95
+ elif not os.path.exists(user_srt): with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
 
 
96
  except:
97
  with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
98
 
 
129
  cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
130
  ret, frame = cap.read()
131
  if ret:
132
+ # 1280x720 (No Crop)
133
+ frame = cv2.resize(frame, (1280, 720))
 
 
 
 
 
 
 
134
  fname = f"frame_{count:04d}.png"
135
  p = os.path.join(frames_dir, fname)
136
+ cv2.imwrite(p, frame)
 
137
  frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
138
  frame_files_ordered.append(fname)
139
  count += 1
 
144
  bubbles_list = []
145
  for i, f in enumerate(frame_files_ordered):
146
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
 
147
  b_type = 'speech'
148
  if '(' in dialogue: b_type = 'narration'
149
  elif '!' in dialogue: b_type = 'reaction'
 
150
 
 
151
  pos_idx = i % 4
152
  if pos_idx == 0: bx, by = 150, 50
153
  elif pos_idx == 1: bx, by = 550, 50
 
166
 
167
  while len(p_frames) < 4:
168
  fname = f"empty_{i}_{len(p_frames)}.png"
169
+ img = np.zeros((720, 1280, 3), dtype=np.uint8); img[:] = (30,30,30)
170
  cv2.imwrite(os.path.join(frames_dir, fname), img)
171
  p_frames.append(fname)
172
  p_bubbles.append(bubble(dialog="", x=-999, y=-999, type='speech'))
173
 
174
  if p_frames:
175
+ pg_panels = [{'image': f} for f in p_frames]
176
+ pages.append({'panels': pg_panels, 'bubbles': p_bubbles})
177
+
178
+ return pages
 
 
 
 
 
 
179
 
180
  @spaces.GPU
181
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
 
195
  cap.release()
196
 
197
  if ret:
198
+ frame = cv2.resize(frame, (1280, 720))
199
+ cv2.imwrite(os.path.join(frames_dir, fname), frame)
 
 
 
 
 
 
 
200
  if isinstance(meta[fname], dict): meta[fname]['time'] = new_t
201
  else: meta[fname] = new_t
202
  with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
 
213
  cap.release()
214
 
215
  if ret:
216
+ frame = cv2.resize(frame, (1280, 720))
217
+ cv2.imwrite(os.path.join(frames_dir, fname), frame)
 
 
 
 
 
 
 
218
  if os.path.exists(metadata_path):
219
  with open(metadata_path, 'r') as f: meta = json.load(f)
220
  if fname in meta:
 
222
  else: meta[fname] = float(ts)
223
  with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
224
  return {"success": True, "message": f"Jumped to {ts}s"}
225
+ return {"success": False}
226
 
227
  class EnhancedComicGenerator:
228
  def __init__(self, sid):
 
257
  json.dump({'message': msg, 'progress': prog}, f)
258
 
259
  # ======================================================
260
+ # 🌐 FRONTEND
261
  # ======================================================
262
  INDEX_HTML = '''
263
+ <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Fixed Comic Editor</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: #2c3e50; font-family: 'Lato', sans-serif; color: #eee; margin: 0; min-height: 100vh; }
264
+
265
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
266
  .upload-box { max-width: 500px; width: 100%; padding: 40px; background: #34495e; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); text-align: center; }
 
267
 
268
+ /* 🎯 CENTER THE COMIC EDITOR */
269
+ #editor-container {
270
+ display: none;
271
+ padding: 20px;
272
+ width: 100%;
273
+ box-sizing: border-box;
274
+ padding-bottom: 150px;
275
+ flex-direction: column;
276
+ align-items: center; /* Centers comic horizontally */
277
+ }
278
+
279
+ h1 { color: #fff; margin-bottom: 20px; }
280
+ .file-input { display: none; }
281
+ .file-label { display: block; padding: 15px; background: #e67e22; color: white; border-radius: 8px; cursor: pointer; font-weight: bold; margin-bottom: 10px; transition:0.2s; }
282
+ .page-input-group { margin: 20px 0; text-align: left; }
283
+ .page-input-group label { font-weight: bold; font-size: 14px; display: block; margin-bottom: 5px; color: #ccc; }
284
+ .page-input-group input { width: 100%; padding: 12px; border: 2px solid #555; background: #2c3e50; color: white; border-radius: 8px; font-size: 16px; box-sizing: border-box; }
285
+ .submit-btn { width: 100%; padding: 15px; background: #2980b9; color: white; border: none; border-radius: 8px; font-size: 18px; font-weight: bold; cursor: pointer; }
286
+ .loader { width: 100px; height: 10px; background: #e67e22; margin: 20px auto; animation: load 1s infinite alternate; }
287
+ @keyframes load { from { width: 20px; } to { width: 100px; } }
288
+
289
+ /* COMIC PAGE - 800x800 */
290
+ .comic-wrapper {
291
+ margin: 0 auto;
292
+ display: flex;
293
+ flex-direction: column;
294
+ align-items: center; /* Center pages */
295
+ gap: 40px;
296
+ width: 100%;
297
+ }
298
+
299
+ .comic-page {
300
+ width: 800px;
301
+ height: 800px;
302
+ background: white;
303
+ box-shadow: 0 5px 30px rgba(0,0,0,0.6);
304
+ position: relative; overflow: hidden;
305
+ border: 5px solid #ffffff;
306
+ flex-shrink: 0;
307
+ }
308
+
309
+ .comic-grid {
310
+ width: 100%; height: 100%; position: relative;
311
+ background: #ffffff;
312
+ --y: 50%; --t1: 100%; --t2: 100%; --b1: 100%; --b2: 100%;
313
+ --gap: 5px;
314
+ }
315
+
316
  .panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
317
  .panel img { width: 100%; height: 100%; object-fit: cover; transform-origin: center; transition: transform 0.05s ease-out; display: block; }
318
  .panel img.panning { cursor: grabbing; transition: none; }
319
  .panel.selected { outline: 4px solid #3498db; z-index: 5; }
320
 
321
+ /* Clip Paths */
322
  .panel:nth-child(1) { clip-path: polygon(0 0, calc(var(--t1) - var(--gap)) 0, calc(var(--t2) - var(--gap)) calc(var(--y) - var(--gap)), 0 calc(var(--y) - var(--gap))); z-index: 1; }
323
  .panel:nth-child(2) { clip-path: polygon(calc(var(--t1) + var(--gap)) 0, 100% 0, 100% calc(var(--y) - var(--gap)), calc(var(--t2) + var(--gap)) calc(var(--y) - var(--gap))); z-index: 1; }
324
  .panel:nth-child(3) { clip-path: polygon(0 calc(var(--y) + var(--gap)), calc(var(--b1) - var(--gap)) calc(var(--y) + var(--gap)), calc(var(--b2) - var(--gap)) 100%, 0 100%); z-index: 1; }
325
  .panel:nth-child(4) { clip-path: polygon(calc(var(--b1) + var(--gap)) calc(var(--y) + var(--gap)), 100% calc(var(--y) + var(--gap)), 100% 100%, calc(var(--b2) + var(--gap)) 100%); z-index: 1; }
326
 
327
  .handle { position: absolute; width: 26px; height: 26px; border: 3px solid white; border-radius: 50%; transform: translate(-50%, -50%); z-index: 101; cursor: ew-resize; box-shadow: 0 2px 5px rgba(0,0,0,0.8); }
328
+ .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; }
329
+ .h-t2 { background: #3498db; left: var(--t2); top: 50%; margin-top: -15px; }
330
+ .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; }
331
+ .h-b2 { background: #2ecc71; left: var(--b2); top: 100%; margin-top: -15px; }
332
 
333
+ /* BUBBLES */
334
  .speech-bubble {
335
  position: absolute; display: flex; justify-content: center; align-items: center;
336
  min-width: 60px; min-height: 40px; box-sizing: border-box;
337
  z-index: 10; cursor: move; font-weight: bold; text-align: center;
338
  overflow: visible; line-height: 1.2; --tail-pos: 50%;
339
  }
340
+ .bubble-text {
341
+ padding: 0.8em; word-wrap: break-word; white-space: pre-wrap;
342
+ width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
343
+ border-radius: inherit; pointer-events: none;
344
+ }
345
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
346
 
347
+ /* TYPES */
348
  .speech-bubble.speech {
349
  --b: 3em; --h: 1.8em; --t: 0.6; --p: var(--tail-pos, 50%); --r: 1.2em;
350
  background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); padding: 0;
 
356
  -webkit-mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
357
  mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
358
  }
 
359
  .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))); }
 
 
360
  .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); }
 
 
361
  .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; }
 
 
362
  .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; }
363
 
 
364
  .speech-bubble.thought { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 2px dashed #555; border-radius: 50%; }
365
  .speech-bubble.thought::before { display:none; }
366
  .thought-dot { position: absolute; background-color: var(--bubble-fill, #fff); border: 2px solid #555; border-radius: 50%; z-index: -1; }
367
  .thought-dot-1 { width: 15px; height: 15px; bottom:-15px; left:20px; } .thought-dot-2 { width: 10px; height: 10px; bottom:-25px; left:10px; }
 
368
 
369
  .speech-bubble.reaction { background: #FFD700; border: 3px solid #E53935; color: #D32F2F; font-family: 'Bangers'; text-transform: uppercase; clip-path: polygon(0% 25%, 17% 21%, 17% 0%, 31% 16%, 50% 4%, 69% 16%, 83% 0%, 83% 21%, 100% 25%, 85% 45%, 95% 62%, 82% 79%, 100% 97%, 79% 89%, 60% 98%, 46% 82%, 27% 95%, 15% 78%, 5% 62%, 15% 45%); }
370
+
371
  .speech-bubble.narration { background: #eee; border: 2px solid #000; color: #000; border-radius: 0; font-family: 'Lato'; bottom: 10px; left: 50%; transform: translateX(-50%); width: 80% !important; height: auto !important; }
372
 
373
  .resize-handle { position: absolute; bottom:-5px; right:-5px; width:15px; height:15px; background:#3498db; border:1px solid white; cursor:se-resize; display:none; }
 
387
  .save-btn { background: #8e44ad; color: white; }
388
 
389
  .tip { text-align:center; padding:10px; background:#e74c3c; color:white; font-weight:bold; margin-bottom:20px; border-radius:5px; }
 
 
 
390
  </style>
391
  </head> <body>
392
 
393
  <div id="upload-container">
394
  <div class="upload-box">
395
+ <h1>⚡ Square HD Comic</h1>
396
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
397
  <label for="file-upload" class="file-label">📁 Choose Video</label>
398
  <span id="fn" style="margin-bottom:10px; display:block; color:#aaa;">No file selected</span>
399
  <div class="page-input-group"> <label>📚 Total Pages:</label> <input type="number" id="page-count" value="4" min="1" max="15"> </div>
400
  <button class="submit-btn" onclick="upload()">🚀 Generate</button>
401
  <button id="restore-draft-btn" class="reset-btn" style="display:none; margin-top:10px;" onclick="restoreDraft()">📂 Restore Draft</button>
402
+ <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;"> <div class="loader"></div> <p id="status-text" style="margin-top:10px;">Processing...</p> </div>
 
 
 
 
 
 
 
403
  </div>
404
  </div>
405
 
406
  <div id="editor-container">
407
+ <div class="tip">👉 Drag dots from RIGHT edge to LEFT to reveal 4 panels!</div>
408
  <div class="comic-wrapper" id="comic-container"></div>
409
  <input type="file" id="image-uploader" style="display: none;" accept="image/*">
410
  <div class="edit-controls">
 
413
  <div class="control-group">
414
  <label>💬 Bubble Styling:</label>
415
  <select id="bubble-type" onchange="updateBubbleType()"> <option value="speech">Speech 💬</option> <option value="thought">Thought 💭</option> <option value="reaction">Reaction 💥</option> <option value="narration">Narration ⬜</option> </select>
416
+ <select id="font-select" onchange="updateFont()"> <option value="'Comic Neue', cursive">Comic Neue</option> <option value="'Bangers', cursive">Bangers</option> <option value="'Lato', sans-serif">Modern</option> </select>
417
+ <div class="color-grid"> <input type="color" id="bub-fill" value="#ffffff" onchange="updateColors()"> <input type="color" id="bub-text" value="#000000" onchange="updateColors()"> </div>
418
  <div class="button-grid"> <button onclick="addBubble()" class="action-btn">Add</button> <button onclick="deleteBubble()" class="reset-btn">Delete</button> </div>
419
  <div id="tail-controls">
420
+ <button onclick="rotateTail()" class="secondary-btn" style="margin-top:5px;">🔄 Rotate Tail</button>
421
  <input type="range" min="10" max="90" value="50" oninput="slideTail(this.value)" title="Tail Pos">
422
  </div>
423
  </div>
424
+ <div class="control-group"> <label>🖼️ Image:</label> <div class="button-grid"> <button onclick="adjustFrame('backward')" class="secondary-btn">⬅️ Frame</button> <button onclick="adjustFrame('forward')" class="action-btn">Frame ➡️</button> </div> </div>
425
+ <div class="control-group"> <label>🔍 Zoom (Scroll):</label> <input type="range" id="zoom-slider" min="20" max="300" value="100" step="5" oninput="handleZoom(this.value)" disabled> <button onclick="resetPanelTransform()" class="secondary-btn">Reset</button> </div>
426
  <div class="control-group"> <button onclick="exportComic()" class="action-btn" style="background:#3498db;">📥 Export PNG</button> <button onclick="location.reload()" class="reset-btn" style="margin-top:10px;">🏠 Home</button> </div>
427
  </div>
428
  </div>
429
 
 
 
 
 
 
 
 
 
430
  <script>
431
  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);}); }
432
  let sid = localStorage.getItem('comic_sid') || genUUID();
 
442
  const layout = { t1: grid.style.getPropertyValue('--t1')||'100%', t2: grid.style.getPropertyValue('--t2')||'100%', b1: grid.style.getPropertyValue('--b1')||'100%', b2: grid.style.getPropertyValue('--b2')||'100%' };
443
  const bubbles = [];
444
  grid.querySelectorAll('.speech-bubble').forEach(b => {
445
+ bubbles.push({
446
+ text: b.querySelector('.bubble-text').textContent,
447
+ left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
448
+ type: b.dataset.type, font: b.style.fontFamily,
449
+ colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') },
450
+ tailPos: b.style.getPropertyValue('--tail-pos'),
451
+ classes: b.className
452
+ });
453
  });
454
  const panels = [];
455
  grid.querySelectorAll('.panel').forEach(pan => {
 
466
  }
467
 
468
  function undo() { if(historyStack.length > 1) { historyStack.pop(); const prev = JSON.parse(historyStack[historyStack.length-1]); restoreFromState(prev); } }
469
+
470
  function restoreFromState(stateData) {
471
  if(!stateData) return;
472
  const pages = document.querySelectorAll('.comic-page');
 
489
  }
490
 
491
  if(localStorage.getItem('comic_draft_'+sid)) document.getElementById('restore-draft-btn').style.display='inline-block';
492
+ function restoreDraft() { document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='flex'; loadNewComic().then(() => { setTimeout(() => restoreFromState(JSON.parse(localStorage.getItem('comic_draft_'+sid))), 500); }); }
493
  async function upload() {
494
  const f = document.getElementById('file-upload').files[0];
495
  const pCount = document.getElementById('page-count').value;
 
506
  try {
507
  const r = await fetch(`/status?sid=${sid}`); const d = await r.json();
508
  document.getElementById('status-text').innerText = d.message;
509
+ if(d.progress >= 100) { clearInterval(interval); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='flex'; loadNewComic(); }
510
  } catch(e) {}
511
  }
512
  async function loadNewComic() {
 
540
  });
541
  }
542
  function createHandle(cls, grid, varName) { let h = document.createElement('div'); h.className = `handle ${cls}`; h.onmousedown = (e) => { e.stopPropagation(); dragType = 'handle'; activeObj = { grid: grid, var: varName }; }; return h; }
543
+
544
  function createBubbleHTML(data) {
545
  const b = document.createElement('div');
546
  const type = data.type || 'speech';
547
+
548
+ // 🎯 FIX: REGENERATE CLASSES BASED ON TYPE, DO NOT INHERIT BLINDLY
549
+ let className = `speech-bubble ${type}`;
550
+ if(type === 'speech') className += ' tail-bottom';
551
+ else if(type === 'thought') className += ' pos-bl';
552
  b.className = className;
553
+
554
  b.dataset.type = type;
555
  b.style.left = data.left; b.style.top = data.top;
556
  if(data.width) b.style.width = data.width;
 
558
  if(data.font) b.style.fontFamily = data.font;
559
  if(data.colors) { b.style.setProperty('--bubble-fill', data.colors.fill); b.style.setProperty('--bubble-text', data.colors.text); }
560
  if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
561
+
562
  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); } }
563
+
564
  const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || 'Text'; b.appendChild(textSpan);
565
  const resizer = document.createElement('div'); resizer.className = 'resize-handle';
566
  resizer.onmousedown = (e) => { e.stopPropagation(); dragType='resize'; activeObj={b:b, startW:b.offsetWidth, startH:b.offsetHeight, mx:e.clientX, my:e.clientY}; };
 
569
  b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
570
  return b;
571
  }
572
+
573
  function editBubbleText(bubble) {
574
  const textSpan = bubble.querySelector('.bubble-text');
575
  const newText = prompt("Edit Text:", textSpan.textContent);
 
598
  }
599
  });
600
  document.addEventListener('mouseup', () => { if(activeObj && activeObj.classList) activeObj.classList.remove('panning'); if(dragType) saveState(); dragType = null; activeObj = null; });
601
+
602
+ function selectBubble(el) {
603
+ if(selectedBubble) selectedBubble.classList.remove('selected');
604
+ selectedBubble = el; el.classList.add('selected');
605
+ document.getElementById('bubble-type').value = el.dataset.type;
606
+ document.getElementById('font-select').value = el.style.fontFamily || "'Comic Neue', cursive";
607
+
608
+ // Show/Hide Tail Controls
609
+ document.getElementById('tail-controls').style.display = (el.dataset.type === 'speech' || el.dataset.type === 'thought') ? 'block' : 'none';
610
+ }
611
+
612
+ function selectPanel(el) {
613
+ if(selectedPanel) selectedPanel.classList.remove('selected');
614
+ selectedPanel = el; el.classList.add('selected');
615
+ document.getElementById('zoom-slider').disabled = false;
616
+ document.getElementById('zoom-slider').value = el.querySelector('img').dataset.zoom;
617
+ }
618
  function addBubble() { const grid = document.querySelector('.comic-grid'); if(grid) { const b = createBubbleHTML({ text: "Text", left: "50%", top: "50%" }); grid.appendChild(b); selectBubble(b); saveState(); } }
619
  function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); selectedBubble=null; saveState(); } }
620
+
621
+ // 🎯 UPDATED BUBBLE TYPE LOGIC
622
  function updateBubbleType() {
623
  if(!selectedBubble) return;
624
  const type = document.getElementById('bubble-type').value;
625
  const oldB = selectedBubble;
626
+
627
+ // Don't pass 'classes' key so createBubbleHTML generates fresh ones
628
  const data = {
629
  text: oldB.querySelector('.bubble-text').textContent,
630
  left: oldB.style.left, top: oldB.style.top, width: oldB.style.width, height: oldB.style.height,
631
  type: type, font: oldB.style.fontFamily,
632
  colors: { fill: oldB.style.getPropertyValue('--bubble-fill'), text: oldB.style.getPropertyValue('--bubble-text') },
633
+ tailPos: oldB.style.getPropertyValue('--tail-pos')
 
634
  };
635
  const newB = createBubbleHTML(data);
636
  oldB.parentElement.replaceChild(newB, oldB);
 
640
  function updateFont() { if(selectedBubble) { selectedBubble.style.fontFamily = document.getElementById('font-select').value; saveState(); } }
641
  function slideTail(val) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', val+'%'); saveState(); } }
642
 
 
643
  function rotateTail() {
644
  if(!selectedBubble) return;
645
  const type = selectedBubble.dataset.type;
 
646
  if(type === 'speech') {
647
  const positions = ['tail-bottom', 'tail-right', 'tail-top', 'tail-left'];
648
+ let current = positions.find(p => selectedBubble.classList.contains(p)) || 'tail-bottom';
 
649
  selectedBubble.classList.remove(current);
650
+ let next = positions[(positions.indexOf(current)+1)%4];
651
+ selectedBubble.classList.add(next);
 
 
 
 
 
 
652
  }
653
  saveState();
654
  }
 
679
  if(d.success) img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
680
  img.style.opacity='1'; saveState();
681
  }
 
 
 
 
 
 
 
 
 
 
 
 
682
 
683
  async function exportComic() {
684
  const pgs = document.querySelectorAll('.comic-page');
 
697
  const code = document.getElementById('load-code').value;
698
  const r = await fetch(`/load_comic/${code}`);
699
  const d = await r.json();
700
+ if(d.success) { sid=d.originalSid; localStorage.setItem('comic_sid', sid); restoreFromState(d.pages); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='flex'; }
701
  else alert(d.message);
702
  }
703
  function closeModal() { document.getElementById('save-modal').style.display='none'; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
704
  </script>
705
  </body> </html> '''
706
 
 
749
  gen = EnhancedComicGenerator(sid)
750
  return jsonify(regen_frame_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], d['direction']))
751
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
752
  @app.route('/save_comic', methods=['POST'])
753
  def save_comic():
754
  sid = request.args.get('sid')