tester343 commited on
Commit
11e2baf
·
verified ·
1 Parent(s): 8b0a8d1

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +155 -316
app_enhanced.py CHANGED
@@ -52,16 +52,16 @@ def generate_save_code(length=8):
52
  # ======================================================
53
  # 🧱 DATA CLASSES
54
  # ======================================================
55
- def bubble(dialog="...", x=50, y=50, type='speech'):
56
- # Default styling
57
  classes = f"speech-bubble {type}"
58
  if type == 'speech':
59
- classes += " tail-bottom"
60
  elif type == 'thought':
61
  classes += " pos-bl"
62
 
63
  return {
64
- 'dialog': dialog if dialog.strip() else "...", # Ensure text is never empty
65
  'bubble_offset_x': int(x),
66
  'bubble_offset_y': int(y),
67
  'type': type,
@@ -71,6 +71,14 @@ def bubble(dialog="...", x=50, y=50, type='speech'):
71
  'font': "'Comic Neue', cursive"
72
  }
73
 
 
 
 
 
 
 
 
 
74
  # ======================================================
75
  # 🧠 GPU GENERATION
76
  # ======================================================
@@ -96,29 +104,27 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
96
  if os.path.exists('test1.srt'):
97
  shutil.move('test1.srt', user_srt)
98
  elif not os.path.exists(user_srt):
99
- with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\nText\n")
100
  except:
101
- with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\nText\n")
102
 
103
  with open(user_srt, 'r', encoding='utf-8') as f:
104
  try: all_subs = list(srt.parse(f.read()))
105
  except: all_subs = []
106
 
107
- valid_subs = [s for s in all_subs if s.content and s.content.strip()]
108
  if valid_subs:
109
  raw_moments = [{'text': s.content.strip(), 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
110
  else:
111
  raw_moments = []
112
 
113
- # 4 Panels Per Page
114
  panels_per_page = 4
115
  total_panels_needed = int(target_pages) * panels_per_page
116
 
117
  selected_moments = []
118
  if not raw_moments:
119
- # Create timestamps if no text
120
  times = np.linspace(1, max(1, duration-1), total_panels_needed)
121
- for t in times: selected_moments.append({'text': '...', 'start': t, 'end': t+1})
122
  elif len(raw_moments) <= total_panels_needed:
123
  selected_moments = raw_moments
124
  else:
@@ -135,23 +141,20 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
135
  cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
136
  ret, frame = cap.read()
137
  if ret:
138
- # 🎯 SQUARE PADDING (0% Cut, Matches Template)
139
  h, w = frame.shape[:2]
140
  sq_dim = max(h, w)
141
  square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
142
  x_off = (sq_dim - w) // 2
143
  y_off = (sq_dim - h) // 2
144
  square_img[y_off:y_off+h, x_off:x_off+w] = frame
145
- # Standardize size
146
  square_img = cv2.resize(square_img, (1024, 1024))
147
 
148
  fname = f"frame_{count:04d}.png"
149
  p = os.path.join(frames_dir, fname)
150
  cv2.imwrite(p, square_img)
151
 
152
- # Ensure text exists
153
- txt = moment['text'] if moment['text'].strip() else "..."
154
- frame_metadata[fname] = {'dialogue': txt, 'time': mid}
155
  frame_files_ordered.append(fname)
156
  count += 1
157
  cap.release()
@@ -160,25 +163,21 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
160
 
161
  bubbles_list = []
162
  for i, f in enumerate(frame_files_ordered):
163
- dialogue = frame_metadata.get(f, {}).get('dialogue', '...')
164
 
165
- # Determine Bubble Type
166
  b_type = 'speech'
167
- if '(' in dialogue:
168
- b_type = 'narration'
169
- elif '!' in dialogue and len(dialogue) < 15: # Reaction only if short
170
- b_type = 'reaction'
171
- else:
172
- b_type = 'speech'
173
 
174
- # Smart Positioning (Center of quadrants)
175
  pos_idx = i % 4
176
- if pos_idx == 0: bx, by = 150, 80
177
- elif pos_idx == 1: bx, by = 600, 80
178
- elif pos_idx == 2: bx, by = 150, 600
179
- elif pos_idx == 3: bx, by = 600, 600
180
  else: bx, by = 50, 50
181
-
182
  bubbles_list.append(bubble(dialog=dialogue, x=bx, y=by, type=b_type))
183
 
184
  pages = []
@@ -196,10 +195,16 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
196
  p_bubbles.append(bubble(dialog="", x=-999, y=-999, type='speech'))
197
 
198
  if p_frames:
199
- pg_panels = [{'image': f} for f in p_frames]
200
- pages.append({'panels': pg_panels, 'bubbles': p_bubbles})
201
-
202
- return pages
 
 
 
 
 
 
203
 
204
  @spaces.GPU
205
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
@@ -262,9 +267,6 @@ def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
262
  return {"success": True, "message": f"Jumped to {ts}s"}
263
  return {"success": False, "message": "Invalid timestamp"}
264
 
265
- # ======================================================
266
- # 💻 BACKEND CLASS
267
- # ======================================================
268
  class EnhancedComicGenerator:
269
  def __init__(self, sid):
270
  self.sid = sid
@@ -302,91 +304,40 @@ class EnhancedComicGenerator:
302
  # ======================================================
303
  INDEX_HTML = '''
304
  <!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; }
305
-
306
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
307
  .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; }
308
-
309
  #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 150px; }
310
 
311
- h1 { color: #fff; margin-bottom: 20px; }
312
- .file-input { display: none; }
313
- .file-label { display: block; padding: 15px; background: #e67e22; color: white; border-radius: 8px; cursor: pointer; font-weight: bold; margin-bottom: 10px; transition:0.2s; }
314
- .file-label:hover { background: #d35400; }
315
-
316
- .page-input-group { margin: 20px 0; text-align: left; }
317
- .page-input-group label { font-weight: bold; font-size: 14px; display: block; margin-bottom: 5px; color: #ccc; }
318
- .page-input-group input { width: 100%; padding: 12px; border: 2px solid #555; background: #2c3e50; color: white; border-radius: 8px; font-size: 16px; box-sizing: border-box; }
319
-
320
- .submit-btn { width: 100%; padding: 15px; background: #2980b9; color: white; border: none; border-radius: 8px; font-size: 18px; font-weight: bold; cursor: pointer; }
321
- .loader { width: 100px; height: 10px; background: #e67e22; margin: 20px auto; animation: load 1s infinite alternate; }
322
- @keyframes load { from { width: 20px; } to { width: 100px; } }
323
-
324
- /* === SQUARE COMIC LAYOUT (800x800) === */
325
- .comic-wrapper { max-width: 1000px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; gap: 40px; }
326
- .page-wrapper { display: flex; flex-direction: column; align-items: center; }
327
- .page-title { text-align: center; color: #eee; margin-bottom: 10px; font-size: 20px; font-weight: bold; }
328
-
329
- .comic-page {
330
- width: 800px;
331
- height: 800px;
332
- background: white;
333
- box-shadow: 0 5px 30px rgba(0,0,0,0.6);
334
- position: relative; overflow: hidden;
335
- border: 6px solid #000;
336
- }
337
-
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 { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
344
-
345
- /* IMAGE: Cover ensures it fills. Zoom allows control. */
346
- .panel img {
347
- width: 100%; height: 100%;
348
- object-fit: cover;
349
- transform-origin: center;
350
- transition: transform 0.05s ease-out;
351
- display: block;
352
- }
353
  .panel img.panning { cursor: grabbing; transition: none; }
354
  .panel.selected { outline: 4px solid #3498db; z-index: 5; }
355
 
356
- /* Clip Paths */
357
  .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; }
358
  .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; }
359
  .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; }
360
  .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; }
361
 
362
- /* Handles */
363
  .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); }
364
- .handle:hover { transform: translate(-50%, -50%) scale(1.3); }
365
- .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; }
366
- .h-t2 { background: #3498db; left: var(--t2); top: 50%; margin-top: -15px; }
367
- .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; }
368
- .h-b2 { background: #2ecc71; left: var(--b2); top: 100%; margin-top: -15px; }
369
 
370
- /* SPEECH BUBBLES */
371
  .speech-bubble {
372
  position: absolute; display: flex; justify-content: center; align-items: center;
373
  min-width: 60px; min-height: 40px; box-sizing: border-box;
374
  z-index: 10; cursor: move; font-weight: bold; text-align: center;
375
  overflow: visible; line-height: 1.2; --tail-pos: 50%;
376
  }
377
- .bubble-text {
378
- padding: 0.8em; word-wrap: break-word; white-space: pre-wrap;
379
- width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
380
- border-radius: inherit; pointer-events: none;
381
- }
382
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
383
 
384
- /* SPEECH */
385
  .speech-bubble.speech {
386
  --b: 3em; --h: 1.8em; --t: 0.6; --p: var(--tail-pos, 50%); --r: 1.2em;
387
- background: var(--bubble-fill, #fff);
388
- color: var(--bubble-text, #000);
389
- padding: 0;
390
  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);
391
  }
392
  .speech-bubble.speech:before {
@@ -395,29 +346,27 @@ INDEX_HTML = '''
395
  -webkit-mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
396
  mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
397
  }
 
398
  .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))); }
