tester343 commited on
Commit
ab47677
·
verified ·
1 Parent(s): 01543ba

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +155 -117
app_enhanced.py CHANGED
@@ -1,4 +1,4 @@
1
- import spaces # <--- CRITICAL: MUST BE THE FIRST IMPORT
2
  import os
3
  import time
4
  import threading
@@ -28,10 +28,8 @@ def gpu_warmup():
28
  # ======================================================
29
  if os.path.exists('/data'):
30
  BASE_STORAGE_PATH = '/data'
31
- print("✅ Using Persistent Storage at /data")
32
  else:
33
  BASE_STORAGE_PATH = '.'
34
- print("⚠️ Using Ephemeral Storage")
35
 
36
  BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
37
  SAVED_COMICS_DIR = os.path.join(BASE_STORAGE_PATH, "saved_comics")
@@ -40,7 +38,7 @@ os.makedirs(BASE_USER_DIR, exist_ok=True)
40
  os.makedirs(SAVED_COMICS_DIR, exist_ok=True)
41
 
42
  app = Flask(__name__)
43
- app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500MB Upload Limit
44
 
45
  def generate_save_code(length=8):
46
  chars = string.ascii_uppercase + string.digits
@@ -53,13 +51,20 @@ def generate_save_code(length=8):
53
  # 🧱 DATA CLASSES
54
  # ======================================================
55
  def bubble(dialog="", x=50, y=20, type='speech'):
 
 
 
 
 
 
 
56
  return {
57
  'dialog': dialog,
58
  'bubble_offset_x': int(x),
59
  'bubble_offset_y': int(y),
60
  'type': type,
61
  'tail_pos': '50%',
62
- 'classes': f'speech-bubble {type} tail-bottom',
63
  'colors': {'fill': '#ffffff', 'text': '#000000'},
64
  'font': "'Comic Neue', cursive"
65
  }
@@ -73,12 +78,11 @@ class Page:
73
  self.bubbles = bubbles
74
 
75
  # ======================================================
76
- # 🧠 GPU GENERATION (16:9 Aspect Ratio Preservation)
77
  # ======================================================
78
  @spaces.GPU(duration=300)
79
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
80
- print(f"🚀 Generating Comic (16:9 Aspect Ratio): {video_path}")
81
-
82
  import cv2
83
  import srt
84
  import numpy as np
@@ -91,6 +95,7 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
91
  duration = total_frames / fps
92
  cap.release()
93
 
 
94
  user_srt = os.path.join(user_dir, 'subs.srt')
95
  try:
96
  get_real_subtitles(video_path)
@@ -106,11 +111,14 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
106
  except: all_subs = []
107
 
108
  valid_subs = [s for s in all_subs if s.content.strip()]
