tester343 commited on
Commit
a15df70
·
verified ·
1 Parent(s): 4507661

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +133 -133
app_enhanced.py CHANGED
@@ -39,11 +39,8 @@ SAVED_COMICS_DIR = os.path.join(BASE_STORAGE_PATH, "saved_comics")
39
  os.makedirs(BASE_USER_DIR, exist_ok=True)
40
  os.makedirs(SAVED_COMICS_DIR, exist_ok=True)
41
 
42
- # ======================================================
43
- # 🔧 APP CONFIG
44
- # ======================================================
45
  app = Flask(__name__)
46
- app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500MB Limit
47
 
48
  def generate_save_code(length=8):
49
  chars = string.ascii_uppercase + string.digits
@@ -53,19 +50,23 @@ def generate_save_code(length=8):
53
  return code
54
 
55
  # ======================================================
56
- # 🧱 DATA CLASSES (Restored from safwe.py)
57
  # ======================================================
58
- def bubble(dialog="", bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, emotion='normal', type='speech'):
 
 
 
 
 
 
 
59
  return {
60
  'dialog': dialog,
61
- 'bubble_offset_x': int(bubble_offset_x),
62
- 'bubble_offset_y': int(bubble_offset_y),
63
- 'lip_x': int(lip_x),
64
- 'lip_y': int(lip_y),
65
- 'emotion': emotion,
66
  'type': type,
67
  'tail_pos': '50%',
68
- 'classes': f'speech-bubble {type} tail-bottom',
69
  'colors': {'fill': '#ffffff', 'text': '#000000'},
70
  'font': "'Comic Neue', cursive"
71
  }
@@ -79,18 +80,16 @@ class Page:
79
  self.bubbles = bubbles
80
 
81
  # ======================================================
82
- # 🧠 GPU GENERATION (SQUARE PADDING LOGIC)
83
  # ======================================================
84
  @spaces.GPU(duration=300)
85
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
86
- print(f"🚀 Generating Square Comic: {video_path}")
87
-
88
  import cv2
89
  import srt
90
  import numpy as np
91
  from backend.subtitles.subs_real import get_real_subtitles
92
 
93
- # 1. Video Setup
94
  cap = cv2.VideoCapture(video_path)
95
  if not cap.isOpened(): raise Exception("Cannot open video")
96
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
@@ -98,7 +97,7 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
98
  duration = total_frames / fps
99
  cap.release()
100
 
101
- # 2. SUBTITLES
102
  user_srt = os.path.join(user_dir, 'subs.srt')
103
  try:
104
  get_real_subtitles(video_path)
@@ -121,27 +120,22 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
121
  if valid_subs:
122
  raw_moments = [{'text': s.content.strip(), 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
123
  else:
124
- # Fallback moments if no speech
125
  raw_moments = []
126
 
127
- # 3. Frame Selection (4 per page)
128
  panels_per_page = 4
129
  total_panels_needed = int(target_pages) * panels_per_page
130
 
131
  selected_moments = []
132
  if not raw_moments:
133
- # Generate time-based moments
134
  times = np.linspace(1, max(1, duration-1), total_panels_needed)
135
- for t in times:
136
- selected_moments.append({'text': '', 'start': t, 'end': t+1})
137
  elif len(raw_moments) <= total_panels_needed:
138
  selected_moments = raw_moments
139
  else:
140
- # Subsample moments
141
  indices = np.linspace(0, len(raw_moments) - 1, total_panels_needed, dtype=int)
142
  selected_moments = [raw_moments[i] for i in indices]
143
 
144
- # 4. Extract & PAD TO SQUARE
145
  frame_metadata = {}
146
  cap = cv2.VideoCapture(video_path)
147
  count = 0
@@ -151,25 +145,17 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
151
  mid = (moment['start'] + moment['end']) / 2
152
  cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
153
  ret, frame = cap.read()
154
-
155
  if ret:
156
- # ----------------------------------------------------
157
- # 🎯 SQUARE PADDING LOGIC (0% Cut)
158
- # ----------------------------------------------------
159
  h, w = frame.shape[:2]
160
  sq_dim = max(h, w)
161
-
162
- # Create black square canvas
163
  square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
164
-
165
- # Calculate centering offsets
166
  x_off = (sq_dim - w) // 2
167
  y_off = (sq_dim - h) // 2
168
-
169
- # Paste original frame in center
170
  square_img[y_off:y_off+h, x_off:x_off+w] = frame
 
 
171
 
172
- # Save
173
  fname = f"frame_{count:04d}.png"
174
  p = os.path.join(frames_dir, fname)
175
  cv2.imwrite(p, square_img)
@@ -177,23 +163,23 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
177
  frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
178
  frame_files_ordered.append(fname)
179
  count += 1
180
-
181
  cap.release()
182
- with open(metadata_path, 'w') as f:
183
- json.dump(frame_metadata, f, indent=2)
184
 
185
- # 5. Bubbles
186
  bubbles_list = []
187
  for f in frame_files_ordered:
188
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
 
 
189
  b_type = 'speech'
190
  if '(' in dialogue: b_type = 'narration'
191
- elif '!' in dialogue: b_type = 'reaction'
 
192
 
193
- # Use full bubble function structure
194
- bubbles_list.append(bubble(dialog=dialogue, bubble_offset_x=50, bubble_offset_y=20, type=b_type))
195
 
196
- # 6. Pages
197
  pages = []
198
  for i in range(int(target_pages)):
199
  start_idx = i * 4
@@ -203,33 +189,23 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
203
 
204
  while len(p_frames) < 4:
205
  fname = f"empty_{i}_{len(p_frames)}.png"
206
- img = np.zeros((800, 800, 3), dtype=np.uint8)
207
- img[:] = (30,30,30)
208
  cv2.imwrite(os.path.join(frames_dir, fname), img)
209
  p_frames.append(fname)
210
  p_bubbles.append(bubble(dialog="", type='speech'))
211
 
212
  if p_frames:
213
- pg_panels = [panel(image=f) for f in p_frames]
214
- pages.append(Page(panels=pg_panels, bubbles=p_bubbles))
215
-
216
- # 7. Convert to Dict
217
- result = []
218
- for pg in pages:
219
- p_data = [p if isinstance(p, dict) else p.__dict__ for p in pg.panels]
220
- b_data = [b if isinstance(b, dict) else b.__dict__ for b in pg.bubbles]
221
- result.append({'panels': p_data, 'bubbles': b_data})
222
-
223
- return result
224
 
225
  @spaces.GPU
226
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
227
  import cv2
228
  import json
229
- if not os.path.exists(metadata_path):
230
- return {"success": False, "message": "No metadata"}
231
- with open(metadata_path, 'r') as f:
232
- meta = json.load(f)
233
 
234
  t = meta[fname]['time'] if isinstance(meta[fname], dict) else meta[fname]
235
  cap = cv2.VideoCapture(video_path)
@@ -242,25 +218,20 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
242
  cap.release()
243
 
244
  if ret:
245
- # Re-apply Square Padding
246
  h, w = frame.shape[:2]
247
  sq_dim = max(h, w)
248
  square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
249
  x_off = (sq_dim - w) // 2
250
  y_off = (sq_dim - h) // 2
251
  square_img[y_off:y_off+h, x_off:x_off+w] = frame
 
252
 
253
- p = os.path.join(frames_dir, fname)
254
- cv2.imwrite(p, square_img)
255
-
256
- if isinstance(meta[fname], dict):
257
- meta[fname]['time'] = new_t
258
- else:
259
- meta[fname] = new_t
260
- with open(metadata_path, 'w') as f:
261
- json.dump(meta, f, indent=2)
262
  return {"success": True, "message": f"Time: {new_t:.2f}s"}
263
- return {"success": False, "message": "End of video"}
264
 
265
  @spaces.GPU
266
  def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
@@ -278,19 +249,15 @@ def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
278
  x_off = (sq_dim - w) // 2
279
  y_off = (sq_dim - h) // 2
280
  square_img[y_off:y_off+h, x_off:x_off+w] = frame
 
281
 
282
- p = os.path.join(frames_dir, fname)
283
- cv2.imwrite(p, square_img)
284
  if os.path.exists(metadata_path):
285
- with open(metadata_path, 'r') as f:
286
- meta = json.load(f)
287
  if fname in meta:
288
- if isinstance(meta[fname], dict):
289
- meta[fname]['time'] = float(ts)
290
- else:
291
- meta[fname] = float(ts)
292
- with open(metadata_path, 'w') as f:
293
- json.dump(meta, f, indent=2)
294
  return {"success": True, "message": f"Jumped to {ts}s"}
295
  return {"success": False, "message": "Invalid timestamp"}
296
 
@@ -309,10 +276,8 @@ class EnhancedComicGenerator:
309
  self.metadata_path = os.path.join(self.frames_dir, 'frame_metadata.json')
310
 
311
  def cleanup(self):
312
- if os.path.exists(self.frames_dir):
313
- shutil.rmtree(self.frames_dir)
314
- if os.path.exists(self.output_dir):
315
- shutil.rmtree(self.output_dir)
316
  os.makedirs(self.frames_dir, exist_ok=True)
317
  os.makedirs(self.output_dir, exist_ok=True)
318
 
@@ -332,10 +297,10 @@ class EnhancedComicGenerator:
332
  json.dump({'message': msg, 'progress': prog}, f)
333
 
334
  # ======================================================
335
- # 🌐 ROUTES & FRONTEND
336
  # ======================================================
337
  INDEX_HTML = '''
338
- <!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&display=swap" rel="stylesheet"> <style> * { box-sizing: border-box; } body { background-color: #2c3e50; font-family: 'Comic Neue', sans-serif; color: #eee; margin: 0; min-height: 100vh; }
339
 
340
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
341
  .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; }
@@ -369,14 +334,14 @@ INDEX_HTML = '''
369
  border: 6px solid #000;
370
  }
371
 
 
372
  .comic-grid {
373
  width: 100%; height: 100%; position: relative; background: #000;
374
  --y: 50%; --t1: 100%; --t2: 100%; --b1: 100%; --b2: 100%; --gap: 3px;
375
  }
376
 
 
377
  .panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
378
-
379
- /* IMAGE: Cover ensures it fills. Zoom allows control. */
380
  .panel img {
381
  width: 100%; height: 100%;
382
  object-fit: cover;
@@ -387,7 +352,7 @@ INDEX_HTML = '''
387
  .panel img.panning { cursor: grabbing; transition: none; }
388
  .panel.selected { outline: 4px solid #3498db; z-index: 5; }
389
 
390
- /* Clip Paths */
391
  .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; }
392
  .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; }
393
  .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; }
@@ -401,26 +366,57 @@ INDEX_HTML = '''
401
  .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; }
402
  .h-b2 { background: #2ecc71; left: var(--b2); top: 100%; margin-top: -15px; }
403
 
404
- /* SPEECH BUBBLES */
405
  .speech-bubble {
406
  position: absolute; display: flex; justify-content: center; align-items: center;
407
  min-width: 60px; min-height: 40px; box-sizing: border-box;
408
  z-index: 10; cursor: move; font-weight: bold; text-align: center;
409
  overflow: visible; line-height: 1.2; --tail-pos: 50%;
410
  }
411
- .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; }
 
 
 
 
412
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
413
 
414
- /* Bubble Styles */
415
- .speech-bubble.speech { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 3px solid #000; border-radius: 50%; }
416
- .speech-bubble.speech::after { content: ''; position: absolute; bottom: -12px; left: var(--tail-pos); border: 12px solid transparent; border-top-color: #000; border-bottom: 0; margin-left: -12px; }
 
 
 
 
 
 
 
 
 
 
 
 
417
 
 
418
  .speech-bubble.thought { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 2px dashed #555; border-radius: 50%; }
419
- .speech-bubble.thought .dots { position: absolute; bottom:-20px; left:20px; width:15px; height:15px; background:#fff; border:2px solid #555; border-radius:50%; }
420
-
421
- .speech-bubble.reaction { background: #ff0; border: 3px solid red; color: red; 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%); }
 
 
 
 
 
 
 
 
 
422
 
423
- .speech-bubble.narration { background: #eee; border: 2px solid #000; color: #000; border-radius: 0; font-family: 'Lato'; bottom: 10px; left: 50%; transform: translateX(-50%); }
 
 
 
 
 
424
 
425
  .resize-handle { position: absolute; bottom:-5px; right:-5px; width:15px; height:15px; background:#3498db; border:1px solid white; cursor:se-resize; display:none; }
426
  .speech-bubble.selected .resize-handle { display:block; }
@@ -559,7 +555,9 @@ INDEX_HTML = '''
559
  left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
560
  type: b.dataset.type, font: b.style.fontFamily,
561
  colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') },
562
- tailPos: b.style.getPropertyValue('--tail-pos')
 
 
563
  });
564
  });
565
  const panels = [];
@@ -643,9 +641,10 @@ INDEX_HTML = '''
643
  const data = await r.json();
644
  const cleanData = data.map(p => ({
645
  panels: p.panels.map(pan => ({ src: `/frames/${pan.image}?sid=${sid}` })),
 
646
  bubbles: p.bubbles.map(b => ({
647
  text: b.dialog, left: (b.bubble_offset_x||50)+'px', top: (b.bubble_offset_y||20)+'px', type: b.type,
648
- colors: b.colors, font: b.font
649
  }))
650
  }));
651
  renderFromState(cleanData);
@@ -660,30 +659,32 @@ INDEX_HTML = '''
660
  const div = document.createElement('div'); div.className = 'comic-page';
661
  const grid = document.createElement('div'); grid.className = 'comic-grid';
662
 
663
- page.panels.forEach(pan => {
664
  const pDiv = document.createElement('div'); pDiv.className = 'panel';
665
  const img = document.createElement('img');
666
  img.src = pan.src.includes('?') ? pan.src : pan.src + `?sid=${sid}`;
667
  img.dataset.zoom = 100; img.dataset.translateX = 0; img.dataset.translateY = 0;
668
  img.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); selectPanel(pDiv); dragType = 'pan'; activeObj = img; dragStart = {x:e.clientX, y:e.clientY}; img.classList.add('panning'); };
669
-
670
- // 🚀 ZOOM: Allows zooming out to 20% to fit 16:9 images in squares
671
- img.onwheel = (e) => {
672
- e.preventDefault();
673
- let zoom = parseFloat(img.dataset.zoom);
674
- zoom += e.deltaY * -0.1;
675
- zoom = Math.min(Math.max(20, zoom), 300);
676
- img.dataset.zoom = zoom;
677
- updateImageTransform(img);
678
- if(selectedPanel === pDiv) document.getElementById('zoom-slider').value = zoom;
679
- saveState();
680
- };
681
-
682
  pDiv.appendChild(img); grid.appendChild(pDiv);
683
  });
684
 
685
  grid.append(createHandle('h-t1', grid, 't1'), createHandle('h-t2', grid, 't2'), createHandle('h-b1', grid, 'b1'), createHandle('h-b2', grid, 'b2'));
686
- (page.bubbles || []).forEach(bData => { grid.appendChild(createBubbleHTML(bData)); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
687
  div.appendChild(grid); pageWrapper.appendChild(div); con.appendChild(pageWrapper);
688
  });
689
  }
@@ -697,7 +698,12 @@ INDEX_HTML = '''
697
  function createBubbleHTML(data) {
698
  const b = document.createElement('div');
699
  const type = data.type || 'speech';
700
- b.className = `speech-bubble ${type}`;
 
 
 
 
 
701
  b.dataset.type = type;
702
  b.style.left = data.left; b.style.top = data.top;
703
  if(data.width) b.style.width = data.width;
@@ -705,7 +711,8 @@ INDEX_HTML = '''
705
  if(data.font) b.style.fontFamily = data.font;
706
  if(data.colors) { b.style.setProperty('--bubble-fill', data.colors.fill); b.style.setProperty('--bubble-text', data.colors.text); }
707
  if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
708
- if(type === 'thought') { const dots=document.createElement('div'); dots.className='dots'; b.appendChild(dots); }
 
709
 
710
  const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || 'Text'; b.appendChild(textSpan);
711
  const resizer = document.createElement('div'); resizer.className = 'resize-handle';
@@ -723,7 +730,6 @@ INDEX_HTML = '''
723
  if(newText !== null) { textSpan.textContent = newText; saveState(); }
724
  }
725
 
726
- // --- GLOBAL MOUSE EVENTS ---
727
  document.addEventListener('mousemove', (e) => {
728
  if(!dragType) return;
729
  if(dragType === 'handle') {
@@ -753,7 +759,6 @@ INDEX_HTML = '''
753
  dragType = null; activeObj = null;
754
  });
755
 
756
- // --- UI ACTIONS ---
757
  function selectBubble(el) {
758
  if(selectedBubble) selectedBubble.classList.remove('selected');
759
  selectedBubble = el; el.classList.add('selected');
@@ -821,18 +826,6 @@ INDEX_HTML = '''
821
  if(d.success) img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
822
  img.style.opacity='1'; saveState();
823
  }
824
- async function gotoTimestamp() {
825
- if(!selectedPanel) return alert("Select a panel");
826
- let v = document.getElementById('timestamp-input').value.trim();
827
- if(!v) return;
828
- if(v.includes(':')) { let p = v.split(':'); v = parseInt(p[0]) * 60 + parseFloat(p[1]); } else { v = parseFloat(v); }
829
- const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0];
830
- img.style.opacity = '0.5';
831
- const r = await fetch(`/goto_timestamp?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, timestamp:v}) });
832
- const d = await r.json();
833
- if(d.success) img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
834
- img.style.opacity='1'; saveState();
835
- }
836
 
837
  async function exportComic() {
838
  const pgs = document.querySelectorAll('.comic-page');
@@ -863,7 +856,14 @@ INDEX_HTML = '''
863
  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%' };
864
  const bubbles = [];
865
  grid.querySelectorAll('.speech-bubble').forEach(b => {
866
- bubbles.push({ text: b.querySelector('.bubble-text').textContent, left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height, colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') } });
 
 
 
 
 
 
 
867
  });
868
  const panels = [];
869
  grid.querySelectorAll('.panel').forEach(pan => {
 
39
  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
44
 
45
  def generate_save_code(length=8):
46
  chars = string.ascii_uppercase + string.digits
 
50
  return code
51
 
52
  # ======================================================
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"
60
+ elif type == 'thought':
61
+ classes += " pos-bl" # Bottom-left tail default for thought
62
+
63
  return {
64
  'dialog': dialog,
65
+ 'bubble_offset_x': int(x),
66
+ 'bubble_offset_y': int(y),
 
 
 
67
  'type': type,
68
  'tail_pos': '50%',
69
+ 'classes': classes,
70
  'colors': {'fill': '#ffffff', 'text': '#000000'},
71
  'font': "'Comic Neue', cursive"
72
  }
 
80
  self.bubbles = bubbles
81
 
82
  # ======================================================
83
+ # 🧠 GPU GENERATION (SQUARE + TEXT + 1 BUBBLE/PANEL)
84
  # ======================================================
85
  @spaces.GPU(duration=300)
86
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
87
+ print(f"🚀 Generating HD Comic: {video_path}")
 
88
  import cv2
89
  import srt
90
  import numpy as np
91
  from backend.subtitles.subs_real import get_real_subtitles
92
 
 
93
  cap = cv2.VideoCapture(video_path)
94
  if not cap.isOpened(): raise Exception("Cannot open video")
95
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
 
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)
 
120
  if valid_subs:
121
  raw_moments = [{'text': s.content.strip(), 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
122
  else:
 
123
  raw_moments = []
124
 
125
+ # 4 Panels Per Page
126
  panels_per_page = 4
127
  total_panels_needed = int(target_pages) * panels_per_page
128
 
129
  selected_moments = []
130
  if not raw_moments:
 
131
  times = np.linspace(1, max(1, duration-1), total_panels_needed)
132
+ for t in times: selected_moments.append({'text': '', 'start': t, 'end': t+1})
 
133
  elif len(raw_moments) <= total_panels_needed:
134
  selected_moments = raw_moments
135
  else:
 
136
  indices = np.linspace(0, len(raw_moments) - 1, total_panels_needed, dtype=int)
137
  selected_moments = [raw_moments[i] for i in indices]
138
 
 
139
  frame_metadata = {}
140
  cap = cv2.VideoCapture(video_path)
141
  count = 0
 
145
  mid = (moment['start'] + moment['end']) / 2
146
  cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
147
  ret, frame = cap.read()
 
148
  if ret:
149
+ # 🎯 SQUARE PADDING (0% Cut)
 
 
150
  h, w = frame.shape[:2]
151
  sq_dim = max(h, w)
 
 
152
  square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
 
 
153
  x_off = (sq_dim - w) // 2
154
  y_off = (sq_dim - h) // 2
 
 
155
  square_img[y_off:y_off+h, x_off:x_off+w] = frame
156
+ # Resize to standard high res
157
+ square_img = cv2.resize(square_img, (1024, 1024))
158
 
 
159
  fname = f"frame_{count:04d}.png"
160
  p = os.path.join(frames_dir, fname)
161
  cv2.imwrite(p, square_img)
 
163
  frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
164
  frame_files_ordered.append(fname)
165
  count += 1
 
166
  cap.release()
167
+
168
+ with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
169
 
 
170
  bubbles_list = []
171
  for f in frame_files_ordered:
172
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
173
+
174
+ # Determine Bubble Type
175
  b_type = 'speech'
176
  if '(' in dialogue: b_type = 'narration'
177
+ elif '!' in dialogue and dialogue.isupper(): b_type = 'reaction'
178
+ elif '?' in dialogue: b_type = 'speech'
179
 
180
+ # 1 Bubble Per Panel logic handled by 1:1 list mapping
181
+ bubbles_list.append(bubble(dialog=dialogue, x=50, y=20, type=b_type))
182
 
 
183
  pages = []
184
  for i in range(int(target_pages)):
185
  start_idx = i * 4
 
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="", type='speech'))
196
 
197
  if p_frames:
198
+ pg_panels = [{'image': f} for f in p_frames]
199
+ pages.append({'panels': pg_panels, 'bubbles': p_bubbles})
200
+
201
+ return pages
 
 
 
 
 
 
 
202
 
203
  @spaces.GPU
204
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
205
  import cv2
206
  import json
207
+ if not os.path.exists(metadata_path): return {"success": False, "message": "No metadata"}
208
+ with open(metadata_path, 'r') as f: meta = json.load(f)
 
 
209
 
210
  t = meta[fname]['time'] if isinstance(meta[fname], dict) else meta[fname]
211
  cap = cv2.VideoCapture(video_path)
 
218
  cap.release()
219
 
220
  if ret:
 
221
  h, w = frame.shape[:2]
222
  sq_dim = max(h, w)
223
  square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
224
  x_off = (sq_dim - w) // 2
225
  y_off = (sq_dim - h) // 2
226
  square_img[y_off:y_off+h, x_off:x_off+w] = frame
227
+ square_img = cv2.resize(square_img, (1024, 1024))
228
 
229
+ cv2.imwrite(os.path.join(frames_dir, fname), square_img)
230
+ if isinstance(meta[fname], dict): meta[fname]['time'] = new_t
231
+ else: meta[fname] = new_t
232
+ with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
 
 
 
 
 
233
  return {"success": True, "message": f"Time: {new_t:.2f}s"}
234
+ return {"success": False}
235
 
236
  @spaces.GPU
237
  def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
 
249
  x_off = (sq_dim - w) // 2
250
  y_off = (sq_dim - h) // 2
251
  square_img[y_off:y_off+h, x_off:x_off+w] = frame
252
+ square_img = cv2.resize(square_img, (1024, 1024))
253
 
254
+ cv2.imwrite(os.path.join(frames_dir, fname), square_img)
 
255
  if os.path.exists(metadata_path):
256
+ with open(metadata_path, 'r') as f: meta = json.load(f)
 
257
  if fname in meta:
258
+ if isinstance(meta[fname], dict): meta[fname]['time'] = float(ts)
259
+ else: meta[fname] = float(ts)
260
+ with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
 
 
 
261
  return {"success": True, "message": f"Jumped to {ts}s"}
262
  return {"success": False, "message": "Invalid timestamp"}
263
 
 
276
  self.metadata_path = os.path.join(self.frames_dir, 'frame_metadata.json')
277
 
278
  def cleanup(self):
279
+ if os.path.exists(self.frames_dir): shutil.rmtree(self.frames_dir)
280
+ if os.path.exists(self.output_dir): shutil.rmtree(self.output_dir)
 
 
281
  os.makedirs(self.frames_dir, exist_ok=True)
282
  os.makedirs(self.output_dir, exist_ok=True)
283
 
 
297
  json.dump({'message': msg, 'progress': prog}, f)
298
 
299
  # ======================================================
300
+ # 🌐 FRONTEND
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>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; }
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: #34495e; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); text-align: center; }
 
334
  border: 6px solid #000;
335
  }
336
 
337
+ /* === GRID CSS === */
338
  .comic-grid {
339
  width: 100%; height: 100%; position: relative; background: #000;
340
  --y: 50%; --t1: 100%; --t2: 100%; --b1: 100%; --b2: 100%; --gap: 3px;
341
  }
342
 
343
+ /* Panel and Image */
344
  .panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
 
 
345
  .panel img {
346
  width: 100%; height: 100%;
347
  object-fit: cover;
 
352
  .panel img.panning { cursor: grabbing; transition: none; }
353
  .panel.selected { outline: 4px solid #3498db; z-index: 5; }
354
 
355
+ /* Clip Paths (4 Panels) */
356
  .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; }
357
  .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; }
358
  .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; }
 
366
  .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; }
367
  .h-b2 { background: #2ecc71; left: var(--b2); top: 100%; margin-top: -15px; }
368
 
369
+ /* --- ADVANCED SPEECH BUBBLES (FROM SAFWE.PY) --- */
370
  .speech-bubble {
371
  position: absolute; display: flex; justify-content: center; align-items: center;
372
  min-width: 60px; min-height: 40px; box-sizing: border-box;
373
  z-index: 10; cursor: move; font-weight: bold; text-align: center;
374
  overflow: visible; line-height: 1.2; --tail-pos: 50%;
375
  }
376
+ .bubble-text {
377
+ padding: 0.8em; word-wrap: break-word; white-space: pre-wrap;
378
+ width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
379
+ border-radius: inherit; pointer-events: none;
380
+ }
381
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
382
 
383
+ /* SPEECH */
384
+ .speech-bubble.speech {
385
+ --b: 3em; --h: 1.8em; --t: 0.6; --p: var(--tail-pos, 50%); --r: 1.2em;
386
+ background: var(--bubble-fill, #fff);
387
+ color: var(--bubble-text, #000);
388
+ padding: 0;
389
+ 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);
390
+ }
391
+ .speech-bubble.speech:before {
392
+ content: ""; position: absolute; width: var(--b); height: var(--h);
393
+ background: inherit; border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
394
+ -webkit-mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
395
+ mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
396
+ }
397
+ .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))); }
398
 
399
+ /* THOUGHT */
400
  .speech-bubble.thought { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 2px dashed #555; border-radius: 50%; }
401
+ .speech-bubble.thought::before { display:none; }
402
+ .thought-dot { position: absolute; background-color: var(--bubble-fill, #fff); border: 2px solid #555; border-radius: 50%; z-index: -1; }
403
+ .thought-dot-1 { width: 15px; height: 15px; bottom:-15px; left:20px; }
404
+ .thought-dot-2 { width: 10px; height: 10px; bottom:-25px; left:10px; }
405
+ .speech-bubble.thought.pos-bl .thought-dot-1 { left: 20px; bottom: -20px; }
406
+
407
+ /* REACTION */
408
+ .speech-bubble.reaction {
409
+ background: #FFD700; border: 3px solid #E53935; color: #D32F2F;
410
+ font-family: 'Bangers'; text-transform: uppercase;
411
+ 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%);
412
+ }
413
 
414
+ /* NARRATION */
415
+ .speech-bubble.narration {
416
+ background: #eee; border: 2px solid #000; color: #000;
417
+ border-radius: 0; font-family: 'Lato';
418
+ bottom: 10px; left: 50%; transform: translateX(-50%); width: 80% !important; height: auto !important;
419
+ }
420
 
421
  .resize-handle { position: absolute; bottom:-5px; right:-5px; width:15px; height:15px; background:#3498db; border:1px solid white; cursor:se-resize; display:none; }
422
  .speech-bubble.selected .resize-handle { display:block; }
 
555
  left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
556
  type: b.dataset.type, font: b.style.fontFamily,
557
  colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') },
558
+ tailPos: b.style.getPropertyValue('--tail-pos'),
559
+ // Save specific class lists
560
+ classes: b.className
561
  });
562
  });
563
  const panels = [];
 
641
  const data = await r.json();
642
  const cleanData = data.map(p => ({
643
  panels: p.panels.map(pan => ({ src: `/frames/${pan.image}?sid=${sid}` })),
644
+ // Map backend bubbles to strict list structure to avoid grouping
645
  bubbles: p.bubbles.map(b => ({
646
  text: b.dialog, left: (b.bubble_offset_x||50)+'px', top: (b.bubble_offset_y||20)+'px', type: b.type,
647
+ colors: b.colors, font: b.font, classes: b.classes, tailPos: b.tail_pos
648
  }))
649
  }));
650
  renderFromState(cleanData);
 
659
  const div = document.createElement('div'); div.className = 'comic-page';
660
  const grid = document.createElement('div'); grid.className = 'comic-grid';
661
 
662
+ page.panels.forEach((pan, idx) => {
663
  const pDiv = document.createElement('div'); pDiv.className = 'panel';
664
  const img = document.createElement('img');
665
  img.src = pan.src.includes('?') ? pan.src : pan.src + `?sid=${sid}`;
666
  img.dataset.zoom = 100; img.dataset.translateX = 0; img.dataset.translateY = 0;
667
  img.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); selectPanel(pDiv); dragType = 'pan'; activeObj = img; dragStart = {x:e.clientX, y:e.clientY}; img.classList.add('panning'); };
668
+ img.onwheel = (e) => { e.preventDefault(); let zoom = parseFloat(img.dataset.zoom); zoom += e.deltaY * -0.1; zoom = Math.min(Math.max(20, zoom), 300); img.dataset.zoom = zoom; updateImageTransform(img); if(selectedPanel === pDiv) document.getElementById('zoom-slider').value = zoom; saveState(); };
 
 
 
 
 
 
 
 
 
 
 
 
669
  pDiv.appendChild(img); grid.appendChild(pDiv);
670
  });
671
 
672
  grid.append(createHandle('h-t1', grid, 't1'), createHandle('h-t2', grid, 't2'), createHandle('h-b1', grid, 'b1'), createHandle('h-b2', grid, 'b2'));
673
+
674
+ // 🎯 CRITICAL: PAIR 1 BUBBLE TO 1 PANEL IF AVAILABLE
675
+ // Instead of just looping all bubbles, we map them by index to keep 1:1 if possible
676
+ if(page.bubbles) {
677
+ page.bubbles.forEach((bData, bIdx) => {
678
+ // We simply append them to the grid. The backend ensures the list length matches panels roughly.
679
+ // To enforce strict placement visually, we rely on the backend setting varied coordinates.
680
+ // But here we just append to Grid so they float over panels.
681
+ if(bData.text) {
682
+ const b = createBubbleHTML(bData);
683
+ grid.appendChild(b);
684
+ }
685
+ });
686
+ }
687
+
688
  div.appendChild(grid); pageWrapper.appendChild(div); con.appendChild(pageWrapper);
689
  });
690
  }
 
698
  function createBubbleHTML(data) {
699
  const b = document.createElement('div');
700
  const type = data.type || 'speech';
701
+
702
+ // Use existing classes or generate defaults
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;
 
711
  if(data.font) b.style.fontFamily = data.font;
712
  if(data.colors) { b.style.setProperty('--bubble-fill', data.colors.fill); b.style.setProperty('--bubble-text', data.colors.text); }
713
  if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
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';
 
730
  if(newText !== null) { textSpan.textContent = newText; saveState(); }
731
  }
732
 
 
733
  document.addEventListener('mousemove', (e) => {
734
  if(!dragType) return;
735
  if(dragType === 'handle') {
 
759
  dragType = null; activeObj = null;
760
  });
761
 
 
762
  function selectBubble(el) {
763
  if(selectedBubble) selectedBubble.classList.remove('selected');
764
  selectedBubble = el; el.classList.add('selected');
 
826
  if(d.success) img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
827
  img.style.opacity='1'; saveState();
828
  }
 
 
 
 
 
 
 
 
 
 
 
 
829
 
830
  async function exportComic() {
831
  const pgs = document.querySelectorAll('.comic-page');
 
856
  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%' };
857
  const bubbles = [];
858
  grid.querySelectorAll('.speech-bubble').forEach(b => {
859
+ bubbles.push({
860
+ text: b.querySelector('.bubble-text').textContent,
861
+ left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
862
+ type: b.dataset.type, font: b.style.fontFamily,
863
+ colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') },
864
+ tailPos: b.style.getPropertyValue('--tail-pos'),
865
+ classes: b.className
866
+ });
867
  });
868
  const panels = [];
869
  grid.querySelectorAll('.panel').forEach(pan => {