399
 
400
- /* THOUGHT */
 
 
 
 
 
 
 
 
 
401
  .speech-bubble.thought { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 2px dashed #555; border-radius: 50%; }
402
  .speech-bubble.thought::before { display:none; }
403
  .thought-dot { position: absolute; background-color: var(--bubble-fill, #fff); border: 2px solid #555; border-radius: 50%; z-index: -1; }
404
- .thought-dot-1 { width: 15px; height: 15px; bottom:-15px; left:20px; }
405
- .thought-dot-2 { width: 10px; height: 10px; bottom:-25px; left:10px; }
406
  .speech-bubble.thought.pos-bl .thought-dot-1 { left: 20px; bottom: -20px; }
407
 
408
- /* REACTION */
409
- .speech-bubble.reaction {
410
- background: #FFD700; border: 3px solid #E53935; color: #D32F2F;
411
- font-family: 'Bangers'; text-transform: uppercase;
412
- 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%);
413
- }
414
-
415
- /* NARRATION */
416
- .speech-bubble.narration {
417
- background: #eee; border: 2px solid #000; color: #000;
418
- border-radius: 0; font-family: 'Lato';
419
- bottom: 10px; left: 50%; transform: translateX(-50%); width: 80% !important; height: auto !important;
420
- }
421
 
422
  .resize-handle { position: absolute; bottom:-5px; right:-5px; width:15px; height:15px; background:#3498db; border:1px solid white; cursor:se-resize; display:none; }
423
  .speech-bubble.selected .resize-handle { display:block; }
@@ -448,15 +397,9 @@ INDEX_HTML = '''
448
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
449
  <label for="file-upload" class="file-label">📁 Choose Video</label>
450
  <span id="fn" style="margin-bottom:10px; display:block; color:#aaa;">No file selected</span>
451
-
452
- <div class="page-input-group">
453
- <label>📚 Total Pages:</label>
454
- <input type="number" id="page-count" value="4" min="1" max="15">
455
- </div>
456
-
457
  <button class="submit-btn" onclick="upload()">🚀 Generate</button>
458
  <button id="restore-draft-btn" class="reset-btn" style="display:none; margin-top:10px;" onclick="restoreDraft()">📂 Restore Draft</button>
459
-
460
  <div style="margin-top:20px; border-top:1px solid #555; padding-top:10px;">
461
  <input type="text" id="load-code" placeholder="ENTER SAVE CODE" style="width:70%; display:inline-block;">
462
  <button onclick="loadComic()" style="width:25%; display:inline-block; background:#9b59b6; color:white;">Load</button>
@@ -472,59 +415,23 @@ INDEX_HTML = '''
472
  <div class="tip">👉 Drag Right-Side Dots to reveal 4 panels! | 📜 Scroll to Zoom/Pan</div>
473
  <div class="comic-wrapper" id="comic-container"></div>
474
  <input type="file" id="image-uploader" style="display: none;" accept="image/*">
475
-
476
  <div class="edit-controls">
477
  <h4>✏️ Editor</h4>
478
-
479
- <div class="control-group">
480
- <button onclick="undo()" style="background:#7f8c8d; color:white;">↩️ Undo</button>
481
- <button onclick="saveComic()" class="save-btn">💾 Save Comic</button>
482
- </div>
483
-
484
  <div class="control-group">
485
  <label>💬 Bubble Styling:</label>
486
- <select id="bubble-type" onchange="updateBubbleType()">
487
- <option value="speech">Speech 💬</option>
488
- <option value="thought">Thought 💭</option>
489
- <option value="reaction">Reaction 💥</option>
490
- <option value="narration">Narration ⬜</option>
491
- </select>
492
- <select id="font-select" onchange="updateFont()">
493
- <option value="'Comic Neue', cursive">Comic Neue</option>
494
- <option value="'Bangers', cursive">Bangers</option>
495
- <option value="'Gloria Hallelujah', cursive">Handwritten</option>
496
- <option value="'Lato', sans-serif">Modern</option>
497
- </select>
498
- <div class="color-grid">
499
- <input type="color" id="bub-fill" value="#ffffff" onchange="updateColors()" title="Fill">
500
- <input type="color" id="bub-text" value="#000000" onchange="updateColors()" title="Text">
501
- </div>
502
- <div class="button-grid">
503
- <button onclick="addBubble()" class="action-btn">Add</button>
504
- <button onclick="deleteBubble()" class="reset-btn">Delete</button>
505
- </div>
506
- <input type="range" min="10" max="90" value="50" oninput="slideTail(this.value)" title="Tail Pos">
507
- </div>
508
-
509
- <div class="control-group">
510
- <label>🖼️ Image Control:</label>
511
- <button onclick="replaceImage()" class="action-btn">Replace Image</button>
512
- <div class="button-grid">
513
- <button onclick="adjustFrame('backward')" class="secondary-btn">⬅️ Frame</button>
514
- <button onclick="adjustFrame('forward')" class="action-btn">Frame ➡️</button>
515
  </div>
516
  </div>
517
-
518
- <div class="control-group">
519
- <label>🔍 Zoom (Scroll Wheel):</label>
520
- <input type="range" id="zoom-slider" min="20" max="300" value="100" step="5" oninput="handleZoom(this.value)" disabled>
521
- <button onclick="resetPanelTransform()" class="secondary-btn">Reset View</button>
522
- </div>
523
-
524
- <div class="control-group">
525
- <button onclick="exportComic()" class="action-btn" style="background:#3498db;">📥 Export PNG</button>
526
- <button onclick="location.reload()" class="reset-btn" style="margin-top:10px;">🏠 Home</button>
527
- </div>
528
  </div>
529
  </div>
530
 
@@ -551,14 +458,7 @@ INDEX_HTML = '''
551
  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%' };
552
  const bubbles = [];
553
  grid.querySelectorAll('.speech-bubble').forEach(b => {
554
- bubbles.push({
555
- text: b.querySelector('.bubble-text').textContent,
556
- left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
557
- type: b.dataset.type, font: b.style.fontFamily,
558
- colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') },
559
- tailPos: b.style.getPropertyValue('--tail-pos'),
560
- classes: b.className
561
- });
562
  });
563
  const panels = [];
564
  grid.querySelectorAll('.panel').forEach(pan => {
@@ -574,24 +474,14 @@ INDEX_HTML = '''
574
  localStorage.setItem('comic_draft_'+sid, JSON.stringify(state));
575
  }
576
 
577
- function undo() {
578
- if(historyStack.length > 1) {
579
- historyStack.pop();
580
- const prev = JSON.parse(historyStack[historyStack.length-1]);
581
- restoreFromState(prev);
582
- }
583
- }
584
-
585
  function restoreFromState(stateData) {
586
  if(!stateData) return;
587
  const pages = document.querySelectorAll('.comic-page');
588
  stateData.forEach((pgData, i) => {
589
  if(i >= pages.length) return;
590
  const grid = pages[i].querySelector('.comic-grid');
591
- if(pgData.layout) {
592
- grid.style.setProperty('--t1', pgData.layout.t1); grid.style.setProperty('--t2', pgData.layout.t2);
593
- grid.style.setProperty('--b1', pgData.layout.b1); grid.style.setProperty('--b2', pgData.layout.b2);
594
- }
595
  grid.querySelectorAll('.speech-bubble').forEach(b=>b.remove());
596
  pgData.bubbles.forEach(bData => { const b = createBubbleHTML(bData); grid.appendChild(b); });
597
  const panels = grid.querySelectorAll('.panel');
@@ -607,14 +497,7 @@ INDEX_HTML = '''
607
  }
608
 
609
  if(localStorage.getItem('comic_draft_'+sid)) document.getElementById('restore-draft-btn').style.display='inline-block';
610
- function restoreDraft() {
611
- document.getElementById('upload-container').style.display='none';
612
- document.getElementById('editor-container').style.display='block';
613
- loadNewComic().then(() => {
614
- setTimeout(() => restoreFromState(JSON.parse(localStorage.getItem('comic_draft_'+sid))), 500);
615
- });
616
- }
617
-
618
  async function upload() {
619
  const f = document.getElementById('file-upload').files[0];
620
  const pCount = document.getElementById('page-count').value;
@@ -627,7 +510,6 @@ INDEX_HTML = '''
627
  if(r.ok) interval = setInterval(checkStatus, 1500);
628
  else { const d = await r.json(); alert("Upload failed: " + d.message); location.reload(); }
629
  }
630
-
631
  async function checkStatus() {
632
  try {
633
  const r = await fetch(`/status?sid=${sid}`); const d = await r.json();
@@ -635,22 +517,15 @@ INDEX_HTML = '''
635
  if(d.progress >= 100) { clearInterval(interval); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='block'; loadNewComic(); }
636
  } catch(e) {}
637
  }
638
-
639
  async function loadNewComic() {
640
  const r = await fetch(`/output/pages.json?sid=${sid}`);
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);
651
- saveState();
652
  }
653
-
654
  function renderFromState(pagesData) {
655
  const con = document.getElementById('comic-container'); con.innerHTML = '';
656
  pagesData.forEach((page, pageIdx) => {
@@ -658,7 +533,6 @@ INDEX_HTML = '''
658
  pageWrapper.innerHTML = `<h2 class="page-title">Page ${pageIdx + 1}</h2>`;
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');
@@ -668,36 +542,18 @@ INDEX_HTML = '''
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
- if(page.bubbles) {
675
- page.bubbles.forEach((bData, bIdx) => {
676
- // Only append valid bubbles, ignore dummies
677
- if(bData.text) {
678
- const b = createBubbleHTML(bData);
679
- grid.appendChild(b);
680
- }
681
- });
682
- }
683
-
684
  div.appendChild(grid); pageWrapper.appendChild(div); con.appendChild(pageWrapper);
685
  });
686
  }
687
-
688
- function createHandle(cls, grid, varName) {
689
- let h = document.createElement('div'); h.className = `handle ${cls}`;
690
- h.onmousedown = (e) => { e.stopPropagation(); dragType = 'handle'; activeObj = { grid: grid, var: varName }; };
691
- return h;
692
- }
693
-
694
  function createBubbleHTML(data) {
695
  const b = document.createElement('div');
696
  const type = data.type || 'speech';
697
  let className = data.classes || `speech-bubble ${type} tail-bottom`;
698
  if (type === 'thought' && !className.includes('pos-')) className += ' pos-bl';
699
  b.className = className;
700
-
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,43 +561,22 @@ 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
-
709
  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); } }
710
-
711
  const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || 'Text'; b.appendChild(textSpan);
712
  const resizer = document.createElement('div'); resizer.className = 'resize-handle';
713
  resizer.onmousedown = (e) => { e.stopPropagation(); dragType='resize'; activeObj={b:b, startW:b.offsetWidth, startH:b.offsetHeight, mx:e.clientX, my:e.clientY}; };
714
  b.appendChild(resizer);
715
-
716
- // 🎯 DRAG FIX: Stop propagation to prevent selecting panel behind it
717
- b.onmousedown = (e) => {
718
- if(e.target === resizer) return;
719
- e.stopPropagation();
720
- e.preventDefault(); // Stop text selection
721
- selectBubble(b);
722
- dragType = 'bubble';
723
- activeObj = b;
724
-
725
- // Calculate offset so we drag from clicked point, not top-left jump
726
- dragStart = {
727
- x: e.clientX - b.getBoundingClientRect().left,
728
- y: e.clientY - b.getBoundingClientRect().top
729
- };
730
- };
731
  b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
732
  return b;
733
  }
734
-
735
  function editBubbleText(bubble) {
736
  const textSpan = bubble.querySelector('.bubble-text');
737
  const newText = prompt("Edit Text:", textSpan.textContent);
738
  if(newText !== null) { textSpan.textContent = newText; saveState(); }
739
  }
740
-
741
- // --- GLOBAL MOUSE EVENTS ---
742
  document.addEventListener('mousemove', (e) => {
743
  if(!dragType) return;
744
-
745
  if(dragType === 'handle') {
746
  const rect = activeObj.grid.getBoundingClientRect();
747
  let x = (e.clientX - rect.left) / rect.width * 100;
@@ -753,41 +588,20 @@ INDEX_HTML = '''
753
  img.dataset.translateY = parseFloat(img.dataset.translateY) + dy;
754
  updateImageTransform(img); dragStart = {x: e.clientX, y: e.clientY};
755
  } else if(dragType === 'bubble') {
756
- // Correct parent-relative positioning
757
- const parentRect = activeObj.parentElement.getBoundingClientRect();
758
- let newX = e.clientX - parentRect.left - dragStart.x;
759
- let newY = e.clientY - parentRect.top - dragStart.y;
760
-
761
- activeObj.style.left = newX + 'px';
762
- activeObj.style.top = newY + 'px';
763
  } else if(dragType === 'resize') {
764
  const dx = e.clientX - activeObj.mx; const dy = e.clientY - activeObj.my;
765
  activeObj.b.style.width = (activeObj.startW + dx) + 'px';
766
  activeObj.b.style.height = (activeObj.startH + dy) + 'px';
767
  }
768
  });
769
-
770
- document.addEventListener('mouseup', () => {
771
- if(activeObj && activeObj.classList) activeObj.classList.remove('panning');
772
- if(dragType) saveState();
773
- dragType = null; activeObj = null;
774
- });
775
-
776
- function selectBubble(el) {
777
- if(selectedBubble) selectedBubble.classList.remove('selected');
778
- selectedBubble = el; el.classList.add('selected');
779
- document.getElementById('bubble-type').value = el.dataset.type;
780
- document.getElementById('font-select').value = el.style.fontFamily || "'Comic Neue', cursive";
781
- }
782
- function selectPanel(el) {
783
- if(selectedPanel) selectedPanel.classList.remove('selected');
784
- selectedPanel = el; el.classList.add('selected');
785
- document.getElementById('zoom-slider').disabled = false;
786
- document.getElementById('zoom-slider').value = el.querySelector('img').dataset.zoom;
787
- }
788
  function addBubble() { const grid = document.querySelector('.comic-grid'); if(grid) { const b = createBubbleHTML({ text: "Text", left: "50%", top: "50%" }); grid.appendChild(b); selectBubble(b); saveState(); } }
789
  function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); selectedBubble=null; saveState(); } }
