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

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +131 -153
app_enhanced.py CHANGED
@@ -24,7 +24,7 @@ def gpu_warmup():
24
  return True
25
 
26
  # ======================================================
27
- # πŸ’Ύ PERSISTENT STORAGE
28
  # ======================================================
29
  if os.path.exists('/data'):
30
  BASE_STORAGE_PATH = '/data'
@@ -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
47
 
48
  def generate_save_code(length=8):
49
  chars = string.ascii_uppercase + string.digits
@@ -55,21 +52,14 @@ def generate_save_code(length=8):
55
  # ======================================================
56
  # 🧱 DATA CLASSES
57
  # ======================================================
58
- def bubble(dialog="", x=50, y=50, type='speech'):
59
- # Determine CSS classes based on type
60
- classes = f"speech-bubble {type}"
61
- if type == 'speech':
62
- classes += " tail-bottom"
63
- elif type == 'thought':
64
- classes += " pos-bl"
65
-
66
  return {
67
  'dialog': dialog,
68
  'bubble_offset_x': int(x),
69
  'bubble_offset_y': int(y),
70
  'type': type,
71
  'tail_pos': '50%',
72
- 'classes': classes,
73
  'colors': {'fill': '#ffffff', 'text': '#000000'},
74
  'font': "'Comic Neue', cursive"
75
  }
@@ -83,11 +73,12 @@ class Page:
83
  self.bubbles = bubbles
84
 
85
  # ======================================================
86
- # 🧠 GPU GENERATION (1920x1080 Source -> 864x1080 Layout)
87
  # ======================================================
88
  @spaces.GPU(duration=300)
89
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
90
- print(f"πŸš€ Generating Comic (864x1080): {video_path}")
 
91
  import cv2
92
  import srt
93
  import numpy as np
@@ -100,7 +91,6 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
100
  duration = total_frames / fps
101
  cap.release()
102
 
103
- # Subtitles
104
  user_srt = os.path.join(user_dir, 'subs.srt')
105
  try:
106
  get_real_subtitles(video_path)
@@ -116,14 +106,11 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
116
  except: all_subs = []
117
 
118
  valid_subs = [s for s in all_subs if s.content.strip()]