109
- raw_moments = [{'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
 
 
 
110
 
111
- if target_pages <= 0: target_pages = 1
112
  panels_per_page = 4
113
- total_panels_needed = target_pages * panels_per_page
114
 
115
  selected_moments = []
116
  if not raw_moments:
@@ -129,20 +137,22 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
129
 
130
  for i, moment in enumerate(selected_moments):
131
  mid = (moment['start'] + moment['end']) / 2
132
- if mid > duration: mid = duration - 1
133
- cap.set(cv2.CAP_PROP_POS_FRAMES, int(mid * fps))
134
  ret, frame = cap.read()
135
  if ret:
136
- # ----------------------------------------------------
137
- # 🎯 EXTRACT AT 1280x720 (16:9 Aspect Ratio)
138
- # This preserves full width and height.
139
- # Frontend handles fitting into square panels.
140
- # ----------------------------------------------------
141
- frame = cv2.resize(frame, (1280, 720))
 
 
 
142
 
143
  fname = f"frame_{count:04d}.png"
144
  p = os.path.join(frames_dir, fname)
145
- cv2.imwrite(p, frame)
146
 
147
  frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
148
  frame_files_ordered.append(fname)
@@ -153,26 +163,33 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
153
 
154
  bubbles_list = []
155
  for i, f in enumerate(frame_files_ordered):
156
- p = os.path.join(frames_dir, f)
157
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
158
 
 
159
  b_type = 'speech'
160
- if '(' in dialogue and ')' in dialogue: b_type = 'narration'
161
- elif '!' in dialogue and dialogue.isupper(): b_type = 'reaction'
162
  elif '?' in dialogue: b_type = 'speech'
163
 
164
- # Position bubbles centrally in panels
165
- pos_idx = i % 4 # Determine which of the 4 panels this frame is for
166
- if pos_idx == 0: bx, by = 150, 80 # Top-Left
167
- elif pos_idx == 1: bx, by = 600, 80 # Top-Right
168
- elif pos_idx == 2: bx, by = 150, 600 # Bottom-Left
169
- elif pos_idx == 3: bx, by = 600, 600 # Bottom-Right
170
- else: bx, by = 50, 50 # Default
171
-
 
 
 
 
 
 
 
172
  bubbles_list.append(bubble(dialog=dialogue, x=bx, y=by, type=b_type))
173
 
174
  pages = []
175
- for i in range(target_pages):
176
  start_idx = i * 4
177
  end_idx = start_idx + 4
178
  p_frames = frame_files_ordered[start_idx:end_idx]
@@ -183,11 +200,13 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
183
  img = np.zeros((1024, 1024, 3), dtype=np.uint8); img[:] = (30,30,30)
184
  cv2.imwrite(os.path.join(frames_dir, fname), img)
185
  p_frames.append(fname)
186
- p_bubbles.append(bubble(dialog="", type='speech'))
 
187
 
188
  if p_frames:
 
189
  pg_panels = [panel(image=f) for f in p_frames]
190
- pages.append({'panels': pg_panels, 'bubbles': p_bubbles})
191
 
192
  result = []
193
  for pg in pages:
@@ -215,15 +234,48 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
215
  cap.release()
216
 
217
  if ret:
218
- frame = cv2.resize(frame, (1280, 720)) # Keep 16:9 Aspect Ratio
219
- p = os.path.join(frames_dir, fname)
220
- cv2.imwrite(p, frame)
 
 
 
 
221
 
 
222
  if isinstance(meta[fname], dict): meta[fname]['time'] = new_t
223
  else: meta[fname] = new_t
224
  with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
225
  return {"success": True, "message": f"Time: {new_t:.2f}s"}
226
- return {"success": False, "message": "End of video"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
 
228
  # ======================================================
229
  # 💻 BACKEND CLASS
@@ -308,28 +360,31 @@ INDEX_HTML = '''
308
  .panel img.panning { cursor: grabbing; transition: none; }
309
  .panel.selected { outline: 4px solid #3498db; z-index: 5; }
310
 
311
- /* === CLIP PATHS === */
312
  .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; }
313
  .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; }
314
  .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; }
315
  .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; }
316
 
317
- /* === HANDLES === */
318
  .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); }
319
- .handle:hover { transform: translate(-50%, -50%) scale(1.3); }
320
  .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; }
321
  .h-t2 { background: #3498db; left: var(--t2); top: 50%; margin-top: -15px; }
322
  .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; }
323
  .h-b2 { background: #2ecc71; left: var(--b2); top: 100%; margin-top: -15px; }
324
 
325
- /* SPEECH BUBBLES */
326
  .speech-bubble {
327
  position: absolute; display: flex; justify-content: center; align-items: center;
328
  min-width: 60px; min-height: 40px; box-sizing: border-box;
329
  z-index: 10; cursor: move; font-weight: bold; text-align: center;
330
  overflow: visible; line-height: 1.2; --tail-pos: 50%;
331
  }
332
- .bubble-text { padding: 0.8em; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; white-space: pre-wrap; pointer-events: none; border-radius: inherit; }
 
 
 
 
333
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
334
 
335
  /* SPEECH */
@@ -381,45 +436,40 @@ INDEX_HTML = '''
381
  button, input, select { width: 100%; margin-top: 5px; padding: 8px; border-radius: 4px; border: 1px solid #ddd; cursor: pointer; font-weight: bold; font-size: 13px; }
382
  .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
383
  .color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
384
- .action-btn { background: #4CAF50; color: white; }
385
- .reset-btn { background: #e74c3c; color: white; }
386
  .secondary-btn { background: #f39c12; color: white; }
387
- .save-btn { background: #9b59b6; color: white; }
388
- .undo-btn { background: #7f8c8d; color: white; margin-bottom: 5px; }
389
 
390
- /* MODAL */
391
- .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; }
392
- .modal-content { background: white; padding: 30px; border-radius: 12px; max-width: 400px; width: 90%; text-align: center; }
393
- .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; }
394
  </style>
395
  </head> <body>
396
 
397
  <div id="upload-container">
398
  <div class="upload-box">
399
- <h1>⚡ Enhanced Comic Generator</h1>
400
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
401
- <label for="file-upload" class="file-label">📁 Choose Video File</label>
402
- <span id="fn" style="margin-bottom:10px; display:block; color:#666;">No file selected</span>
403
 
404
  <div class="page-input-group">
405
- <label>📚 Total Comic Pages:</label>
406
- <input type="number" id="page-count" value="4" min="1" max="15" placeholder="e.g. 4 (Video will be divided evenly)">
407
- <small style="color:#666; font-size:11px; display:block; margin-top:5px;">System calculates ~4 panels per page.</small>
408
  </div>
409
 
410
- <button class="submit-btn" onclick="upload()">🚀 Generate Comic</button>
411
- <button id="restore-draft-btn" class="restore-btn" style="display:none; margin-top:15px;" onclick="restoreDraft()">📂 Restore Unsaved Draft</button>
412
 
413
- <div class="load-section">
414
- <h3>📥 Load Saved Comic</h3>
415
- <div class="load-input-group">
416
- <input type="text" id="load-code-input" placeholder="SAVE CODE" maxlength="8" style="text-transform:uppercase;">
417
- <button onclick="loadSavedComic()">Load</button>
418
- </div>
419
  </div>
420
  <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
421
- <div class="loader" style="margin:0 auto;"></div>
422
- <p id="status-text" style="margin-top:10px;">Starting...</p>
423
  </div>
424
  </div>
425
  </div>
@@ -430,93 +480,76 @@ INDEX_HTML = '''
430
  <input type="file" id="image-uploader" style="display: none;" accept="image/*">
431
 
432
  <div class="edit-controls">
433
- <h4>✏️ Interactive Editor</h4>
434
 
435
  <div class="control-group">
436
- <button onclick="undoLastAction()" class="undo-btn">↩️ Undo</button>
437
  <button onclick="saveComic()" class="save-btn">💾 Save Comic</button>
438
  </div>
439
-
440
  <div class="control-group">
441
  <label>💬 Bubble Styling:</label>
442
- <select id="bubble-type" onchange="changeBubbleType(this.value)" disabled>
443
- <option value="speech">Speech</option>
444
- <option value="thought">Thought</option>
445
- <option value="reaction">Reaction (Shout)</option>
446
- <option value="narration">Narration (Box)</option>
447
  </select>
448
- <select id="font-select" onchange="changeFont(this.value)" disabled>
449
  <option value="'Comic Neue', cursive">Comic Neue</option>
450
  <option value="'Bangers', cursive">Bangers</option>
451
- <option value="'Gloria Hallelujah', cursive">Gloria</option>
452
- <option value="'Lato', sans-serif">Lato</option>
453
  </select>
454
  <div class="color-grid">
455
- <div><label>Text</label><input type="color" id="bubble-text-color" value="#ffffff" disabled></div>
456
- <div><label>Fill</label><input type="color" id="bubble-fill-color" value="#4ECDC4" disabled></div>
457
  </div>
458
  <div class="button-grid">
459
  <button onclick="addBubble()" class="action-btn">Add</button>
460
  <button onclick="deleteBubble()" class="reset-btn">Delete</button>
461
  </div>
462
-
463
- <div class="control-group" id="tail-controls" style="display:none;">
464
- <label>📐 Tail Adjustment:</label>
465
- <button onclick="rotateTail()" class="secondary-btn">🔄 Rotate Side</button>
466
- <div class="slider-container">
467
- <label>Pos:</label>
468
- <input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)">
469
- </div>
470
- </div>
471
  </div>
472
-
473
  <div class="control-group">
474
- <label>🖼️ Panel Tools:</label>
475
- <button onclick="replacePanelImage()" class="action-btn">🖼️ Replace Image</button>
476
  <div class="button-grid">
477
- <button onclick="adjustFrame('backward')" class="secondary-btn">⬅️ Prev</button>
478
- <button onclick="adjustFrame('forward')" class="action-btn">Next ➡️</button>
479
- </div>
480
- <div class="timestamp-controls">
481
- <input type="text" id="timestamp-input" placeholder="mm:ss or secs">
482
- <button onclick="gotoTimestamp()" class="action-btn">Go</button>
483
  </div>
484
  </div>
485
 
486
  <div class="control-group">
487
- <label>🔍 Zoom & Pan:</label>
488
- <div class="button-grid">
489
- <button onclick="resetPanelTransform()" class="secondary-btn">Reset</button>
490
- <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled oninput="handleZoom(this)">
491
- </div>
492
  </div>
493
 
494
  <div class="control-group">
495
- <button onclick="exportComic()" class="export-btn">📥 Export as PNG</button>
496
  <button onclick="location.reload()" class="reset-btn" style="margin-top:10px;">🏠 Home</button>
497
  </div>
498
  </div>
499
  </div>
 
500
  <div class="modal-overlay" id="save-modal">
501
  <div class="modal-content">
502
  <h2>✅ Comic Saved!</h2>
503
- <div class="code" id="modal-save-code">XXXX</div>
504
- <button onclick="copyModalCode()">📋 Copy Code</button>
505
- <button class="close-btn" onclick="closeModal()">Close</button>
506
  </div>
507
  </div>
 
508
  <script>
509
  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);}); }
510
  let sid = localStorage.getItem('comic_sid') || genUUID();
511
  localStorage.setItem('comic_sid', sid);
512
-
513
- let currentSaveCode = null;
514
- let isProcessing = false;
515
  let interval, selectedBubble = null, selectedPanel = null;
516
  let dragType = null, activeObj = null, dragStart = {x:0, y:0};
517
  let historyStack = [];
518
 
519
- // HISTORY & UNDO
520
  function saveState() {
521
  const state = [];
522
  document.querySelectorAll('.comic-page').forEach(pg => {
@@ -528,9 +561,9 @@ INDEX_HTML = '''
528
  text: b.querySelector('.bubble-text').textContent,
529
  left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
530
  type: b.dataset.type, font: b.style.fontFamily,
531
- classes: b.className, // Save the full class list
532
  tailPos: b.style.getPropertyValue('--tail-pos'),
533
- colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') }
534
  });
535
  });
536
  const panels = [];
@@ -614,6 +647,7 @@ INDEX_HTML = '''
614
  const data = await r.json();
615
  const cleanData = data.map(p => ({
616
  panels: p.panels.map(pan => ({ src: `/frames/${pan.image}?sid=${sid}` })),
 
617
  bubbles: p.bubbles.map(b => ({
618
  text: b.dialog, left: (b.bubble_offset_x||50)+'px', top: (b.bubble_offset_y||20)+'px', type: b.type,
619
  colors: b.colors, font: b.font, classes: b.classes, tailPos: b.tail_pos
@@ -645,6 +679,7 @@ INDEX_HTML = '''
645
 
646
  if(page.bubbles) {
647
  page.bubbles.forEach((bData, bIdx) => {
 
648
  if(bData.text) {
649
  const b = createBubbleHTML(bData);
650
  grid.appendChild(b);
@@ -667,8 +702,8 @@ INDEX_HTML = '''
667
  const type = data.type || 'speech';
668
  let className = data.classes || `speech-bubble ${type} tail-bottom`;
669
  if (type === 'thought' && !className.includes('pos-')) className += ' pos-bl';
670
-
671
  b.className = className;
 
672
  b.dataset.type = type;
673
  b.style.left = data.left; b.style.top = data.top;
674
  if(data.width) b.style.width = data.width;
@@ -679,9 +714,12 @@ INDEX_HTML = '''
679
 
680
  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); } }
681
 
682
- ['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); });
 
 
 
683
 
684
- b.onmousedown = (e) => { if(e.target.classList.contains('resize-handle')) return; e.stopPropagation(); selectBubble(b); dragType = 'bubble'; activeObj = b; dragStart = {x: e.clientX, y: e.clientY}; };
685
  b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
686
  return b;
687
  }
@@ -819,7 +857,7 @@ INDEX_HTML = '''
819
  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%' };
820
  const bubbles = [];
821
  grid.querySelectorAll('.speech-bubble').forEach(b => {
822
- 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 });
823
  });
824
  const panels = [];
825
  grid.querySelectorAll('.panel').forEach(pan => {
 
1
+ import spaces
2
  import os
3
  import time
4
  import threading
 
28
  # ======================================================
29
  if os.path.exists('/data'):
30
  BASE_STORAGE_PATH = '/data'
 
31
  else:
32
  BASE_STORAGE_PATH = '.'
 
33
 
34
  BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
35
  SAVED_COMICS_DIR = os.path.join(BASE_STORAGE_PATH, "saved_comics")
 
38
  os.makedirs(SAVED_COMICS_DIR, exist_ok=True)
39
 
40
  app = Flask(__name__)
41
+ app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024
42
 
43
  def generate_save_code(length=8):
44
  chars = string.ascii_uppercase + string.digits
 
51
  # 🧱 DATA CLASSES
52
  # ======================================================
53
  def bubble(dialog="", x=50, y=20, type='speech'):
54
+ # Determine CSS classes based on type (Logic from safwe.py)
55
+ classes = f"speech-bubble {type}"
56
+ if type == 'speech':
57
+ classes += " tail-bottom"
58
+ elif type == 'thought':
59
+ classes += " pos-bl"
60
+
61
  return {
62
  'dialog': dialog,
63
  'bubble_offset_x': int(x),
64
  'bubble_offset_y': int(y),
65
  'type': type,
66
  'tail_pos': '50%',
67
+ 'classes': classes,
68
  'colors': {'fill': '#ffffff', 'text': '#000000'},
69
  'font': "'Comic Neue', cursive"
70
  }
 
78
  self.bubbles = bubbles
79
 
80
  # ======================================================
81
+ # 🧠 GPU GENERATION
82
  # ======================================================
83
  @spaces.GPU(duration=300)
84
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
85
+ print(f"🚀 Generating HD Comic: {video_path}")
 
86
  import cv2
87
  import srt
88
  import numpy as np
 
95
  duration = total_frames / fps
96
  cap.release()
97
 
98
+ # Subtitles
99
  user_srt = os.path.join(user_dir, 'subs.srt')
100
  try:
101
  get_real_subtitles(video_path)
 
111
  except: all_subs = []
112
 
113
  valid_subs = [s for s in all_subs if s.content.strip()]
114
+ if valid_subs:
115
+ raw_moments = [{'text': s.content.strip(), 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
116
+ else:
117
+ raw_moments = []
118
 
119
+ # 4 Panels Per Page
120
  panels_per_page = 4
121
+ total_panels_needed = int(target_pages) * panels_per_page
122
 
123
  selected_moments = []
124
  if not raw_moments:
 
137
 
138
  for i, moment in enumerate(selected_moments):
139
  mid = (moment['start'] + moment['end']) / 2
140
+ cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
 
141
  ret, frame = cap.read()
142
  if ret:
143
+ # 🎯 SQUARE PADDING (0% Cut)
144
+ h, w = frame.shape[:2]
145
+ sq_dim = max(h, w)
146
+ square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
147
+ x_off = (sq_dim - w) // 2
148
+ y_off = (sq_dim - h) // 2
149
+ square_img[y_off:y_off+h, x_off:x_off+w] = frame
150
+ # Resize to standard high res
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)
 
163
 
164
  bubbles_list = []
165
  for i, f in enumerate(frame_files_ordered):
 
166
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
167
 
168
+ # Determine Bubble Type
169
  b_type = 'speech'
170
+ if '(' in dialogue: b_type = 'narration'
171
+ elif '!' in dialogue: b_type = 'reaction'
172
  elif '?' in dialogue: b_type = 'speech'
173
 
174
+ # 🎯 1 BUBBLE PER PANEL PLACEMENT (for 800x800 page)
175
+ # Quadrants: TL, TR, BL, BR
176
+ # Panel size approx 400x400
177
+ pos_idx = i % 4
178
+ if pos_idx == 0: # TL
179
+ bx, by = 150, 50
180
+ elif pos_idx == 1: # TR
181
+ bx, by = 550, 50
182
+ elif pos_idx == 2: # BL
183
+ bx, by = 150, 450
184
+ elif pos_idx == 3: # BR
185
+ bx, by = 550, 450
186
+ else:
187
+ bx, by = 50, 50
188
+
189
  bubbles_list.append(bubble(dialog=dialogue, x=bx, y=by, type=b_type))
190
 
191
  pages = []
192
+ for i in range(int(target_pages)):
193
  start_idx = i * 4
194
  end_idx = start_idx + 4
195
  p_frames = frame_files_ordered[start_idx:end_idx]
 
200
  img = np.zeros((1024, 1024, 3), dtype=np.uint8); img[:] = (30,30,30)
201
  cv2.imwrite(os.path.join(frames_dir, fname), img)
202
  p_frames.append(fname)
203
+ # Add hidden empty bubble
204
+ p_bubbles.append(bubble(dialog="", x=-999, y=-999, type='speech'))
205
 
206
  if p_frames:
207
+ # FIX: Use Page Class to avoid AttributeError
208
  pg_panels = [panel(image=f) for f in p_frames]
209
+ pages.append(Page(panels=pg_panels, bubbles=p_bubbles))
210
 
211
  result = []
212
  for pg in pages:
 
234
  cap.release()
235
 
236
  if ret:
237
+ h, w = frame.shape[:2]
238
+ sq_dim = max(h, w)
239
+ square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
240
+ x_off = (sq_dim - w) // 2
241
+ y_off = (sq_dim - h) // 2
242
+ square_img[y_off:y_off+h, x_off:x_off+w] = frame
243
+ square_img = cv2.resize(square_img, (1024, 1024))
244
 
245
+ cv2.imwrite(os.path.join(frames_dir, fname), square_img)
246
  if isinstance(meta[fname], dict): meta[fname]['time'] = new_t
247
  else: meta[fname] = new_t
248
  with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
249
  return {"success": True, "message": f"Time: {new_t:.2f}s"}
250
+ return {"success": False}
251
+
252
+ @spaces.GPU
253
+ def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
254
+ import cv2
255
+ import json
256
+ cap = cv2.VideoCapture(video_path)
257
+ cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
258
+ ret, frame = cap.read()
259
+ cap.release()
260
+
261
+ if ret:
262
+ h, w = frame.shape[:2]
263
+ sq_dim = max(h, w)
264
+ square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
265
+ x_off = (sq_dim - w) // 2
266
+ y_off = (sq_dim - h) // 2
267
+ square_img[y_off:y_off+h, x_off:x_off+w] = frame
268
+ square_img = cv2.resize(square_img, (1024, 1024))
269
+
270
+ cv2.imwrite(os.path.join(frames_dir, fname), square_img)
271
+ if os.path.exists(metadata_path):
272
+ with open(metadata_path, 'r') as f: meta = json.load(f)
273
+ if fname in meta:
274
+ if isinstance(meta[fname], dict): meta[fname]['time'] = float(ts)
275
+ else: meta[fname] = float(ts)
276
+ with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
277
+ return {"success": True, "message": f"Jumped to {ts}s"}
278
+ return {"success": False, "message": "Invalid timestamp"}
279
 
280
  # ======================================================
281
  # 💻 BACKEND CLASS
 
360
  .panel img.panning { cursor: grabbing; transition: none; }
361
  .panel.selected { outline: 4px solid #3498db; z-index: 5; }
362
 
363
+ /* Clip Paths */
364
  .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; }
365
  .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; }
366
  .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; }
367
  .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; }
368
 
369
+ /* Handles */
370
  .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); }
 
371
  .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; }
372
  .h-t2 { background: #3498db; left: var(--t2); top: 50%; margin-top: -15px; }
373
  .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; }
374
  .h-b2 { background: #2ecc71; left: var(--b2); top: 100%; margin-top: -15px; }
375
 
376
+ /* SPEECH BUBBLES - STYLED */
377
  .speech-bubble {
378
  position: absolute; display: flex; justify-content: center; align-items: center;
379
  min-width: 60px; min-height: 40px; box-sizing: border-box;
380
  z-index: 10; cursor: move; font-weight: bold; text-align: center;
381
  overflow: visible; line-height: 1.2; --tail-pos: 50%;
382
  }
383
+ .bubble-text {
384
+ padding: 0.8em; word-wrap: break-word; white-space: pre-wrap;
385
+ width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
386
+ border-radius: inherit; pointer-events: none;
387
+ }
388
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
389
 
390
  /* SPEECH */
 
436
  button, input, select { width: 100%; margin-top: 5px; padding: 8px; border-radius: 4px; border: 1px solid #ddd; cursor: pointer; font-weight: bold; font-size: 13px; }
437
  .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
438
  .color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
439
+ .action-btn { background: #27ae60; color: white; }
440
+ .reset-btn { background: #c0392b; color: white; }
441
  .secondary-btn { background: #f39c12; color: white; }
442
+ .save-btn { background: #8e44ad; color: white; }
 
443
 
444
+ .tip { text-align:center; padding:10px; background:#e74c3c; color:white; font-weight:bold; margin-bottom:20px; border-radius:5px; }
445
+ .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; }
446
+ .modal-content { background: white; padding: 30px; border-radius: 12px; width: 90%; max-width: 400px; text-align: center; color: #333; }
447
+ .code { font-size: 24px; font-weight: bold; letter-spacing: 3px; background: #eee; padding: 10px; margin: 15px 0; display: inline-block; font-family: monospace; }
448
  </style>
449
  </head> <body>
450
 
451
  <div id="upload-container">
452
  <div class="upload-box">
453
+ <h1>⚡ Ultimate Square Comic</h1>
454
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
455
+ <label for="file-upload" class="file-label">📁 Choose Video</label>
456
+ <span id="fn" style="margin-bottom:10px; display:block; color:#aaa;">No file selected</span>
457
 
458
  <div class="page-input-group">
459
+ <label>📚 Total Pages:</label>
460
+ <input type="number" id="page-count" value="4" min="1" max="15">
 
461
  </div>
462
 
463
+ <button class="submit-btn" onclick="upload()">🚀 Generate</button>
464
+ <button id="restore-draft-btn" class="reset-btn" style="display:none; margin-top:10px;" onclick="restoreDraft()">📂 Restore Draft</button>
465
 
466
+ <div style="margin-top:20px; border-top:1px solid #555; padding-top:10px;">
467
+ <input type="text" id="load-code" placeholder="ENTER SAVE CODE" style="width:70%; display:inline-block;">
468
+ <button onclick="loadComic()" style="width:25%; display:inline-block; background:#9b59b6; color:white;">Load</button>
 
 
 
469
  </div>
470
  <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
471
+ <div class="loader"></div>
472
+ <p id="status-text" style="margin-top:10px;">Analyzing Video...</p>
473
  </div>
474
  </div>
475
  </div>
 
480
  <input type="file" id="image-uploader" style="display: none;" accept="image/*">
481
 
482
  <div class="edit-controls">
483
+ <h4>✏️ Editor</h4>
484
 
485
  <div class="control-group">
486
+ <button onclick="undo()" style="background:#7f8c8d; color:white;">↩️ Undo</button>
487
  <button onclick="saveComic()" class="save-btn">💾 Save Comic</button>
488
  </div>
489
+
490
  <div class="control-group">
491
  <label>💬 Bubble Styling:</label>
492
+ <select id="bubble-type" onchange="updateBubbleType()">
493
+ <option value="speech">Speech 💬</option>
494
+ <option value="thought">Thought 💭</option>
495
+ <option value="reaction">Reaction 💥</option>
496
+ <option value="narration">Narration ⬜</option>
497
  </select>
498
+ <select id="font-select" onchange="updateFont()">
499
  <option value="'Comic Neue', cursive">Comic Neue</option>
500
  <option value="'Bangers', cursive">Bangers</option>
501
+ <option value="'Gloria Hallelujah', cursive">Handwritten</option>
502
+ <option value="'Lato', sans-serif">Modern</option>
503
  </select>
504
  <div class="color-grid">
505
+ <input type="color" id="bub-fill" value="#ffffff" onchange="updateColors()" title="Fill">
506
+ <input type="color" id="bub-text" value="#000000" onchange="updateColors()" title="Text">
507
  </div>
508
  <div class="button-grid">
509
  <button onclick="addBubble()" class="action-btn">Add</button>
510
  <button onclick="deleteBubble()" class="reset-btn">Delete</button>
511
  </div>
512
+ <input type="range" min="10" max="90" value="50" oninput="slideTail(this.value)" title="Tail Pos">
 
 
 
 
 
 
 
 
513
  </div>
514
+
515
  <div class="control-group">
516
+ <label>🖼️ Image Control:</label>
517
+ <button onclick="replaceImage()" class="action-btn">Replace Image</button>
518
  <div class="button-grid">
519
+ <button onclick="adjustFrame('backward')" class="secondary-btn">⬅️ Frame</button>
520
+ <button onclick="adjustFrame('forward')" class="action-btn">Frame ➡️</button>
 
 
 
 
521
  </div>
522
  </div>
523
 
524
  <div class="control-group">
525
+ <label>🔍 Zoom (Scroll Wheel):</label>
526
+ <input type="range" id="zoom-slider" min="20" max="300" value="100" step="5" oninput="handleZoom(this.value)" disabled>
527
+ <button onclick="resetPanelTransform()" class="secondary-btn">Reset View</button>
 
 
528
  </div>
529
 
530
  <div class="control-group">
531
+ <button onclick="exportComic()" class="action-btn" style="background:#3498db;">📥 Export PNG</button>
532
  <button onclick="location.reload()" class="reset-btn" style="margin-top:10px;">🏠 Home</button>
533
  </div>
534
  </div>
535
  </div>
536
+
537
  <div class="modal-overlay" id="save-modal">
538
  <div class="modal-content">
539
  <h2>✅ Comic Saved!</h2>
540
+ <div class="code" id="modal-code">XXXX</div>
541
+ <button onclick="closeModal()">Close</button>
 
542
  </div>
543
  </div>
544
+
545
  <script>
546
  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);}); }
547
  let sid = localStorage.getItem('comic_sid') || genUUID();
548
  localStorage.setItem('comic_sid', sid);
 
 
 
549
  let interval, selectedBubble = null, selectedPanel = null;
550
  let dragType = null, activeObj = null, dragStart = {x:0, y:0};
551
  let historyStack = [];
552
 
 
553
  function saveState() {
554
  const state = [];
555
  document.querySelectorAll('.comic-page').forEach(pg => {
 
561
  text: b.querySelector('.bubble-text').textContent,
562
  left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
563
  type: b.dataset.type, font: b.style.fontFamily,
564
+ colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') },
565
  tailPos: b.style.getPropertyValue('--tail-pos'),
566
+ classes: b.className
567
  });
568
  });
569
  const panels = [];
 
647
  const data = await r.json();
648
  const cleanData = data.map(p => ({
649
  panels: p.panels.map(pan => ({ src: `/frames/${pan.image}?sid=${sid}` })),
650
+ // Map backend bubbles to strict list structure to avoid grouping
651
  bubbles: p.bubbles.map(b => ({
652
  text: b.dialog, left: (b.bubble_offset_x||50)+'px', top: (b.bubble_offset_y||20)+'px', type: b.type,
653
  colors: b.colors, font: b.font, classes: b.classes, tailPos: b.tail_pos
 
679
 
680
  if(page.bubbles) {
681
  page.bubbles.forEach((bData, bIdx) => {
682
+ // Only append valid bubbles, ignore dummies
683
  if(bData.text) {
684
  const b = createBubbleHTML(bData);
685
  grid.appendChild(b);
 
702
  const type = data.type || 'speech';
703
  let className = data.classes || `speech-bubble ${type} tail-bottom`;
704
  if (type === 'thought' && !className.includes('pos-')) className += ' pos-bl';
 
705
  b.className = className;
706
+
707
  b.dataset.type = type;
708
  b.style.left = data.left; b.style.top = data.top;
709
  if(data.width) b.style.width = data.width;
 
714
 
715
  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); } }
716
 
717
+ const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || 'Text'; b.appendChild(textSpan);
718
+ const resizer = document.createElement('div'); resizer.className = 'resize-handle';
719
+ resizer.onmousedown = (e) => { e.stopPropagation(); dragType='resize'; activeObj={b:b, startW:b.offsetWidth, startH:b.offsetHeight, mx:e.clientX, my:e.clientY}; };
720
+ b.appendChild(resizer);
721
 
722
+ b.onmousedown = (e) => { if(e.target === resizer) return; e.stopPropagation(); selectBubble(b); dragType = 'bubble'; activeObj = b; dragStart = {x: e.clientX, y: e.clientY}; };
723
  b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
724
  return b;
725
  }
 
857
  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%' };
858
  const bubbles = [];
859
  grid.querySelectorAll('.speech-bubble').forEach(b => {
860
+ 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') });
861
  });
862
  const panels = [];
863
  grid.querySelectorAll('.panel').forEach(pan => {