790
-
791
  function updateBubbleType() {
792
  if(!selectedBubble) return;
793
  const type = document.getElementById('bubble-type').value;
@@ -798,23 +612,38 @@ INDEX_HTML = '''
798
  type: type, font: oldB.style.fontFamily,
799
  colors: { fill: oldB.style.getPropertyValue('--bubble-fill'), text: oldB.style.getPropertyValue('--bubble-text') },
800
  tailPos: oldB.style.getPropertyValue('--tail-pos'),
801
- classes: oldB.className // Pass existing classes
802
  };
803
  const newB = createBubbleHTML(data);
804
  oldB.parentElement.replaceChild(newB, oldB);
805
  selectBubble(newB); saveState();
806
  }
 
 
 
807
 
808
- function updateColors() {
 
809
  if(!selectedBubble) return;
810
- selectedBubble.style.setProperty('--bubble-fill', document.getElementById('bub-fill').value);
811
- selectedBubble.style.setProperty('--bubble-text', document.getElementById('bub-text').value);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
812
  saveState();
813
  }
814
-
815
- function updateFont() { if(selectedBubble) { selectedBubble.style.fontFamily = document.getElementById('font-select').value; saveState(); } }
816
- function slideTail(val) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', val+'%'); saveState(); } }
817
-
818
  function handleZoom(val) { if(selectedPanel) { const img = selectedPanel.querySelector('img'); img.dataset.zoom = val; updateImageTransform(img); saveState(); } }
819
  function updateImageTransform(img) { const z = (img.dataset.zoom||100)/100, x = img.dataset.translateX||0, y = img.dataset.translateY||0; img.style.transform = `translateX(${x}px) translateY(${y}px) scale(${z})`; }
820
  function resetPanelTransform() { if(selectedPanel) { const img = selectedPanel.querySelector('img'); img.dataset.zoom=100; img.dataset.translateX=0; img.dataset.translateY=0; updateImageTransform(img); document.getElementById('zoom-slider').value=100; saveState(); } }
@@ -841,6 +670,18 @@ INDEX_HTML = '''
841
  if(d.success) img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
842
  img.style.opacity='1'; saveState();
843
  }
 
 
 
 
 
 
 
 
 
 
 
 