119
- if valid_subs:
120
- raw_moments = [{'text': s.content.strip(), 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
121
- else:
122
- raw_moments = []
123
 
124
- # 4 Panels Per Page (Logic)
125
  panels_per_page = 4
126
- total_panels_needed = int(target_pages) * panels_per_page
127
 
128
  selected_moments = []
129
  if not raw_moments:
@@ -142,11 +129,16 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
142
 
143
  for i, moment in enumerate(selected_moments):
144
  mid = (moment['start'] + moment['end']) / 2
145
- cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
 
146
  ret, frame = cap.read()
147
  if ret:
148
- # 🎯 EXTRACT AT 1920x1080 (Matches Page Height, High Quality)
149
- frame = cv2.resize(frame, (1920, 1080))
 
 
 
 
150
 
151
  fname = f"frame_{count:04d}.png"
152
  p = os.path.join(frames_dir, fname)
@@ -161,32 +153,26 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
161
 
162
  bubbles_list = []
163
  for i, f in enumerate(frame_files_ordered):
 
164
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
165
 
166
  b_type = 'speech'
167
- if '(' in dialogue: b_type = 'narration'
168
- elif '!' in dialogue: b_type = 'reaction'
169
  elif '?' in dialogue: b_type = 'speech'
170
 
171
- # 🎯 SMART PLACEMENT FOR 864x1080 GRID
172
- # Top-Left (0), Top-Right (1), Bot-Left (2), Bot-Right (3)
173
- pos_idx = i % 4
 
 
 
 
174
 
175
- if pos_idx == 0: # Top-Left Panel
176
- bx, by = 150, 80
177
- elif pos_idx == 1: # Top-Right Panel (Hidden by default)
178
- bx, by = 600, 80
179
- elif pos_idx == 2: # Bot-Left Panel
180
- bx, by = 150, 600
181
- elif pos_idx == 3: # Bot-Right Panel (Hidden by default)
182
- bx, by = 600, 600
183
- else:
184
- bx, by = 50, 50
185
-
186
  bubbles_list.append(bubble(dialog=dialogue, x=bx, y=by, type=b_type))
187
 
188
  pages = []
189
- for i in range(int(target_pages)):
190
  start_idx = i * 4
191
  end_idx = start_idx + 4
192
  p_frames = frame_files_ordered[start_idx:end_idx]
@@ -194,17 +180,22 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
194
 
195
  while len(p_frames) < 4:
196
  fname = f"empty_{i}_{len(p_frames)}.png"
197
- img = np.zeros((1080, 1920, 3), dtype=np.uint8); img[:] = (30,30,30)
198
  cv2.imwrite(os.path.join(frames_dir, fname), img)
199
  p_frames.append(fname)
200
- # Place empty bubble offscreen
201
- p_bubbles.append(bubble(dialog="", x=-999, y=-999, type='speech'))
202
 
203
  if p_frames:
204
- pg_panels = [{'image': f} for f in p_frames]
205
  pages.append({'panels': pg_panels, 'bubbles': p_bubbles})
206
 
207
- return pages
 
 
 
 
 
 
208
 
209
  @spaces.GPU
210
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
@@ -224,35 +215,15 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
224
  cap.release()
225
 
226
  if ret:
227
- frame = cv2.resize(frame, (1920, 1080))
228
- cv2.imwrite(os.path.join(frames_dir, fname), frame)
 
 
229
  if isinstance(meta[fname], dict): meta[fname]['time'] = new_t
230
  else: meta[fname] = new_t
231
  with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
232
  return {"success": True, "message": f"Time: {new_t:.2f}s"}
233
- return {"success": False}
234
-
235
- @spaces.GPU
236
- def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
237
- import cv2
238
- import json
239
- cap = cv2.VideoCapture(video_path)
240
- cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
241
- ret, frame = cap.read()
242
- cap.release()
243
-
244
- if ret:
245
- frame = cv2.resize(frame, (1920, 1080))
246
- p = os.path.join(frames_dir, fname)
247
- cv2.imwrite(p, frame)
248
- if os.path.exists(metadata_path):
249
- with open(metadata_path, 'r') as f: meta = json.load(f)
250
- if fname in meta:
251
- if isinstance(meta[fname], dict): meta[fname]['time'] = float(ts)
252
- else: meta[fname] = float(ts)
253
- with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
254
- return {"success": True, "message": f"Jumped to {ts}s"}
255
- return {"success": False, "message": "Invalid timestamp"}
256
 
257
  # ======================================================
258
  # πŸ’» BACKEND CLASS
@@ -290,10 +261,10 @@ class EnhancedComicGenerator:
290
  json.dump({'message': msg, 'progress': prog}, f)
291
 
292
  # ======================================================
293
- # 🌐 FRONTEND
294
  # ======================================================
295
  INDEX_HTML = '''
296
- <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>864x1080 Comic Gen</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; }
297
 
298
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
299
  .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; }
@@ -313,14 +284,14 @@ INDEX_HTML = '''
313
  .loader { width: 100px; height: 10px; background: #e67e22; margin: 20px auto; animation: load 1s infinite alternate; }
314
  @keyframes load { from { width: 20px; } to { width: 100px; } }
315
 
316
- /* === 864x1080 PAGE LAYOUT === */
317
  .comic-wrapper { max-width: 1000px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; gap: 40px; }
318
  .page-wrapper { display: flex; flex-direction: column; align-items: center; }
319
  .page-title { text-align: center; color: #eee; margin-bottom: 10px; font-size: 20px; font-weight: bold; }
320
 
321
  .comic-page {
322
- width: 864px;
323
- height: 1080px;
324
  background: white;
325
  box-shadow: 0 5px 30px rgba(0,0,0,0.6);
326
  position: relative; overflow: hidden;
@@ -333,25 +304,17 @@ INDEX_HTML = '''
333
  }
334
 
335
  .panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
336
-
337
- /* IMAGE HANDLING */
338
- .panel img {
339
- width: 100%; height: 100%;
340
- object-fit: cover;
341
- transform-origin: center;
342
- transition: transform 0.05s ease-out;
343
- display: block;
344
- }
345
  .panel img.panning { cursor: grabbing; transition: none; }
346
  .panel.selected { outline: 4px solid #3498db; z-index: 5; }
347
 
348
- /* === CLIP PATHS FOR 864x1080 (100% t1/b1 = 864px width) === */
349
  .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; }
350
  .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; }
351
  .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; }
352
  .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; }
353
 
354
- /* Handles */
355
  .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); }
356
  .handle:hover { transform: translate(-50%, -50%) scale(1.3); }
357
  .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; }
@@ -359,18 +322,14 @@ INDEX_HTML = '''
359
  .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; }
360
  .h-b2 { background: #2ecc71; left: var(--b2); top: 100%; margin-top: -15px; }
361
 
362
- /* === SPEECH BUBBLES === */
363
  .speech-bubble {
364
  position: absolute; display: flex; justify-content: center; align-items: center;
365
  min-width: 60px; min-height: 40px; box-sizing: border-box;
366
  z-index: 10; cursor: move; font-weight: bold; text-align: center;
367
  overflow: visible; line-height: 1.2; --tail-pos: 50%;
368
  }
369
- .bubble-text {
370
- padding: 0.8em; word-wrap: break-word; white-space: pre-wrap;
371
- width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
372
- border-radius: inherit; pointer-events: none;
373
- }
374
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
375
 
376
  /* SPEECH */
@@ -422,120 +381,142 @@ INDEX_HTML = '''
422
  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; }
423
  .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
424
  .color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
425
- .action-btn { background: #27ae60; color: white; }
426
- .reset-btn { background: #c0392b; color: white; }
427
  .secondary-btn { background: #f39c12; color: white; }
428
- .save-btn { background: #8e44ad; color: white; }
 
429
 
430
- .tip { text-align:center; padding:10px; background:#e74c3c; color:white; font-weight:bold; margin-bottom:20px; border-radius:5px; }
431
- .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; }
432
- .modal-content { background: white; padding: 30px; border-radius: 12px; width: 90%; max-width: 400px; text-align: center; color: #333; }
433
- .code { font-size: 24px; font-weight: bold; letter-spacing: 3px; background: #eee; padding: 10px; margin: 15px 0; display: inline-block; font-family: monospace; }
434
  </style>
435
  </head> <body>
436
 
437
  <div id="upload-container">
438
  <div class="upload-box">
439
- <h1>⚑ 864x1080 Comic Gen</h1>
440
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
441
- <label for="file-upload" class="file-label">πŸ“ Choose Video</label>
442
- <span id="fn" style="margin-bottom:10px; display:block; color:#aaa;">No file selected</span>
443
 
444
  <div class="page-input-group">
445
- <label>πŸ“š Total Pages:</label>
446
- <input type="number" id="page-count" value="4" min="1" max="15">
 
447
  </div>
448
 
449
- <button class="submit-btn" onclick="upload()">πŸš€ Generate</button>
450
- <button id="restore-draft-btn" class="reset-btn" style="display:none; margin-top:10px;" onclick="restoreDraft()">πŸ“‚ Restore Draft</button>
451
 
452
- <div style="margin-top:20px; border-top:1px solid #555; padding-top:10px;">
453
- <input type="text" id="load-code" placeholder="ENTER SAVE CODE" style="width:70%; display:inline-block;">
454
- <button onclick="loadComic()" style="width:25%; display:inline-block; background:#9b59b6; color:white;">Load</button>
 
 
 
455
  </div>
456
  <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
457
- <div class="loader"></div>
458
- <p id="status-text" style="margin-top:10px;">Analyzing Video...</p>
459
  </div>
460
  </div>
461
  </div>
462
 
463
  <div id="editor-container">
464
- <div class="tip">πŸ‘‰ Drag Right-Side Dots (Blue/Green) to reveal 4 panels! | πŸ“œ Scroll to Zoom/Pan</div>
465
  <div class="comic-wrapper" id="comic-container"></div>
466
  <input type="file" id="image-uploader" style="display: none;" accept="image/*">
467
 
468
  <div class="edit-controls">
469
- <h4>✏️ Editor</h4>
470
 
471
  <div class="control-group">
472
- <button onclick="undo()" style="background:#7f8c8d; color:white;">↩️ Undo</button>
473
  <button onclick="saveComic()" class="save-btn">πŸ’Ύ Save Comic</button>
474
  </div>
475
-
476
  <div class="control-group">
477
  <label>πŸ’¬ Bubble Styling:</label>
478
- <select id="bubble-type" onchange="updateBubbleType()">
479
- <option value="speech">Speech πŸ’¬</option>
480
- <option value="thought">Thought πŸ’­</option>
481
- <option value="reaction">Reaction πŸ’₯</option>
482
- <option value="narration">Narration ⬜</option>
483
  </select>
484
- <select id="font-select" onchange="updateFont()">
485
  <option value="'Comic Neue', cursive">Comic Neue</option>
486
  <option value="'Bangers', cursive">Bangers</option>
487
- <option value="'Gloria Hallelujah', cursive">Handwritten</option>
488
- <option value="'Lato', sans-serif">Modern</option>
489
  </select>
490
  <div class="color-grid">
491
- <input type="color" id="bub-fill" value="#ffffff" onchange="updateColors()" title="Fill">
492
- <input type="color" id="bub-text" value="#000000" onchange="updateColors()" title="Text">
493
  </div>
494
  <div class="button-grid">
495
  <button onclick="addBubble()" class="action-btn">Add</button>
496
  <button onclick="deleteBubble()" class="reset-btn">Delete</button>
497
  </div>
498
- <input type="range" min="10" max="90" value="50" oninput="slideTail(this.value)" title="Tail Pos">
 
 
 
 
 
 
 
 
499
  </div>
500
-
501
  <div class="control-group">
502
- <label>πŸ–ΌοΈ Image Control:</label>
503
- <button onclick="replaceImage()" class="action-btn">Replace Image</button>
504
  <div class="button-grid">
505
- <button onclick="adjustFrame('backward')" class="secondary-btn">⬅️ Frame</button>
506
- <button onclick="adjustFrame('forward')" class="action-btn">Frame ➑️</button>
 
 
 
 
507
  </div>
508
  </div>
509
 
510
  <div class="control-group">
511
- <label>πŸ” Zoom (Scroll Wheel):</label>
512
- <input type="range" id="zoom-slider" min="20" max="300" value="100" step="5" oninput="handleZoom(this.value)" disabled>
513
- <button onclick="resetPanelTransform()" class="secondary-btn">Reset View</button>
 
 
514
  </div>
515
 
516
  <div class="control-group">
517
- <button onclick="exportComic()" class="action-btn" style="background:#3498db;">πŸ“₯ Export PNG</button>
518
  <button onclick="location.reload()" class="reset-btn" style="margin-top:10px;">🏠 Home</button>
519
  </div>
520
  </div>
521
  </div>
522
-
523
  <div class="modal-overlay" id="save-modal">
524
  <div class="modal-content">
525
  <h2>βœ… Comic Saved!</h2>
526
- <div class="code" id="modal-code">XXXX</div>
527
- <button onclick="closeModal()">Close</button>
 
528
  </div>
529
  </div>
530
-
531
  <script>
532
  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);}); }
533
  let sid = localStorage.getItem('comic_sid') || genUUID();
534
  localStorage.setItem('comic_sid', sid);
 
 
 
535
  let interval, selectedBubble = null, selectedPanel = null;
536
  let dragType = null, activeObj = null, dragStart = {x:0, y:0};
537
  let historyStack = [];
538
 
 
539
  function saveState() {
540
  const state = [];
541
  document.querySelectorAll('.comic-page').forEach(pg => {
@@ -547,9 +528,9 @@ INDEX_HTML = '''
547
  text: b.querySelector('.bubble-text').textContent,
548
  left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
549
  type: b.dataset.type, font: b.style.fontFamily,
550
- colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') },
551
  tailPos: b.style.getPropertyValue('--tail-pos'),
552
- classes: b.className
553
  });
554
  });
555
  const panels = [];
@@ -633,7 +614,6 @@ INDEX_HTML = '''
633
  const data = await r.json();
634
  const cleanData = data.map(p => ({
635
  panels: p.panels.map(pan => ({ src: `/frames/${pan.image}?sid=${sid}` })),
636
- // Map backend bubbles to strict list structure
637
  bubbles: p.bubbles.map(b => ({
638
  text: b.dialog, left: (b.bubble_offset_x||50)+'px', top: (b.bubble_offset_y||20)+'px', type: b.type,
639
  colors: b.colors, font: b.font, classes: b.classes, tailPos: b.tail_pos
@@ -687,8 +667,8 @@ INDEX_HTML = '''
687
  const type = data.type || 'speech';
688
  let className = data.classes || `speech-bubble ${type} tail-bottom`;
689
  if (type === 'thought' && !className.includes('pos-')) className += ' pos-bl';
690
- b.className = className;
691
 
 
692
  b.dataset.type = type;
693
  b.style.left = data.left; b.style.top = data.top;
694
  if(data.width) b.style.width = data.width;
@@ -699,12 +679,9 @@ INDEX_HTML = '''
699
 
700
  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); } }
701
 
702
- const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || 'Text'; b.appendChild(textSpan);
703
- const resizer = document.createElement('div'); resizer.className = 'resize-handle';
704
- resizer.onmousedown = (e) => { e.stopPropagation(); dragType='resize'; activeObj={b:b, startW:b.offsetWidth, startH:b.offsetHeight, mx:e.clientX, my:e.clientY}; };
705
- b.appendChild(resizer);
706
 
707
- b.onmousedown = (e) => { if(e.target === resizer) return; e.stopPropagation(); selectBubble(b); dragType = 'bubble'; activeObj = b; dragStart = {x: e.clientX, y: e.clientY}; };
708
  b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
709
  return b;
710
  }
@@ -768,7 +745,8 @@ INDEX_HTML = '''
768
  left: oldB.style.left, top: oldB.style.top, width: oldB.style.width, height: oldB.style.height,
769
  type: type, font: oldB.style.fontFamily,
770
  colors: { fill: oldB.style.getPropertyValue('--bubble-fill'), text: oldB.style.getPropertyValue('--bubble-text') },
771
- tailPos: oldB.style.getPropertyValue('--tail-pos')
 
772
  };
