tester343 commited on
Commit
e6f1969
·
verified ·
1 Parent(s): 6d130c1

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +61 -75
app_enhanced.py CHANGED
@@ -24,7 +24,7 @@ def gpu_warmup():
24
  return True
25
 
26
  # ======================================================
27
- # 💾 STORAGE SETUP
28
  # ======================================================
29
  if os.path.exists('/data'):
30
  BASE_STORAGE_PATH = '/data'
@@ -39,6 +39,9 @@ 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
  app = Flask(__name__)
43
  app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024
44
 
@@ -52,8 +55,8 @@ def generate_save_code(length=8):
52
  # ======================================================
53
  # 🧱 DATA CLASSES
54
  # ======================================================
55
- def bubble(dialog="", x=50, y=20, type='speech'):
56
- # Apply classes
57
  classes = f"speech-bubble {type}"
58
  if type == 'speech':
59
  classes += " tail-bottom"
@@ -80,11 +83,11 @@ class Page:
80
  self.bubbles = bubbles
81
 
82
  # ======================================================
83
- # 🧠 GPU GENERATION
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
@@ -104,25 +107,21 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
104
  if os.path.exists('test1.srt'):
105
  shutil.move('test1.srt', user_srt)
106
  elif not os.path.exists(user_srt):
107
- with open(user_srt, 'w') as f:
108
- f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
109
  except:
110
- with open(user_srt, 'w') as f:
111
- f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
112
 
113
  with open(user_srt, 'r', encoding='utf-8') as f:
114
- try:
115
- all_subs = list(srt.parse(f.read()))
116
- except:
117
- all_subs = []
118
 
119
- valid_subs = [s for s in all_subs if s.content and s.content.strip()]
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
 
@@ -146,18 +145,12 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
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
- square_img = cv2.resize(square_img, (1024, 1024))
157
 
158
  fname = f"frame_{count:04d}.png"
159
  p = os.path.join(frames_dir, fname)
160
- cv2.imwrite(p, square_img)
161
 
162
  frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
163
  frame_files_ordered.append(fname)
@@ -170,25 +163,23 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
170
  for i, f in enumerate(frame_files_ordered):
171
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
172
 
173
- # Determine Bubble Type
174
  b_type = 'speech'
175
  if '(' in dialogue: b_type = 'narration'
176
  elif '!' in dialogue: b_type = 'reaction'
177
  elif '?' in dialogue: b_type = 'speech'
178
 
179
- # 🎯 CALCULATE POSITION BASED ON PANEL INDEX (0-3)
180
- # Page size is 800x800. Panels are ~400x400 quadrants.
181
- # We want to center the bubble roughly in each quadrant.
182
  pos_idx = i % 4
183
 
184
- if pos_idx == 0: # Top-Left
185
- bx, by = 150, 50
186
- elif pos_idx == 1: # Top-Right
187
- bx, by = 550, 50
188
- elif pos_idx == 2: # Bottom-Left
189
- bx, by = 150, 450
190
- elif pos_idx == 3: # Bottom-Right
191
- bx, by = 550, 450
192
  else:
193
  bx, by = 50, 50
194
 
@@ -203,10 +194,10 @@ 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((1024, 1024, 3), dtype=np.uint8); img[:] = (30,30,30)
207
  cv2.imwrite(os.path.join(frames_dir, fname), img)
208
  p_frames.append(fname)
209
- # Add dummy bubble to keep count synced, but off-screen or empty
210
  p_bubbles.append(bubble(dialog="", x=-999, y=-999, type='speech'))
211
 
212
  if p_frames:
@@ -233,15 +224,8 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
233
  cap.release()
234
 
235
  if ret:
236
- h, w = frame.shape[:2]
237
- sq_dim = max(h, w)
238
- square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
239
- x_off = (sq_dim - w) // 2
240
- y_off = (sq_dim - h) // 2
241
- square_img[y_off:y_off+h, x_off:x_off+w] = frame
242
- square_img = cv2.resize(square_img, (1024, 1024))
243
-
244
- cv2.imwrite(os.path.join(frames_dir, fname), square_img)
245
  if isinstance(meta[fname], dict): meta[fname]['time'] = new_t
246
  else: meta[fname] = new_t
247
  with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
@@ -258,15 +242,9 @@ def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
258
  cap.release()
259
 
260
  if ret:
261
- h, w = frame.shape[:2]
262
- sq_dim = max(h, w)
263
- square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
264
- x_off = (sq_dim - w) // 2
265
- y_off = (sq_dim - h) // 2
266
- square_img[y_off:y_off+h, x_off:x_off+w] = frame
267
- square_img = cv2.resize(square_img, (1024, 1024))
268
-
269
- cv2.imwrite(os.path.join(frames_dir, fname), square_img)
270
  if os.path.exists(metadata_path):