844
 
845
  async function exportComic() {
846
  const pgs = document.querySelectorAll('.comic-page');
@@ -871,7 +712,7 @@ INDEX_HTML = '''
871
  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%' };
872
  const bubbles = [];
873
  grid.querySelectorAll('.speech-bubble').forEach(b => {
874
- 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') } });
875
  });
876
  const panels = [];
877
  grid.querySelectorAll('.panel').forEach(pan => {
@@ -897,8 +738,7 @@ def upload():
897
  if not sid: return jsonify({'success': False, 'message': 'Missing session ID'}), 400
898
 
899
  file = request.files.get('file')
900
- if not file or file.filename == '':
901
- return jsonify({'success': False, 'message': 'No file uploaded'}), 400
902
 
903
  target_pages = request.form.get('target_pages', 4)
904
  gen = EnhancedComicGenerator(sid)
@@ -933,6 +773,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')
@@ -941,49 +798,31 @@ def save_comic():
941
  save_code = generate_save_code()
942
  save_dir = os.path.join(SAVED_COMICS_DIR, save_code)
943
  os.makedirs(save_dir, exist_ok=True)
944
-
945
  user_frames_dir = os.path.join(BASE_USER_DIR, sid, 'frames')
946
  saved_frames_dir = os.path.join(save_dir, 'frames')
947
-
948
  if os.path.exists(user_frames_dir):
949
  if os.path.exists(saved_frames_dir): shutil.rmtree(saved_frames_dir)
950
  shutil.copytree(user_frames_dir, saved_frames_dir)
951
-
952
- save_data = {
953
- 'code': save_code,
954
- 'originalSid': sid,
955
- 'pages': data.get('pages', []),
956
- 'savedAt': data.get('savedAt', time.strftime('%Y-%m-%d %H:%M:%S'))
957
- }
958
- with open(os.path.join(save_dir, 'comic_state.json'), 'w') as f: json.dump(save_data, f, indent=2)
959
  return jsonify({'success': True, 'code': save_code})
960
- except Exception as e:
961
- traceback.print_exc()
962
- return jsonify({'success': False, 'message': str(e)})
963
 
964
  @app.route('/load_comic/<code>')
965
  def load_comic(code):
966
  code = code.upper()
967
  save_dir = os.path.join(SAVED_COMICS_DIR, code)
968
- state_file = os.path.join(save_dir, 'comic_state.json')
969
-
970
- if not os.path.exists(state_file): return jsonify({'success': False, 'message': 'Code not found'})
971
-
972
  try:
973
- with open(state_file, 'r') as f: save_data = json.load(f)
974
- original_sid = save_data.get('originalSid')
975
- saved_frames_dir = os.path.join(save_dir, 'frames')
976
- if original_sid and os.path.exists(saved_frames_dir):
977
- user_frames_dir = os.path.join(BASE_USER_DIR, original_sid, 'frames')
978
- os.makedirs(user_frames_dir, exist_ok=True)
979
- for fname in os.listdir(saved_frames_dir):
980
- src = os.path.join(saved_frames_dir, fname)
981
- dst = os.path.join(user_frames_dir, fname)
982
- if not os.path.exists(dst): shutil.copy2(src, dst)
983
- return jsonify({ 'success': True, 'pages': save_data.get('pages', []), 'originalSid': original_sid, 'savedAt': save_data.get('savedAt') })
984
- except Exception as e:
985
- traceback.print_exc()
986
- return jsonify({'success': False, 'message': str(e)})
987
 
988
  if __name__ == '__main__':
989
  try: gpu_warmup()
 
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" # Default tail position
60
  elif type == 'thought':
61
  classes += " pos-bl"
62
 
63
  return {
64
+ 'dialog': dialog,
65
  'bubble_offset_x': int(x),
66
  'bubble_offset_y': int(y),
67
  'type': type,
 
71
  'font': "'Comic Neue', cursive"
72
  }
73
 
74
+ def panel(image=""):
75
+ return {'image': image}
76
+
77
+ class Page:
78
+ def __init__(self, panels, bubbles):
79
+ self.panels = panels
80
+ self.bubbles = bubbles
81
+
82
  # ======================================================
83
  # 🧠 GPU GENERATION
84
  # ======================================================
 
104
  if os.path.exists('test1.srt'):
105
  shutil.move('test1.srt', user_srt)
106
  elif not os.path.exists(user_srt):
107
+ with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
108
  except:
109
+ with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
110
 
111
  with open(user_srt, 'r', encoding='utf-8') as f:
112
  try: all_subs = list(srt.parse(f.read()))
113
  except: all_subs = []
114
 
115
+ valid_subs = [s for s in all_subs if s.content.strip()]
116
  if valid_subs:
117
  raw_moments = [{'text': s.content.strip(), 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
118
  else:
119
  raw_moments = []
120
 
 
121
  panels_per_page = 4
122
  total_panels_needed = int(target_pages) * panels_per_page
123
 
124
  selected_moments = []
125
  if not raw_moments:
 
126
  times = np.linspace(1, max(1, duration-1), total_panels_needed)
127
+ for t in times: selected_moments.append({'text': '', 'start': t, 'end': t+1})
128
  elif len(raw_moments) <= total_panels_needed:
129
  selected_moments = raw_moments
130
  else:
 
141
  cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
142
  ret, frame = cap.read()
143
  if ret:
144
+ # 🎯 SQUARE PADDING (0% Cut)
145
  h, w = frame.shape[:2]
146
  sq_dim = max(h, w)
147
  square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
148
  x_off = (sq_dim - w) // 2
149
  y_off = (sq_dim - h) // 2
150
  square_img[y_off:y_off+h, x_off:x_off+w] = frame
 
151
  square_img = cv2.resize(square_img, (1024, 1024))
152
 
153
  fname = f"frame_{count:04d}.png"
154
  p = os.path.join(frames_dir, fname)
155
  cv2.imwrite(p, square_img)
156
 
157
+ frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
 
 
158
  frame_files_ordered.append(fname)
159
  count += 1
160
  cap.release()
 
163
 
164
  bubbles_list = []
165
  for i, f in enumerate(frame_files_ordered):
166
+ dialogue = frame_metadata.get(f, {}).get('dialogue', '')
167
 
 
168
  b_type = 'speech'
169
+ if '(' in dialogue: b_type = 'narration'
170
+ elif '!' in dialogue: b_type = 'reaction'
171
+ elif '?' in dialogue: b_type = 'speech'
 
 
 
172
 
173
+ # 1 Bubble Per Panel Placement
174
  pos_idx = i % 4
175
+ if pos_idx == 0: bx, by = 150, 50
176
+ elif pos_idx == 1: bx, by = 550, 50
177
+ elif pos_idx == 2: bx, by = 150, 450
178
+ elif pos_idx == 3: bx, by = 550, 450
179
  else: bx, by = 50, 50
180
+
181
  bubbles_list.append(bubble(dialog=dialogue, x=bx, y=by, type=b_type))
182
 
183
  pages = []
 
195
  p_bubbles.append(bubble(dialog="", x=-999, y=-999, type='speech'))
196
 
197
  if p_frames:
198
+ pg_panels = [panel(image=f) for f in p_frames]
199
+ pages.append(Page(panels=pg_panels, bubbles=p_bubbles))
200
+
201
+ result = []
202
+ for pg in pages:
203
+ p_data = [p if isinstance(p, dict) else p.__dict__ for p in pg.panels]
204
+ b_data = [b if isinstance(b, dict) else b.__dict__ for b in pg.bubbles]
205
+ result.append({'panels': p_data, 'bubbles': b_data})
206
+
207
+ return result
208
 
209
  @spaces.GPU
210
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
 
267
  return {"success": True, "message": f"Jumped to {ts}s"}
268
  return {"success": False, "message": "Invalid timestamp"}
269
 
 
 
 
270
  class EnhancedComicGenerator:
271
  def __init__(self, sid):
272
  self.sid = sid
 
304
  # ======================================================
305
  INDEX_HTML = '''
306
  <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Square HD Comic</title> <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script> <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Gloria+Hallelujah&family=Lato&display=swap" rel="stylesheet"> <style> * { box-sizing: border-box; } body { background-color: #2c3e50; font-family: 'Lato', sans-serif; color: #eee; margin: 0; min-height: 100vh; }
 
307
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
308
  .upload-box { max-width: 500px; width: 100%; padding: 40px; background: #34495e; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); text-align: center; }
 
309
  #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 150px; }
310
 
311
+ .comic-page { width: 800px; height: 800px; background: white; box-shadow: 0 5px 30px rgba(0,0,0,0.6); position: relative; overflow: hidden; border: 6px solid #000; }
312
+ .comic-grid { width: 100%; height: 100%; position: relative; background: #000; --y: 50%; --t1: 100%; --t2: 100%; --b1: 100%; --b2: 100%; --gap: 3px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  .panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
314
+ .panel img { width: 100%; height: 100%; object-fit: cover; transform-origin: center; transition: transform 0.05s ease-out; display: block; }
 
 
 
 
 
 
 
 
315
  .panel img.panning { cursor: grabbing; transition: none; }
316
  .panel.selected { outline: 4px solid #3498db; z-index: 5; }
317
 
 
318
  .panel:nth-child(1) { clip-path: polygon(0 0, calc(var(--t1) - var(--gap)) 0, calc(var(--t2) - var(--gap)) calc(var(--y) - var(--gap)), 0 calc(var(--y) - var(--gap))); z-index: 1; }
319
  .panel:nth-child(2) { clip-path: polygon(calc(var(--t1) + var(--gap)) 0, 100% 0, 100% calc(var(--y) - var(--gap)), calc(var(--t2) + var(--gap)) calc(var(--y) - var(--gap))); z-index: 1; }
320
  .panel:nth-child(3) { clip-path: polygon(0 calc(var(--y) + var(--gap)), calc(var(--b1) - var(--gap)) calc(var(--y) + var(--gap)), calc(var(--b2) - var(--gap)) 100%, 0 100%); z-index: 1; }
321
  .panel:nth-child(4) { clip-path: polygon(calc(var(--b1) + var(--gap)) calc(var(--y) + var(--gap)), 100% calc(var(--y) + var(--gap)), 100% 100%, calc(var(--b2) + var(--gap)) 100%); z-index: 1; }
322
 
 
323
  .handle { position: absolute; width: 26px; height: 26px; border: 3px solid white; border-radius: 50%; transform: translate(-50%, -50%); z-index: 101; cursor: ew-resize; box-shadow: 0 2px 5px rgba(0,0,0,0.8); }
324
+ .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; } .h-t2 { background: #3498db; left: var(--t2); top: 50%; margin-top: -15px; }
325
+ .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; } .h-b2 { background: #2ecc71; left: var(--b2); top: 100%; margin-top: -15px; }
 
 
 
326
 
327
+ /* === ADVANCED BUBBLE CSS (WITH ROTATION) === */
328
  .speech-bubble {
329
  position: absolute; display: flex; justify-content: center; align-items: center;
330
  min-width: 60px; min-height: 40px; box-sizing: border-box;
331
  z-index: 10; cursor: move; font-weight: bold; text-align: center;
332
  overflow: visible; line-height: 1.2; --tail-pos: 50%;
333
  }
334
+ .bubble-text { padding: 0.8em; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; border-radius: inherit; pointer-events: none; }
 
 
 
 
335
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
336
 
337
+ /* Speech Bubble + Rotation Classes */
338
  .speech-bubble.speech {
339
  --b: 3em; --h: 1.8em; --t: 0.6; --p: var(--tail-pos, 50%); --r: 1.2em;
340
+ background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); padding: 0;
 
 
341
  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);
342
  }
343
  .speech-bubble.speech:before {
 
346
  -webkit-mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
347
  mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
348
  }
349
+ /* Tail Orientations */
350
  .speech-bubble.speech.tail-bottom:before { top: 100%; left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); }
351
 
352
+ .speech-bubble.speech.tail-top { border-radius: min(var(--r), calc(var(--p) - (1 - var(--t)) * var(--b) / 2)) min(var(--r), calc(100% - var(--p) - (1 - var(--t)) * var(--b) / 2)) var(--r) var(--r) / var(--r); }
353
+ .speech-bubble.speech.tail-top:before { bottom: 100%; left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); transform: scaleY(-1); }
354
+
355
+ .speech-bubble.speech.tail-left { border-radius: var(--r); }
356
+ .speech-bubble.speech.tail-left:before { right: 100%; top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); transform: rotate(90deg); transform-origin: top right; }
357
+
358
+ .speech-bubble.speech.tail-right { border-radius: var(--r); }
359
+ .speech-bubble.speech.tail-right:before { left: 100%; top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); transform: rotate(-90deg); transform-origin: top left; }
360
+
361
+ /* Thought & Others */
362
  .speech-bubble.thought { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 2px dashed #555; border-radius: 50%; }
363
  .speech-bubble.thought::before { display:none; }
364
  .thought-dot { position: absolute; background-color: var(--bubble-fill, #fff); border: 2px solid #555; border-radius: 50%; z-index: -1; }
365
+ .thought-dot-1 { width: 15px; height: 15px; bottom:-15px; left:20px; } .thought-dot-2 { width: 10px; height: 10px; bottom:-25px; left:10px; }
 
366
  .speech-bubble.thought.pos-bl .thought-dot-1 { left: 20px; bottom: -20px; }
367
 
368
+ .speech-bubble.reaction { background: #FFD700; border: 3px solid #E53935; color: #D32F2F; font-family: 'Bangers'; text-transform: uppercase; clip-path: polygon(0% 25%, 17% 21%, 17% 0%, 31% 16%, 50% 4%, 69% 16%, 83% 0%, 83% 21%, 100% 25%, 85% 45%, 95% 62%, 82% 79%, 100% 97%, 79% 89%, 60% 98%, 46% 82%, 27% 95%, 15% 78%, 5% 62%, 15% 45%); }
369
+ .speech-bubble.narration { background: #eee; border: 2px solid #000; color: #000; border-radius: 0; font-family: 'Lato'; bottom: 10px; left: 50%; transform: translateX(-50%); width: 80% !important; height: auto !important; }
 
 
 
 
 
 
 
 
 
 
 
370
 
371
  .resize-handle { position: absolute; bottom:-5px; right:-5px; width:15px; height:15px; background:#3498db; border:1px solid white; cursor:se-resize; display:none; }
372
  .speech-bubble.selected .resize-handle { display:block; }
 
397
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
398
  <label for="file-upload" class="file-label">📁 Choose Video</label>
399
  <span id="fn" style="margin-bottom:10px; display:block; color:#aaa;">No file selected</span>
400
+ <div class="page-input-group"> <label>📚 Total Pages:</label> <input type="number" id="page-count" value="4" min="1" max="15"> </div>
 
 
 
 
 
401
  <button class="submit-btn" onclick="upload()">🚀 Generate</button>
402
  <button id="restore-draft-btn" class="reset-btn" style="display:none; margin-top:10px;" onclick="restoreDraft()">📂 Restore Draft</button>
 
403
  <div style="margin-top:20px; border-top:1px solid #555; padding-top:10px;">
404
  <input type="text" id="load-code" placeholder="ENTER SAVE CODE" style="width:70%; display:inline-block;">
405
  <button onclick="loadComic()" style="width:25%; display:inline-block; background:#9b59b6; color:white;">Load</button>
 
415
  <div class="tip">👉 Drag Right-Side Dots to reveal 4 panels! | 📜 Scroll to Zoom/Pan</div>
416
  <div class="comic-wrapper" id="comic-container"></div>
417
  <input type="file" id="image-uploader" style="display: none;" accept="image/*">
 
418
  <div class="edit-controls">
419
  <h4>✏️ Editor</h4>
420
+ <div class="control-group"> <button onclick="undo()" style="background:#7f8c8d; color:white;">↩️ Undo</button> <button onclick="saveComic()" class="save-btn">💾 Save Comic</button> </div>
 
 
 
 
 
421
  <div class="control-group">
422
  <label>💬 Bubble Styling:</label>
423
+ <select id="bubble-type" onchange="updateBubbleType()"> <option value="speech">Speech 💬</option> <option value="thought">Thought 💭</option> <option value="reaction">Reaction 💥</option> <option value="narration">Narration ⬜</option> </select>
424
+ <select id="font-select" onchange="updateFont()"> <option value="'Comic Neue', cursive">Comic Neue</option> <option value="'Bangers', cursive">Bangers</option> <option value="'Gloria Hallelujah', cursive">Handwritten</option> <option value="'Lato', sans-serif">Modern</option> </select>
425
+ <div class="color-grid"> <input type="color" id="bub-fill" value="#ffffff" onchange="updateColors()" title="Fill"> <input type="color" id="bub-text" value="#000000" onchange="updateColors()" title="Text"> </div>
426
+ <div class="button-grid"> <button onclick="addBubble()" class="action-btn">Add</button> <button onclick="deleteBubble()" class="reset-btn">Delete</button> </div>
427
+ <div id="tail-controls">
428
+ <button onclick="rotateTail()" class="secondary-btn" style="margin-top:5px;">🔄 Rotate Tail Side</button>
429
+ <input type="range" min="10" max="90" value="50" oninput="slideTail(this.value)" title="Tail Pos">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  </div>
431
  </div>
432
+ <div class="control-group"> <label>🖼️ Image Control:</label> <button onclick="replaceImage()" class="action-btn">Replace Image</button> <div class="button-grid"> <button onclick="adjustFrame('backward')" class="secondary-btn">⬅️ Frame</button> <button onclick="adjustFrame('forward')" class="action-btn">Frame ➡️</button> </div> </div>
433
+ <div class="control-group"> <label>🔍 Zoom (Scroll Wheel):</label> <input type="range" id="zoom-slider" min="20" max="300" value="100" step="5" oninput="handleZoom(this.value)" disabled> <button onclick="resetPanelTransform()" class="secondary-btn">Reset View</button> </div>
434
+ <div class="control-group"> <button onclick="exportComic()" class="action-btn" style="background:#3498db;">📥 Export PNG</button> <button onclick="location.reload()" class="reset-btn" style="margin-top:10px;">🏠 Home</button> </div>
 
 
 
 
 
 
 
 
435
  </div>
436
  </div>
437
 
 
458
  const layout = { t1: grid.style.getPropertyValue('--t1')||'100%', t2: grid.style.getPropertyValue('--t2')||'100%', b1: grid.style.getPropertyValue('--b1')||'100%', b2: grid.style.getPropertyValue('--b2')||'100%' };
459
  const bubbles = [];
460
  grid.querySelectorAll('.speech-bubble').forEach(b => {
461
+ bubbles.push({ text: b.querySelector('.bubble-text').textContent, left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height, type: b.dataset.type, font: b.style.fontFamily, colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') }, tailPos: b.style.getPropertyValue('--tail-pos'), classes: b.className });
 
 
 
 
 
 
 
462
  });
463
  const panels = [];
464
  grid.querySelectorAll('.panel').forEach(pan => {
 
474
  localStorage.setItem('comic_draft_'+sid, JSON.stringify(state));
475
  }
476
 
477
+ function undo() { if(historyStack.length > 1) { historyStack.pop(); const prev = JSON.parse(historyStack[historyStack.length-1]); restoreFromState(prev); } }
 
 
 
 
 
 
 
478
  function restoreFromState(stateData) {
479
  if(!stateData) return;
480
  const pages = document.querySelectorAll('.comic-page');
481
  stateData.forEach((pgData, i) => {
482
  if(i >= pages.length) return;
483
  const grid = pages[i].querySelector('.comic-grid');
484
+ if(pgData.layout) { grid.style.setProperty('--t1', pgData.layout.t1); grid.style.setProperty('--t2', pgData.layout.t2); grid.style.setProperty('--b1', pgData.layout.b1); grid.style.setProperty('--b2', pgData.layout.b2); }
 
 
 
485
  grid.querySelectorAll('.speech-bubble').forEach(b=>b.remove());
486
  pgData.bubbles.forEach(bData => { const b = createBubbleHTML(bData); grid.appendChild(b); });
487
  const panels = grid.querySelectorAll('.panel');
 
497
  }
498
 
499
  if(localStorage.getItem('comic_draft_'+sid)) document.getElementById('restore-draft-btn').style.display='inline-block';
500
+ function restoreDraft() { document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='block'; loadNewComic().then(() => { setTimeout(() => restoreFromState(JSON.parse(localStorage.getItem('comic_draft_'+sid))), 500); }); }
 
 
 
 
 
 
 
501
  async function upload() {
502
  const f = document.getElementById('file-upload').files[0];
503
  const pCount = document.getElementById('page-count').value;
 
510
  if(r.ok) interval = setInterval(checkStatus, 1500);
511
  else { const d = await r.json(); alert("Upload failed: " + d.message); location.reload(); }
512
  }
 
513
  async function checkStatus() {
514
  try {
515
  const r = await fetch(`/status?sid=${sid}`); const d = await r.json();
 
517
  if(d.progress >= 100) { clearInterval(interval); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='block'; loadNewComic(); }
518
  } catch(e) {}
519
  }
 
520
  async function loadNewComic() {
521
  const r = await fetch(`/output/pages.json?sid=${sid}`);
522
  const data = await r.json();
523
  const cleanData = data.map(p => ({
524
  panels: p.panels.map(pan => ({ src: `/frames/${pan.image}?sid=${sid}` })),
525
+ bubbles: p.bubbles.map(b => ({ text: b.dialog, left: (b.bubble_offset_x||50)+'px', top: (b.bubble_offset_y||20)+'px', type: b.type, colors: b.colors, font: b.font, classes: b.classes, tailPos: b.tail_pos }))
 
 
 
 
526
  }));
527
+ renderFromState(cleanData); saveState();
 
528
  }
 
529
  function renderFromState(pagesData) {
530
  const con = document.getElementById('comic-container'); con.innerHTML = '';
531
  pagesData.forEach((page, pageIdx) => {
 
533
  pageWrapper.innerHTML = `<h2 class="page-title">Page ${pageIdx + 1}</h2>`;
534
  const div = document.createElement('div'); div.className = 'comic-page';
535
  const grid = document.createElement('div'); grid.className = 'comic-grid';
 
536
  page.panels.forEach((pan, idx) => {
537
  const pDiv = document.createElement('div'); pDiv.className = 'panel';
538
  const img = document.createElement('img');
 
542
  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(); };
543
  pDiv.appendChild(img); grid.appendChild(pDiv);
544
  });
 
545
  grid.append(createHandle('h-t1', grid, 't1'), createHandle('h-t2', grid, 't2'), createHandle('h-b1', grid, 'b1'), createHandle('h-b2', grid, 'b2'));
546
+ if(page.bubbles) { page.bubbles.forEach((bData, bIdx) => { if(bData.text) { const b = createBubbleHTML(bData); grid.appendChild(b); } }); }
 
 
 
 
 
 
 
 
 
 
547
  div.appendChild(grid); pageWrapper.appendChild(div); con.appendChild(pageWrapper);
548
  });
549
  }
550
+ function createHandle(cls, grid, varName) { let h = document.createElement('div'); h.className = `handle ${cls}`; h.onmousedown = (e) => { e.stopPropagation(); dragType = 'handle'; activeObj = { grid: grid, var: varName }; }; return h; }
 
 
 
 
 
 
551
  function createBubbleHTML(data) {
552
  const b = document.createElement('div');
553
  const type = data.type || 'speech';
554
  let className = data.classes || `speech-bubble ${type} tail-bottom`;
555
  if (type === 'thought' && !className.includes('pos-')) className += ' pos-bl';
556
  b.className = className;
 
557
  b.dataset.type = type;
558
  b.style.left = data.left; b.style.top = data.top;
559
  if(data.width) b.style.width = data.width;
 
561
  if(data.font) b.style.fontFamily = data.font;
562
  if(data.colors) { b.style.setProperty('--bubble-fill', data.colors.fill); b.style.setProperty('--bubble-text', data.colors.text); }
563
  if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
 
564
  if(type === 'thought') { for(let i=1; i<=2; i++){ const d = document.createElement('div'); d.className = `thought-dot thought-dot-${i}`; b.appendChild(d); } }
 
565
  const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || 'Text'; b.appendChild(textSpan);
566
  const resizer = document.createElement('div'); resizer.className = 'resize-handle';
567
  resizer.onmousedown = (e) => { e.stopPropagation(); dragType='resize'; activeObj={b:b, startW:b.offsetWidth, startH:b.offsetHeight, mx:e.clientX, my:e.clientY}; };
568
  b.appendChild(resizer);
569
+ b.onmousedown = (e) => { if(e.target === resizer) return; e.stopPropagation(); selectBubble(b); dragType = 'bubble'; activeObj = b; dragStart = {x: e.clientX, y: e.clientY}; };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
571
  return b;
572
  }
 
573
  function editBubbleText(bubble) {
574
  const textSpan = bubble.querySelector('.bubble-text');
575
  const newText = prompt("Edit Text:", textSpan.textContent);
576
  if(newText !== null) { textSpan.textContent = newText; saveState(); }
577
  }
 
 
578
  document.addEventListener('mousemove', (e) => {
579
  if(!dragType) return;
 
580
  if(dragType === 'handle') {
581
  const rect = activeObj.grid.getBoundingClientRect();
582
  let x = (e.clientX - rect.left) / rect.width * 100;
 
588
  img.dataset.translateY = parseFloat(img.dataset.translateY) + dy;
589
  updateImageTransform(img); dragStart = {x: e.clientX, y: e.clientY};
590
  } else if(dragType === 'bubble') {
591
+ const rect = activeObj.parentElement.getBoundingClientRect();
592
+ activeObj.style.left = (e.clientX - rect.left - (activeObj.offsetWidth/2)) + 'px';
593
+ activeObj.style.top = (e.clientY - rect.top - (activeObj.offsetHeight/2)) + 'px';
 
 
 
 
594
  } else if(dragType === 'resize') {
595
  const dx = e.clientX - activeObj.mx; const dy = e.clientY - activeObj.my;
596
  activeObj.b.style.width = (activeObj.startW + dx) + 'px';
597
  activeObj.b.style.height = (activeObj.startH + dy) + 'px';
598
  }
599
  });
600
+ document.addEventListener('mouseup', () => { if(activeObj && activeObj.classList) activeObj.classList.remove('panning'); if(dragType) saveState(); dragType = null; activeObj = null; });
601
+ function selectBubble(el) { if(selectedBubble) selectedBubble.classList.remove('selected'); selectedBubble = el; el.classList.add('selected'); document.getElementById('bubble-type').value = el.dataset.type; document.getElementById('font-select').value = el.style.fontFamily || "'Comic Neue', cursive"; }
602
+ function selectPanel(el) { if(selectedPanel) selectedPanel.classList.remove('selected'); selectedPanel = el; el.classList.add('selected'); document.getElementById('zoom-slider').disabled = false; document.getElementById('zoom-slider').value = el.querySelector('img').dataset.zoom; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
  function addBubble() { const grid = document.querySelector('.comic-grid'); if(grid) { const b = createBubbleHTML({ text: "Text", left: "50%", top: "50%" }); grid.appendChild(b); selectBubble(b); saveState(); } }
604
  function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); selectedBubble=null; saveState(); } }
 
605
  function updateBubbleType() {
606
  if(!selectedBubble) return;
607
  const type = document.getElementById('bubble-type').value;
 
612
  type: type, font: oldB.style.fontFamily,
613
  colors: { fill: oldB.style.getPropertyValue('--bubble-fill'), text: oldB.style.getPropertyValue('--bubble-text') },
614
  tailPos: oldB.style.getPropertyValue('--tail-pos'),
615
+ classes: oldB.className
616
  };
617
  const newB = createBubbleHTML(data);
618
  oldB.parentElement.replaceChild(newB, oldB);
619
  selectBubble(newB); saveState();
620
  }
621
+ function updateColors() { if(!selectedBubble) return; selectedBubble.style.setProperty('--bubble-fill', document.getElementById('bub-fill').value); selectedBubble.style.setProperty('--bubble-text', document.getElementById('bub-text').value); saveState(); }
622
+ function updateFont() { if(selectedBubble) { selectedBubble.style.fontFamily = document.getElementById('font-select').value; saveState(); } }
623
+ function slideTail(val) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', val+'%'); saveState(); } }
624
 
625
+ // 🎯 NEW ROTATE TAIL FUNCTION
626
+ function rotateTail() {
627
  if(!selectedBubble) return;
628
+ const type = selectedBubble.dataset.type;
629
+
630
+ if(type === 'speech') {
631
+ const positions = ['tail-bottom', 'tail-right', 'tail-top', 'tail-left'];
632
+ // Find current class match
633
+ let current = positions.find(pos => selectedBubble.classList.contains(pos)) || 'tail-bottom';
634
+ selectedBubble.classList.remove(current);
635
+ // Cycle
636
+ let nextIndex = (positions.indexOf(current) + 1) % 4;
637
+ selectedBubble.classList.add(positions[nextIndex]);
638
+ }
639
+ else if (type === 'thought') {
640
+ // Logic for thought bubble (flipping dots) if needed, or simple rotation
641
+ // For now, let's allow thought bubbles to use the same tail classes if CSS supports it,
642
+ // or just flip positions.
643
+ }
644
  saveState();
645
  }
646
+
 
 
 
647
  function handleZoom(val) { if(selectedPanel) { const img = selectedPanel.querySelector('img'); img.dataset.zoom = val; updateImageTransform(img); saveState(); } }
648
  function updateImageTransform(img) { const z = (img.dataset.zoom||100)/100, x = img.dataset.translateX||0, y = img.dataset.translateY||0; img.style.transform = `translateX(${x}px) translateY(${y}px) scale(${z})`; }
649
  function resetPanelTransform() { if(selectedPanel) { const img = selectedPanel.querySelector('img'); img.dataset.zoom=100; img.dataset.translateX=0; img.dataset.translateY=0; updateImageTransform(img); document.getElementById('zoom-slider').value=100; saveState(); } }
 
670
  if(d.success) img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
671
  img.style.opacity='1'; saveState();
672
  }
673
+ async function gotoTimestamp() {
674
+ if(!selectedPanel) return alert("Select a panel");
675
+ let v = document.getElementById('timestamp-input').value.trim();
676
+ if(!v) return;
677
+ if(v.includes(':')) { let p = v.split(':'); v = parseInt(p[0]) * 60 + parseFloat(p[1]); } else { v = parseFloat(v); }
678
+ const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0];
679
+ img.style.opacity = '0.5';
680
+ const r = await fetch(`/goto_timestamp?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, timestamp:v}) });
681
+ const d = await r.json();
682
+ if(d.success) img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
683
+ img.style.opacity='1'; saveState();
684
+ }
685
 
686
  async function exportComic() {
687
  const pgs = document.querySelectorAll('.comic-page');
 
712
  const layout = { t1: grid.style.getPropertyValue('--t1')||'100%', t2: grid.style.getPropertyValue('--t2')||'100%', b1: grid.style.getPropertyValue('--b1')||'100%', b2: grid.style.getPropertyValue('--b2')||'100%' };
713
  const bubbles = [];
714
  grid.querySelectorAll('.speech-bubble').forEach(b => {
715
+ bubbles.push({ text: b.querySelector('.bubble-text').textContent, left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height, type: b.dataset.type, colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') }, tailPos: b.style.getPropertyValue('--tail-pos'), classes: b.className });
716
  });
717
  const panels = [];
718
  grid.querySelectorAll('.panel').forEach(pan => {
 
738
  if not sid: return jsonify({'success': False, 'message': 'Missing session ID'}), 400
739
 
740
  file = request.files.get('file')
741
+ if not file or file.filename == '': return jsonify({'success': False, 'message': 'No file uploaded'}), 400
 
742
 
743
  target_pages = request.form.get('target_pages', 4)
744
  gen = EnhancedComicGenerator(sid)
 
773
  gen = EnhancedComicGenerator(sid)
774
  return jsonify(regen_frame_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], d['direction']))
775
 
776
+ @app.route('/goto_timestamp', methods=['POST'])
777
+ def go_time():
778
+ sid = request.args.get('sid')
779
+ d = request.get_json()
780
+ gen = EnhancedComicGenerator(sid)
781
+ return jsonify(get_frame_at_ts_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], float(d['timestamp'])))
782
+
783
+ @app.route('/replace_panel', methods=['POST'])
784
+ def rep_panel():
785
+ sid = request.args.get('sid')
786
+ f = request.files['image']
787
+ frames_dir = os.path.join(BASE_USER_DIR, sid, 'frames')
788
+ os.makedirs(frames_dir, exist_ok=True)
789
+ fname = f"replaced_{int(time.time() * 1000)}.png"
790
+ f.save(os.path.join(frames_dir, fname))
791
+ return jsonify({'success': True, 'new_filename': fname})
792
+
793
  @app.route('/save_comic', methods=['POST'])
794
  def save_comic():
795
  sid = request.args.get('sid')
 
798
  save_code = generate_save_code()
799
  save_dir = os.path.join(SAVED_COMICS_DIR, save_code)
800
  os.makedirs(save_dir, exist_ok=True)
 
801
  user_frames_dir = os.path.join(BASE_USER_DIR, sid, 'frames')
802
  saved_frames_dir = os.path.join(save_dir, 'frames')
 
803
  if os.path.exists(user_frames_dir):
804
  if os.path.exists(saved_frames_dir): shutil.rmtree(saved_frames_dir)
805
  shutil.copytree(user_frames_dir, saved_frames_dir)
806
+ with open(os.path.join(save_dir, 'comic_state.json'), 'w') as f:
807
+ json.dump({'originalSid': sid, 'pages': data['pages'], 'savedAt': time.time()}, f)
 
 
 
 
 
 
808
  return jsonify({'success': True, 'code': save_code})
809
+ except Exception as e: return jsonify({'success': False, 'message': str(e)})
 
 
810
 
811
  @app.route('/load_comic/<code>')
812
  def load_comic(code):
813
  code = code.upper()
814
  save_dir = os.path.join(SAVED_COMICS_DIR, code)
815
+ if not os.path.exists(save_dir): return jsonify({'success': False, 'message': 'Code not found'})
 
 
 
816
  try:
817
+ with open(os.path.join(save_dir, 'comic_state.json'), 'r') as f: data = json.load(f)
818
+ orig_sid = data['originalSid']
819
+ saved_frames = os.path.join(save_dir, 'frames')
820
+ user_frames = os.path.join(BASE_USER_DIR, orig_sid, 'frames')
821
+ os.makedirs(user_frames, exist_ok=True)
822
+ for fn in os.listdir(saved_frames):
823
+ shutil.copy2(os.path.join(saved_frames, fn), os.path.join(user_frames, fn))
824
+ return jsonify({'success': True, 'originalSid': orig_sid, 'pages': data['pages']})
825
+ except Exception as e: return jsonify({'success': False, 'message': str(e)})
 
 
 
 
 
826
 
827
  if __name__ == '__main__':
828
  try: gpu_warmup()