773
  const newB = createBubbleHTML(data);
774
  oldB.parentElement.replaceChild(newB, oldB);
@@ -841,7 +819,7 @@ INDEX_HTML = '''
841
  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%' };
842
  const bubbles = [];
843
  grid.querySelectorAll('.speech-bubble').forEach(b => {
844
- 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') });
845
  });
846
  const panels = [];
847
  grid.querySelectorAll('.panel').forEach(pan => {
 
24
  return True
25
 
26
  # ======================================================
27
+ # πŸ’Ύ STORAGE SETUP
28
  # ======================================================
29
  if os.path.exists('/data'):
30
  BASE_STORAGE_PATH = '/data'
 
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 # 500MB Upload Limit
44
 
45
  def generate_save_code(length=8):
46
  chars = string.ascii_uppercase + string.digits
 
52
  # ======================================================
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
  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
  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
  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
 
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)
 
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]
 
180
 
181
  while len(p_frames) < 4:
182
  fname = f"empty_{i}_{len(p_frames)}.png"
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:
194
+ p_data = [p if isinstance(p, dict) else p.__dict__ for p in pg.panels]
195
+ b_data = [b if isinstance(b, dict) else b.__dict__ for b in pg.bubbles]
196
+ result.append({'panels': p_data, 'bubbles': b_data})
197
+
198
+ return result
199
 
200
  @spaces.GPU
201
  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
 
261
  json.dump({'message': msg, 'progress': prog}, f)
262
 
263
  # ======================================================
264
+ # 🌐 ROUTES & FRONTEND
265
  # ======================================================
266
  INDEX_HTML = '''