271
  with open(metadata_path, 'r') as f: meta = json.load(f)
272
  if fname in meta:
@@ -315,7 +293,7 @@ class EnhancedComicGenerator:
315
  # 🌐 FRONTEND
316
  # ======================================================
317
  INDEX_HTML = '''
318
- <!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; }
319
 
320
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
321
  .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; }
@@ -335,14 +313,14 @@ INDEX_HTML = '''
335
  .loader { width: 100px; height: 10px; background: #e67e22; margin: 20px auto; animation: load 1s infinite alternate; }
336
  @keyframes load { from { width: 20px; } to { width: 100px; } }
337
 
338
- /* === SQUARE COMIC LAYOUT (800x800) === */
339
  .comic-wrapper { max-width: 1000px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; gap: 40px; }
340
  .page-wrapper { display: flex; flex-direction: column; align-items: center; }
341
  .page-title { text-align: center; color: #eee; margin-bottom: 10px; font-size: 20px; font-weight: bold; }
342
 
343
  .comic-page {
344
- width: 800px;
345
- height: 800px;
346
  background: white;
347
  box-shadow: 0 5px 30px rgba(0,0,0,0.6);
348
  position: relative; overflow: hidden;
@@ -356,7 +334,7 @@ INDEX_HTML = '''
356
 
357
  .panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
358
 
359
- /* IMAGE: Cover ensures it fills. Zoom allows control. */
360
  .panel img {
361
  width: 100%; height: 100%;
362
  object-fit: cover;
@@ -367,7 +345,7 @@ INDEX_HTML = '''
367
  .panel img.panning { cursor: grabbing; transition: none; }
368
  .panel.selected { outline: 4px solid #3498db; z-index: 5; }
369
 
370
- /* Clip Paths */
371
  .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; }
372
  .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; }
373
  .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; }
@@ -381,7 +359,7 @@ INDEX_HTML = '''
381
  .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; }
382
  .h-b2 { background: #2ecc71; left: var(--b2); top: 100%; margin-top: -15px; }
383
 
384
- /* --- ADVANCED SPEECH BUBBLES --- */
385
  .speech-bubble {
386
  position: absolute; display: flex; justify-content: center; align-items: center;
387
  min-width: 60px; min-height: 40px; box-sizing: border-box;
@@ -458,7 +436,7 @@ INDEX_HTML = '''
458
 
459
  <div id="upload-container">
460
  <div class="upload-box">
461
- <h1>⚡ Ultimate Square Comic</h1>
462
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
463
  <label for="file-upload" class="file-label">📁 Choose Video</label>
464
  <span id="fn" style="margin-bottom:10px; display:block; color:#aaa;">No file selected</span>
@@ -483,7 +461,7 @@ INDEX_HTML = '''
483
  </div>
484
 
485
  <div id="editor-container">
486
- <div class="tip">👉 Drag Right-Side Dots to reveal 4 panels! | 📜 Scroll to Zoom/Pan</div>
487
  <div class="comic-wrapper" id="comic-container"></div>
488
  <input type="file" id="image-uploader" style="display: none;" accept="image/*">
489
 
@@ -655,7 +633,7 @@ INDEX_HTML = '''
655
  const data = await r.json();
656
  const cleanData = data.map(p => ({
657
  panels: p.panels.map(pan => ({ src: `/frames/${pan.image}?sid=${sid}` })),
658
- // Map backend bubbles to strict list structure to avoid grouping
659
  bubbles: p.bubbles.map(b => ({
660
  text: b.dialog, left: (b.bubble_offset_x||50)+'px', top: (b.bubble_offset_y||20)+'px', type: b.type,
661
  colors: b.colors, font: b.font, classes: b.classes, tailPos: b.tail_pos
@@ -685,13 +663,8 @@ INDEX_HTML = '''
685
 
686
  grid.append(createHandle('h-t1', grid, 't1'), createHandle('h-t2', grid, 't2'), createHandle('h-b1', grid, 'b1'), createHandle('h-b2', grid, 'b2'));
687
 
688
- // 🎯 CRITICAL: PAIR 1 BUBBLE TO 1 PANEL IF AVAILABLE
689
- // Instead of just looping all bubbles, we map them by index to keep 1:1 if possible
690
  if(page.bubbles) {
691
  page.bubbles.forEach((bData, bIdx) => {
692
- // We simply append them to the grid. The backend ensures the list length matches panels roughly.
693
- // To enforce strict placement visually, we rely on the backend setting varied coordinates.
694
- // But here we just append to Grid so they float over panels.
695
  if(bData.text) {
696
  const b = createBubbleHTML(bData);
697
  grid.appendChild(b);
@@ -712,8 +685,6 @@ INDEX_HTML = '''
712
  function createBubbleHTML(data) {
713
  const b = document.createElement('div');
714
  const type = data.type || 'speech';
715
-
716
- // Use existing classes or generate defaults
717
  let className = data.classes || `speech-bubble ${type} tail-bottom`;
718
  if (type === 'thought' && !className.includes('pos-')) className += ' pos-bl';
719
  b.className = className;
@@ -744,7 +715,6 @@ INDEX_HTML = '''
744
  if(newText !== null) { textSpan.textContent = newText; saveState(); }
745
  }
746
 
747
- // --- GLOBAL MOUSE EVENTS ---
748
  document.addEventListener('mousemove', (e) => {
749
  if(!dragType) return;
750
  if(dragType === 'handle') {
@@ -774,7 +744,6 @@ INDEX_HTML = '''
774
  dragType = null; activeObj = null;
775
  });
776
 
777
- // --- UI ACTIONS ---
778
  function selectBubble(el) {
779
  if(selectedBubble) selectedBubble.classList.remove('selected');
780
  selectedBubble = el; el.classList.add('selected');
@@ -933,6 +902,23 @@ def regen():
933
  gen = EnhancedComicGenerator(sid)
934
  return jsonify(regen_frame_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], d['direction']))
935
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
936
  @app.route('/save_comic', methods=['POST'])
937
  def save_comic():
938
  sid = request.args.get('sid')
 
24
  return True
25
 
26
  # ======================================================
27
+ # 💾 PERSISTENT STORAGE
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
+ # ======================================================
43
+ # 🔧 APP CONFIG
44
+ # ======================================================
45
  app = Flask(__name__)
46
  app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024
47
 
 
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"
 
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
 
107
  if os.path.exists('test1.srt'):
108
  shutil.move('test1.srt', user_srt)
109
  elif not os.path.exists(user_srt):
110
+ with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
 
111
  except:
112
+ with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
 
113
 
114
  with open(user_srt, 'r', encoding='utf-8') as f:
115
+ try: all_subs = list(srt.parse(f.read()))
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
 
 
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)
153
+ cv2.imwrite(p, frame)
154
 
155
  frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
156
  frame_files_ordered.append(fname)
 
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
 
 
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:
 
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)
 
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:
 
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
  .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;
 
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;
 
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; }
 
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;
 
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>
 
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
 
 
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
 
663
 
664
  grid.append(createHandle('h-t1', grid, 't1'), createHandle('h-t2', grid, 't2'), createHandle('h-b1', grid, 'b1'), createHandle('h-b2', grid, 'b2'));
665
 
 
 
666
  if(page.bubbles) {
667
  page.bubbles.forEach((bData, bIdx) => {
 
 
 
668
  if(bData.text) {
669
  const b = createBubbleHTML(bData);
670
  grid.appendChild(b);
 
685
  function createBubbleHTML(data) {
686
  const b = document.createElement('div');
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;
 
715
  if(newText !== null) { textSpan.textContent = newText; saveState(); }
716
  }
717
 
 
718
  document.addEventListener('mousemove', (e) => {
719
  if(!dragType) return;
720
  if(dragType === 'handle') {
 
744
  dragType = null; activeObj = null;
745
  });
746
 
 
747
  function selectBubble(el) {
748
  if(selectedBubble) selectedBubble.classList.remove('selected');
749
  selectedBubble = el; el.classList.add('selected');
 
902
  gen = EnhancedComicGenerator(sid)
903
  return jsonify(regen_frame_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], d['direction']))
904
 
905
+ @app.route('/goto_timestamp', methods=['POST'])
906
+ def go_time():
907
+ sid = request.args.get('sid')
908
+ d = request.get_json()
909
+ gen = EnhancedComicGenerator(sid)
910
+ return jsonify(get_frame_at_ts_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], float(d['timestamp'])))
911
+
912
+ @app.route('/replace_panel', methods=['POST'])
913
+ def rep_panel():
914
+ sid = request.args.get('sid')
915
+ f = request.files['image']
916
+ frames_dir = os.path.join(BASE_USER_DIR, sid, 'frames')
917
+ os.makedirs(frames_dir, exist_ok=True)
918
+ fname = f"replaced_{int(time.time() * 1000)}.png"
919
+ f.save(os.path.join(frames_dir, fname))
920
+ return jsonify({'success': True, 'new_filename': fname})
921
+
922
  @app.route('/save_comic', methods=['POST'])
923
  def save_comic():
924
  sid = request.args.get('sid')