267
+ <!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; }
268
 
269
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
270
  .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; }
 
284
  .loader { width: 100px; height: 10px; background: #e67e22; margin: 20px auto; animation: load 1s infinite alternate; }
285
  @keyframes load { from { width: 20px; } to { width: 100px; } }
286
 
287
+ /* === SQUARE COMIC LAYOUT (800x800) === */
288
  .comic-wrapper { max-width: 1000px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; gap: 40px; }
289
  .page-wrapper { display: flex; flex-direction: column; align-items: center; }
290
  .page-title { text-align: center; color: #eee; margin-bottom: 10px; font-size: 20px; font-weight: bold; }
291
 
292
  .comic-page {
293
+ width: 800px;
294
+ height: 800px;
295
  background: white;
296
  box-shadow: 0 5px 30px rgba(0,0,0,0.6);
297
  position: relative; overflow: hidden;
 
304
  }
305
 
306
  .panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
307
+ .panel img { width: 100%; height: 100%; object-fit: cover; transform-origin: center; transition: transform 0.05s ease-out; display: block; }
 
 
 
 
 
 
 
 
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; }
 
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
  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>
426
 
427
  <div id="editor-container">
428
+ <div class="tip">πŸ‘‰ Drag Right-Side Dots to reveal 4 panels! | πŸ“œ Scroll to Zoom/Pan</div>
429
  <div class="comic-wrapper" id="comic-container"></div>
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
  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
  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
 
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
 
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
  }
 
745
  left: oldB.style.left, top: oldB.style.top, width: oldB.style.width, height: oldB.style.height,
746
  type: type, font: oldB.style.fontFamily,
747
  colors: { fill: oldB.style.getPropertyValue('--bubble-fill'), text: oldB.style.getPropertyValue('--bubble-text') },
748
+ tailPos: oldB.style.getPropertyValue('--tail-pos'),
749
+ classes: oldB.className // Pass existing classes
750
  };
751
  const newB = createBubbleHTML(data);
752
  oldB.parentElement.replaceChild(newB, oldB);
 
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 => {