tester343 commited on
Commit
24655fa
·
verified ·
1 Parent(s): ec587d0

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +160 -458
app_enhanced.py CHANGED
@@ -57,7 +57,9 @@ def bubble(dialog="", x=50, y=20, type='speech'):
57
  if type == 'speech':
58
  classes += " tail-bottom"
59
  elif type == 'thought':
60
- classes += " pos-bl"
 
 
61
 
62
  return {
63
  'dialog': dialog,
@@ -70,8 +72,8 @@ def bubble(dialog="", x=50, y=20, type='speech'):
70
  'font': "'Comic Neue', cursive"
71
  }
72
 
73
- def panel(image=""):
74
- return {'image': image}
75
 
76
  class Page:
77
  def __init__(self, panels, bubbles):
@@ -83,39 +85,33 @@ class Page:
83
  # ======================================================
84
  @spaces.GPU(duration=300)
85
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
86
- print(f"🚀 Generating HD Comic: {video_path}")
87
  import cv2
88
  import srt
89
  import numpy as np
90
  from backend.subtitles.subs_real import get_real_subtitles
91
 
92
  cap = cv2.VideoCapture(video_path)
93
- if not cap.isOpened():
94
- raise Exception("Cannot open video")
95
-
96
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
97
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
98
  duration = total_frames / fps
99
  cap.release()
100
 
101
- # Subtitles Logic (FIXED SYNTAX)
102
  user_srt = os.path.join(user_dir, 'subs.srt')
103
  try:
104
  get_real_subtitles(video_path)
105
  if os.path.exists('test1.srt'):
106
  shutil.move('test1.srt', user_srt)
107
  elif not os.path.exists(user_srt):
108
- with open(user_srt, 'w') as f:
109
- f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
110
  except:
111
- with open(user_srt, 'w') as f:
112
- 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:
116
- all_subs = list(srt.parse(f.read()))
117
- except:
118
- all_subs = []
119
 
120
  valid_subs = [s for s in all_subs if s.content.strip()]
121
  if valid_subs:
@@ -130,8 +126,7 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
130
  selected_moments = []
131
  if not raw_moments:
132
  times = np.linspace(1, max(1, duration-1), total_panels_needed)
133
- for t in times:
134
- selected_moments.append({'text': '', 'start': t, 'end': t+1})
135
  elif len(raw_moments) <= total_panels_needed:
136
  selected_moments = raw_moments
137
  else:
@@ -142,39 +137,44 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
142
  cap = cv2.VideoCapture(video_path)
143
  count = 0
144
  frame_files_ordered = []
 
145
 
146
  for i, moment in enumerate(selected_moments):
147
  mid = (moment['start'] + moment['end']) / 2
148
  cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
149
  ret, frame = cap.read()
150
  if ret:
151
- # 🎯 EXTRACT FULL HD (1280x720) - NO CROP
152
- frame = cv2.resize(frame, (1280, 720))
 
 
153
  fname = f"frame_{count:04d}.png"
154
  p = os.path.join(frames_dir, fname)
155
  cv2.imwrite(p, frame)
 
156
  frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
157
  frame_files_ordered.append(fname)
 
158
  count += 1
159
  cap.release()
160
 
161
- with open(metadata_path, 'w') as f:
162
- json.dump(frame_metadata, f, indent=2)
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
 
172
- # 1 Bubble Per Panel Placement
 
173
  pos_idx = i % 4
174
  if pos_idx == 0: bx, by = 150, 50
175
- elif pos_idx == 1: bx, by = 550, 50
176
- elif pos_idx == 2: bx, by = 150, 450
177
- elif pos_idx == 3: bx, by = 550, 450
178
  else: bx, by = 50, 50
179
 
180
  bubbles_list.append(bubble(dialog=dialogue, x=bx, y=by, type=b_type))
@@ -184,37 +184,37 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
184
  start_idx = i * 4
185
  end_idx = start_idx + 4
186
  p_frames = frame_files_ordered[start_idx:end_idx]
 
187
  p_bubbles = bubbles_list[start_idx:end_idx]
188
 
189
  while len(p_frames) < 4:
190
  fname = f"empty_{i}_{len(p_frames)}.png"
191
- img = np.zeros((720, 1280, 3), dtype=np.uint8)
192
- img[:] = (30,30,30)
193
  cv2.imwrite(os.path.join(frames_dir, fname), img)
194
  p_frames.append(fname)
 
195
  p_bubbles.append(bubble(dialog="", 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):
211
  import cv2
212
  import json
213
- if not os.path.exists(metadata_path):
214
- return {"success": False, "message": "No metadata"}
215
-
216
- with open(metadata_path, 'r') as f:
217
- meta = json.load(f)
218
 
219
  t = meta[fname]['time'] if isinstance(meta[fname], dict) else meta[fname]
220
  cap = cv2.VideoCapture(video_path)
@@ -227,15 +227,12 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
227
  cap.release()
228
 
229
  if ret:
230
- frame = cv2.resize(frame, (1280, 720))
231
  cv2.imwrite(os.path.join(frames_dir, fname), frame)
232
- if isinstance(meta[fname], dict):
233
- meta[fname]['time'] = new_t
234
- else:
235
- meta[fname] = new_t
236
- with open(metadata_path, 'w') as f:
237
- json.dump(meta, f, indent=2)
238
- return {"success": True, "message": f"Time: {new_t:.2f}s"}
239
  return {"success": False}
240
 
241
  @spaces.GPU
@@ -248,20 +245,16 @@ def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
248
  cap.release()
249
 
250
  if ret:
251
- frame = cv2.resize(frame, (1280, 720))
252
  cv2.imwrite(os.path.join(frames_dir, fname), frame)
253
  if os.path.exists(metadata_path):
254
- with open(metadata_path, 'r') as f:
255
- meta = json.load(f)
256
  if fname in meta:
257
- if isinstance(meta[fname], dict):
258
- meta[fname]['time'] = float(ts)
259
- else:
260
- meta[fname] = float(ts)
261
- with open(metadata_path, 'w') as f:
262
- json.dump(meta, f, indent=2)
263
- return {"success": True, "message": f"Jumped to {ts}s"}
264
- return {"success": False}
265
 
266
  # ======================================================
267
  # 💻 BACKEND CLASS
@@ -299,29 +292,19 @@ class EnhancedComicGenerator:
299
  json.dump({'message': msg, 'progress': prog}, f)
300
 
301
  # ======================================================
302
- # 🌐 ROUTES & FRONTEND
303
  # ======================================================
304
  INDEX_HTML = '''
305
- <!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; }
306
 
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
 
310
- /* 🎯 CENTER THE COMIC EDITOR */
311
- #editor-container {
312
- display: none;
313
- padding: 20px;
314
- width: 100%;
315
- box-sizing: border-box;
316
- padding-bottom: 150px;
317
- flex-direction: column;
318
- align-items: center; /* Centers comic horizontally */
319
- }
320
 
321
  h1 { color: #fff; margin-bottom: 20px; }
322
  .file-input { display: none; }
323
  .file-label { display: block; padding: 15px; background: #e67e22; color: white; border-radius: 8px; cursor: pointer; font-weight: bold; margin-bottom: 10px; transition:0.2s; }
324
- .file-label:hover { background: #d35400; }
325
 
326
  .page-input-group { margin: 20px 0; text-align: left; }
327
  .page-input-group label { font-weight: bold; font-size: 14px; display: block; margin-bottom: 5px; color: #ccc; }
@@ -331,33 +314,40 @@ INDEX_HTML = '''
331
  .loader { width: 100px; height: 10px; background: #e67e22; margin: 20px auto; animation: load 1s infinite alternate; }
332
  @keyframes load { from { width: 20px; } to { width: 100px; } }
333
 
334
- /* COMIC PAGE - 800x800 */
335
- .comic-wrapper {
336
- margin: 0 auto;
337
- display: flex;
338
- flex-direction: column;
339
- align-items: center; /* Center pages */
340
- gap: 40px;
341
- width: 100%;
342
- }
343
 
 
344
  .comic-page {
345
- width: 800px;
346
- height: 800px;
347
  background: white;
348
  box-shadow: 0 5px 30px rgba(0,0,0,0.6);
349
  position: relative; overflow: hidden;
350
- border: 6px solid #000;
351
  flex-shrink: 0;
352
  }
353
 
 
354
  .comic-grid {
355
- width: 100%; height: 100%; position: relative; background: #000;
356
- --y: 50%; --t1: 100%; --t2: 100%; --b1: 100%; --b2: 100%; --gap: 3px;
 
 
357
  }
358
 
359
  .panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
360
- .panel img { width: 100%; height: 100%; object-fit: cover; transform-origin: center; transition: transform 0.05s ease-out; display: block; }
 
 
 
 
 
 
 
 
361
  .panel img.panning { cursor: grabbing; transition: none; }
362
  .panel.selected { outline: 4px solid #3498db; z-index: 5; }
363
 
@@ -368,62 +358,35 @@ INDEX_HTML = '''
368
  .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; }
369
 
370
  .handle { position: absolute; width: 26px; height: 26px; border: 3px solid white; border-radius: 50%; transform: translate(-50%, -50%); z-index: 101; cursor: ew-resize; box-shadow: 0 2px 5px rgba(0,0,0,0.8); }
371
- .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; }
372
- .h-t2 { background: #3498db; left: var(--t2); top: 50%; margin-top: -15px; }
373
- .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; }
374
- .h-b2 { background: #2ecc71; left: var(--b2); top: 100%; margin-top: -15px; }
375
 
376
  /* BUBBLES */
377
- .speech-bubble {
378
- position: absolute; display: flex; justify-content: center; align-items: center;
379
- min-width: 60px; min-height: 40px; box-sizing: border-box;
380
- z-index: 10; cursor: move; font-weight: bold; text-align: center;
381
- overflow: visible; line-height: 1.2; --tail-pos: 50%;
382
- }
383
- .bubble-text {
384
- padding: 0.8em; word-wrap: break-word; white-space: pre-wrap;
385
- width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
386
- border-radius: inherit; pointer-events: none;
387
- }
388
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
389
 
390
- /* TYPES */
391
- .speech-bubble.speech {
392
- --b: 3em; --h: 1.8em; --t: 0.6; --p: var(--tail-pos, 50%); --r: 1.2em;
393
- background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); padding: 0;
394
- 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);
395
- }
396
- .speech-bubble.speech:before {
397
- content: ""; position: absolute; width: var(--b); height: var(--h);
398
- background: inherit; border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
399
- -webkit-mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
400
- mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
401
- }
402
  .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))); }
403
-
404
- /* Thought Bubble */
405
- .speech-bubble.thought {
406
- background: var(--bubble-fill, #fff); color: var(--bubble-text, #000);
407
- border: 2px dashed #555; border-radius: 50%;
408
- }
409
  .speech-bubble.thought::before { display:none; }
410
  .thought-dot { position: absolute; background-color: var(--bubble-fill, #fff); border: 2px solid #555; border-radius: 50%; z-index: -1; }
411
- .thought-dot-1 { width: 15px; height: 15px; bottom:-15px; left:20px; }
412
- .thought-dot-2 { width: 10px; height: 10px; bottom:-25px; left:10px; }
413
-
414
- /* Reaction */
415
- .speech-bubble.reaction {
416
- background: #FFD700; border: 3px solid #E53935; color: #D32F2F;
417
- font-family: 'Bangers'; text-transform: uppercase;
418
- 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%);
419
- }
420
 
421
- /* Narration */
422
- .speech-bubble.narration {
423
- background: #eee; border: 2px solid #000; color: #000;
424
- border-radius: 0; font-family: 'Lato';
425
- bottom: 10px; left: 50%; transform: translateX(-50%); width: 80% !important; height: auto !important;
426
- }
427
 
428
  .resize-handle { position: absolute; bottom:-5px; right:-5px; width:15px; height:15px; background:#3498db; border:1px solid white; cursor:se-resize; display:none; }
429
  .speech-bubble.selected .resize-handle { display:block; }
@@ -440,186 +403,47 @@ INDEX_HTML = '''
440
  .reset-btn { background: #c0392b; color: white; }
441
  .secondary-btn { background: #f39c12; color: white; }
442
  .save-btn { background: #8e44ad; color: white; }
443
-
444
  .tip { text-align:center; padding:10px; background:#e74c3c; color:white; font-weight:bold; margin-bottom:20px; border-radius:5px; }
445
- .modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); display: none; justify-content: center; align-items: center; z-index: 2000; }
446
- .modal-content { background: white; padding: 30px; border-radius: 12px; width: 90%; max-width: 400px; text-align: center; color: #333; }
447
- .code { font-size: 24px; font-weight: bold; letter-spacing: 3px; background: #eee; padding: 10px; margin: 15px 0; display: inline-block; font-family: monospace; }
448
  </style>
449
  </head> <body>
450
 
451
  <div id="upload-container">
452
  <div class="upload-box">
453
- <h1>⚡ Ultimate Square Comic</h1>
454
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
455
  <label for="file-upload" class="file-label">📁 Choose Video</label>
456
  <span id="fn" style="margin-bottom:10px; display:block; color:#aaa;">No file selected</span>
457
-
458
- <div class="page-input-group">
459
- <label>📚 Total Pages:</label>
460
- <input type="number" id="page-count" value="4" min="1" max="15">
461
- </div>
462
-
463
  <button class="submit-btn" onclick="upload()">🚀 Generate</button>
464
- <button id="restore-draft-btn" class="reset-btn" style="display:none; margin-top:10px;" onclick="restoreDraft()">📂 Restore Draft</button>
465
-
466
- <div style="margin-top:20px; border-top:1px solid #555; padding-top:10px;">
467
- <input type="text" id="load-code" placeholder="ENTER SAVE CODE" style="width:70%; display:inline-block;">
468
- <button onclick="loadComic()" style="width:25%; display:inline-block; background:#9b59b6; color:white;">Load</button>
469
- </div>
470
- <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
471
- <div class="loader"></div>
472
- <p id="status-text" style="margin-top:10px;">Analyzing Video...</p>
473
- </div>
474
  </div>
475
  </div>
476
 
477
  <div id="editor-container">
478
  <div class="tip">👉 Drag Right-Side Dots to reveal 4 panels! | 📜 Scroll to Zoom/Pan</div>
479
  <div class="comic-wrapper" id="comic-container"></div>
480
- <input type="file" id="image-uploader" style="display: none;" accept="image/*">
481
-
482
  <div class="edit-controls">
483
  <h4>✏️ Editor</h4>
484
-
485
- <div class="control-group">
486
- <button onclick="undo()" style="background:#7f8c8d; color:white;">↩️ Undo</button>
487
- <button onclick="saveComic()" class="save-btn">💾 Save Comic</button>
488
- </div>
489
-
490
- <div class="control-group">
491
- <label>💬 Bubble Styling:</label>
492
- <select id="bubble-type" onchange="updateBubbleType()">
493
- <option value="speech">Speech 💬</option>
494
- <option value="thought">Thought 💭</option>
495
- <option value="reaction">Reaction 💥</option>
496
- <option value="narration">Narration ⬜</option>
497
- </select>
498
- <select id="font-select" onchange="updateFont()">
499
- <option value="'Comic Neue', cursive">Comic Neue</option>
500
- <option value="'Bangers', cursive">Bangers</option>
501
- <option value="'Gloria Hallelujah', cursive">Handwritten</option>
502
- <option value="'Lato', sans-serif">Modern</option>
503
- </select>
504
- <div class="color-grid">
505
- <input type="color" id="bub-fill" value="#ffffff" onchange="updateColors()" title="Fill">
506
- <input type="color" id="bub-text" value="#000000" onchange="updateColors()" title="Text">
507
- </div>
508
- <div class="button-grid">
509
- <button onclick="addBubble()" class="action-btn">Add</button>
510
- <button onclick="deleteBubble()" class="reset-btn">Delete</button>
511
- </div>
512
- <input type="range" min="10" max="90" value="50" oninput="slideTail(this.value)" title="Tail Pos">
513
- </div>
514
-
515
  <div class="control-group">
516
- <label>🖼️ Image Control:</label>
517
- <button onclick="replaceImage()" class="action-btn">Replace Image</button>
518
- <div class="button-grid">
519
- <button onclick="adjustFrame('backward')" class="secondary-btn">⬅️ Frame</button>
520
- <button onclick="adjustFrame('forward')" class="action-btn">Frame ➡️</button>
521
- </div>
522
  </div>
523
-
524
- <div class="control-group">
525
- <label>🔍 Zoom (Scroll Wheel):</label>
526
- <input type="range" id="zoom-slider" min="20" max="300" value="100" step="5" oninput="handleZoom(this.value)" disabled>
527
- <button onclick="resetPanelTransform()" class="secondary-btn">Reset View</button>
528
- </div>
529
-
530
- <div class="control-group">
531
- <button onclick="exportComic()" class="action-btn" style="background:#3498db;">📥 Export PNG</button>
532
- <button onclick="location.reload()" class="reset-btn" style="margin-top:10px;">🏠 Home</button>
533
  </div>
534
- </div>
535
- </div>
536
-
537
- <div class="modal-overlay" id="save-modal">
538
- <div class="modal-content">
539
- <h2>✅ Comic Saved!</h2>
540
- <div class="code" id="modal-code">XXXX</div>
541
- <button onclick="closeModal()">Close</button>
542
  </div>
543
  </div>
544
 
545
  <script>
546
  function genUUID(){ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,c=>{var r=Math.random()*16|0,v=c=='x'?r:(r&0x3|0x8);return v.toString(16);}); }
547
  let sid = localStorage.getItem('comic_sid') || genUUID();
548
- localStorage.setItem('comic_sid', sid);
549
- let interval, selectedBubble = null, selectedPanel = null;
550
  let dragType = null, activeObj = null, dragStart = {x:0, y:0};
551
- let historyStack = [];
552
-
553
- function saveState() {
554
- const state = [];
555
- document.querySelectorAll('.comic-page').forEach(pg => {
556
- const grid = pg.querySelector('.comic-grid');
557
- 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%' };
558
- const bubbles = [];
559
- grid.querySelectorAll('.speech-bubble').forEach(b => {
560
- bubbles.push({
561
- text: b.querySelector('.bubble-text').textContent,
562
- left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
563
- type: b.dataset.type, font: b.style.fontFamily,
564
- colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') },
565
- tailPos: b.style.getPropertyValue('--tail-pos'),
566
- classes: b.className
567
- });
568
- });
569
- const panels = [];
570
- grid.querySelectorAll('.panel').forEach(pan => {
571
- const img = pan.querySelector('img');
572
- const srcParts = img.src.split('frames/');
573
- const fname = srcParts.length > 1 ? srcParts[1].split('?')[0] : '';
574
- panels.push({ image: fname, zoom: img.dataset.zoom, tx: img.dataset.translateX, ty: img.dataset.translateY });
575
- });
576
- state.push({ layout, bubbles, panels });
577
- });
578
- historyStack.push(JSON.stringify(state));
579
- if(historyStack.length > 20) historyStack.shift();
580
- localStorage.setItem('comic_draft_'+sid, JSON.stringify(state));
581
- }
582
-
583
- function undo() {
584
- if(historyStack.length > 1) {
585
- historyStack.pop();
586
- const prev = JSON.parse(historyStack[historyStack.length-1]);
587
- restoreFromState(prev);
588
- }
589
- }
590
-
591
- function restoreFromState(stateData) {
592
- if(!stateData) return;
593
- const pages = document.querySelectorAll('.comic-page');
594
- stateData.forEach((pgData, i) => {
595
- if(i >= pages.length) return;
596
- const grid = pages[i].querySelector('.comic-grid');
597
- if(pgData.layout) {
598
- grid.style.setProperty('--t1', pgData.layout.t1); grid.style.setProperty('--t2', pgData.layout.t2);
599
- grid.style.setProperty('--b1', pgData.layout.b1); grid.style.setProperty('--b2', pgData.layout.b2);
600
- }
601
- grid.querySelectorAll('.speech-bubble').forEach(b=>b.remove());
602
- pgData.bubbles.forEach(bData => { const b = createBubbleHTML(bData); grid.appendChild(b); });
603
- const panels = grid.querySelectorAll('.panel');
604
- pgData.panels.forEach((pData, pi) => {
605
- if(pi < panels.length) {
606
- const img = panels[pi].querySelector('img');
607
- img.dataset.zoom = pData.zoom; img.dataset.translateX = pData.tx; img.dataset.translateY = pData.ty;
608
- if(pData.image) img.src = `/frames/${pData.image}?sid=${sid}`;
609
- updateImageTransform(img);
610
- }
611
- });
612
- });
613
- }
614
-
615
- if(localStorage.getItem('comic_draft_'+sid)) document.getElementById('restore-draft-btn').style.display='inline-block';
616
- function restoreDraft() {
617
- document.getElementById('upload-container').style.display='none';
618
- document.getElementById('editor-container').style.display='flex';
619
- loadNewComic().then(() => {
620
- setTimeout(() => restoreFromState(JSON.parse(localStorage.getItem('comic_draft_'+sid))), 500);
621
- });
622
- }
623
 
624
  async function upload() {
625
  const f = document.getElementById('file-upload').files[0];
@@ -627,18 +451,15 @@ INDEX_HTML = '''
627
  if(!f) return alert("Select video");
628
  sid = genUUID(); localStorage.setItem('comic_sid', sid);
629
  document.querySelector('.upload-box').style.display='none';
630
- document.getElementById('loading-view').style.display='flex';
631
  const fd = new FormData(); fd.append('file', f); fd.append('target_pages', pCount); fd.append('sid', sid);
632
  const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
633
- if(r.ok) interval = setInterval(checkStatus, 1500);
634
- else { const d = await r.json(); alert("Upload failed: " + d.message); location.reload(); }
635
  }
636
 
637
  async function checkStatus() {
638
  try {
639
  const r = await fetch(`/status?sid=${sid}`); const d = await r.json();
640
- document.getElementById('status-text').innerText = d.message;
641
- if(d.progress >= 100) { clearInterval(interval); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='flex'; loadNewComic(); }
642
  } catch(e) {}
643
  }
644
 
@@ -646,80 +467,53 @@ INDEX_HTML = '''
646
  const r = await fetch(`/output/pages.json?sid=${sid}`);
647
  const data = await r.json();
648
  const cleanData = data.map(p => ({
649
- panels: p.panels.map(pan => ({ src: `/frames/${pan.image}?sid=${sid}` })),
650
- bubbles: p.bubbles.map(b => ({
651
- text: b.dialog, left: (b.bubble_offset_x||50)+'px', top: (b.bubble_offset_y||20)+'px', type: b.type,
652
- colors: b.colors, font: b.font, classes: b.classes, tailPos: b.tail_pos
653
- }))
654
  }));
655
  renderFromState(cleanData);
656
- saveState();
657
  }
658
 
659
  function renderFromState(pagesData) {
660
  const con = document.getElementById('comic-container'); con.innerHTML = '';
661
  pagesData.forEach((page, pageIdx) => {
662
- const pageWrapper = document.createElement('div'); pageWrapper.className = 'page-wrapper';
663
- pageWrapper.innerHTML = `<h2 class="page-title">Page ${pageIdx + 1}</h2>`;
664
  const div = document.createElement('div'); div.className = 'comic-page';
665
  const grid = document.createElement('div'); grid.className = 'comic-grid';
666
-
667
  page.panels.forEach((pan, idx) => {
668
  const pDiv = document.createElement('div'); pDiv.className = 'panel';
669
  const img = document.createElement('img');
670
  img.src = pan.src.includes('?') ? pan.src : pan.src + `?sid=${sid}`;
671
  img.dataset.zoom = 100; img.dataset.translateX = 0; img.dataset.translateY = 0;
 
 
672
  img.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); selectPanel(pDiv); dragType = 'pan'; activeObj = img; dragStart = {x:e.clientX, y:e.clientY}; img.classList.add('panning'); };
673
- 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(); };
674
  pDiv.appendChild(img); grid.appendChild(pDiv);
675
  });
676
-
677
  grid.append(createHandle('h-t1', grid, 't1'), createHandle('h-t2', grid, 't2'), createHandle('h-b1', grid, 'b1'), createHandle('h-b2', grid, 'b2'));
678
-
679
- if(page.bubbles) {
680
- page.bubbles.forEach((bData, bIdx) => {
681
- // Only append valid bubbles, ignore dummies
682
- if(bData.text) {
683
- const b = createBubbleHTML(bData);
684
- grid.appendChild(b);
685
- }
686
- });
687
- }
688
-
689
- div.appendChild(grid); pageWrapper.appendChild(div); con.appendChild(pageWrapper);
690
  });
691
  }
692
 
693
- function createHandle(cls, grid, varName) {
694
- let h = document.createElement('div'); h.className = `handle ${cls}`;
695
- h.onmousedown = (e) => { e.stopPropagation(); dragType = 'handle'; activeObj = { grid: grid, var: varName }; };
696
- return h;
697
- }
698
 
699
  function createBubbleHTML(data) {
700
  const b = document.createElement('div');
701
  const type = data.type || 'speech';
702
-
703
- // 🎯 FIX: REGENERATE CLASSES BASED ON TYPE, DO NOT INHERIT BLINDLY
704
  let className = `speech-bubble ${type}`;
705
  if(type === 'speech') className += ' tail-bottom';
706
- else if(type === 'thought') className += ' pos-bl';
707
  b.className = className;
708
 
709
  b.dataset.type = type;
710
  b.style.left = data.left; b.style.top = data.top;
711
- if(data.width) b.style.width = data.width;
712
- if(data.height) b.style.height = data.height;
713
- if(data.font) b.style.fontFamily = data.font;
714
- if(data.colors) { b.style.setProperty('--bubble-fill', data.colors.fill); b.style.setProperty('--bubble-text', data.colors.text); }
715
- if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
716
-
717
  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); } }
718
 
719
  const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || 'Text'; b.appendChild(textSpan);
720
  const resizer = document.createElement('div'); resizer.className = 'resize-handle';
721
  resizer.onmousedown = (e) => { e.stopPropagation(); dragType='resize'; activeObj={b:b, startW:b.offsetWidth, startH:b.offsetHeight, mx:e.clientX, my:e.clientY}; };
722
  b.appendChild(resizer);
 
723
  b.onmousedown = (e) => { if(e.target === resizer) return; e.stopPropagation(); selectBubble(b); dragType = 'bubble'; activeObj = b; dragStart = {x: e.clientX, y: e.clientY}; };
724
  b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
725
  return b;
@@ -728,7 +522,7 @@ INDEX_HTML = '''
728
  function editBubbleText(bubble) {
729
  const textSpan = bubble.querySelector('.bubble-text');
730
  const newText = prompt("Edit Text:", textSpan.textContent);
731
- if(newText !== null) { textSpan.textContent = newText; saveState(); }
732
  }
733
 
734
  document.addEventListener('mousemove', (e) => {
@@ -754,74 +548,64 @@ INDEX_HTML = '''
754
  }
755
  });
756
 
757
- document.addEventListener('mouseup', () => {
758
- if(activeObj && activeObj.classList) activeObj.classList.remove('panning');
759
- if(dragType) saveState();
760
- dragType = null; activeObj = null;
761
- });
762
 
763
  function selectBubble(el) {
764
- if(selectedBubble) selectedBubble.classList.remove('selected');
765
- selectedBubble = el; el.classList.add('selected');
766
  document.getElementById('bubble-type').value = el.dataset.type;
767
- document.getElementById('font-select').value = el.style.fontFamily || "'Comic Neue', cursive";
768
  }
 
769
  function selectPanel(el) {
770
  if(selectedPanel) selectedPanel.classList.remove('selected');
771
  selectedPanel = el; el.classList.add('selected');
772
- document.getElementById('zoom-slider').disabled = false;
773
- document.getElementById('zoom-slider').value = el.querySelector('img').dataset.zoom;
 
 
 
774
  }
775
- 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(); } }
776
- function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); selectedBubble=null; saveState(); } }
777
 
778
- // 🎯 UPDATED BUBBLE TYPE LOGIC
 
 
779
  function updateBubbleType() {
780
  if(!selectedBubble) return;
781
  const type = document.getElementById('bubble-type').value;
782
  const oldB = selectedBubble;
783
-
784
- // Don't pass 'classes' key so createBubbleHTML generates fresh ones
785
  const data = {
786
  text: oldB.querySelector('.bubble-text').textContent,
787
  left: oldB.style.left, top: oldB.style.top, width: oldB.style.width, height: oldB.style.height,
788
- type: type, font: oldB.style.fontFamily,
789
- colors: { fill: oldB.style.getPropertyValue('--bubble-fill'), text: oldB.style.getPropertyValue('--bubble-text') },
790
- tailPos: oldB.style.getPropertyValue('--tail-pos')
791
  };
792
  const newB = createBubbleHTML(data);
793
  oldB.parentElement.replaceChild(newB, oldB);
794
- selectBubble(newB); saveState();
795
  }
796
- 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(); }
797
- function updateFont() { if(selectedBubble) { selectedBubble.style.fontFamily = document.getElementById('font-select').value; saveState(); } }
798
- function slideTail(val) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', val+'%'); saveState(); } }
799
 
800
- function handleZoom(val) { if(selectedPanel) { const img = selectedPanel.querySelector('img'); img.dataset.zoom = val; updateImageTransform(img); saveState(); } }
 
 
 
 
 
 
 
 
 
801
  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})`; }
802
- 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(); } }
803
 
804
- function replaceImage() {
805
  if(!selectedPanel) return alert("Select a panel");
806
- const inp = document.getElementById('image-uploader');
807
- inp.onchange = async (e) => {
808
- const fd = new FormData(); fd.append('image', e.target.files[0]);
809
- const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd});
810
- const d = await r.json();
811
- if(d.success) { selectedPanel.querySelector('img').src = `/frames/${d.new_filename}?sid=${sid}`; saveState(); }
812
- inp.value = '';
813
- };
814
- inp.click();
815
- }
816
-
817
- async function adjustFrame(dir) {
818
- if(!selectedPanel) return alert("Click a panel first");
819
  const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0];
820
- img.style.opacity='0.5';
821
- const r = await fetch(`/regenerate_frame?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, direction:dir}) });
822
  const d = await r.json();
823
- if(d.success) img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
824
- img.style.opacity='1'; saveState();
 
 
825
  }
826
 
827
  async function exportComic() {
@@ -831,42 +615,17 @@ INDEX_HTML = '''
831
  const a = document.createElement('a'); a.href=u; a.download=`Page-${i+1}.png`; a.click();
832
  }
833
  }
834
-
835
- async function saveComic() {
836
- const r = await fetch(`/save_comic?sid=${sid}`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({pages:getCurrentState()})});
837
- const d = await r.json();
838
- if(d.success) { document.getElementById('modal-code').innerText=d.code; document.getElementById('save-modal').style.display='flex'; }
839
- }
840
- async function loadComic() {
841
- const code = document.getElementById('load-code').value;
842
- const r = await fetch(`/load_comic/${code}`);
843
- const d = await r.json();
844
- if(d.success) { sid=d.originalSid; localStorage.setItem('comic_sid', sid); restoreFromState(d.pages); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='flex'; }
845
- else alert(d.message);
846
- }
847
- function closeModal() { document.getElementById('save-modal').style.display='none'; }
848
  </script>
849
  </body> </html> '''
850
 
851
  @app.route('/')
852
- def index():
853
- return INDEX_HTML
854
 
855
  @app.route('/uploader', methods=['POST'])
856
  def upload():
857
- sid = request.args.get('sid') or request.form.get('sid')
858
- if not sid: return jsonify({'success': False, 'message': 'Missing session ID'}), 400
859
-
860
- file = request.files.get('file')
861
- if not file or file.filename == '': return jsonify({'success': False, 'message': 'No file uploaded'}), 400
862
-
863
- target_pages = request.form.get('target_pages', 4)
864
- gen = EnhancedComicGenerator(sid)
865
- gen.cleanup()
866
- file.save(gen.video_path)
867
- gen.write_status("Starting...", 5)
868
-
869
- threading.Thread(target=gen.run, args=(target_pages,)).start()
870
  return jsonify({'success': True})
871
 
872
  @app.route('/status')
@@ -877,72 +636,15 @@ def get_status():
877
  return jsonify({'progress': 0, 'message': "Waiting..."})
878
 
879
  @app.route('/output/<path:filename>')
880
- def get_output(filename):
881
- sid = request.args.get('sid')
882
- return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
883
-
884
  @app.route('/frames/<path:filename>')
885
- def get_frame(filename):
886
- sid = request.args.get('sid')
887
- return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
888
-
889
- @app.route('/regenerate_frame', methods=['POST'])
890
- def regen():
891
- sid = request.args.get('sid')
892
- d = request.get_json()
893
- gen = EnhancedComicGenerator(sid)
894
- return jsonify(regen_frame_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], d['direction']))
895
 
896
  @app.route('/goto_timestamp', methods=['POST'])
897
  def go_time():
898
- sid = request.args.get('sid')
899
- d = request.get_json()
900
  gen = EnhancedComicGenerator(sid)
901
- return jsonify(get_frame_at_ts_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], float(d['timestamp'])))
902
-
903
- @app.route('/replace_panel', methods=['POST'])
904
- def rep_panel():
905
- sid = request.args.get('sid')
906
- f = request.files['image']
907
- frames_dir = os.path.join(BASE_USER_DIR, sid, 'frames')
908
- os.makedirs(frames_dir, exist_ok=True)
909
- fname = f"replaced_{int(time.time() * 1000)}.png"
910
- f.save(os.path.join(frames_dir, fname))
911
- return jsonify({'success': True, 'new_filename': fname})
912
-
913
- @app.route('/save_comic', methods=['POST'])
914
- def save_comic():
915
- sid = request.args.get('sid')
916
- try:
917
- data = request.get_json()
918
- save_code = generate_save_code()
919
- save_dir = os.path.join(SAVED_COMICS_DIR, save_code)
920
- os.makedirs(save_dir, exist_ok=True)
921
- user_frames_dir = os.path.join(BASE_USER_DIR, sid, 'frames')
922
- saved_frames_dir = os.path.join(save_dir, 'frames')
923
- if os.path.exists(user_frames_dir):
924
- if os.path.exists(saved_frames_dir): shutil.rmtree(saved_frames_dir)
925
- shutil.copytree(user_frames_dir, saved_frames_dir)
926
- with open(os.path.join(save_dir, 'comic_state.json'), 'w') as f:
927
- json.dump({'originalSid': sid, 'pages': data['pages'], 'savedAt': time.time()}, f)
928
- return jsonify({'success': True, 'code': save_code})
929
- except Exception as e: return jsonify({'success': False, 'message': str(e)})
930
-
931
- @app.route('/load_comic/<code>')
932
- def load_comic(code):
933
- code = code.upper()
934
- save_dir = os.path.join(SAVED_COMICS_DIR, code)
935
- if not os.path.exists(save_dir): return jsonify({'success': False, 'message': 'Code not found'})
936
- try:
937
- with open(os.path.join(save_dir, 'comic_state.json'), 'r') as f: data = json.load(f)
938
- orig_sid = data['originalSid']
939
- saved_frames = os.path.join(save_dir, 'frames')
940
- user_frames = os.path.join(BASE_USER_DIR, orig_sid, 'frames')
941
- os.makedirs(user_frames, exist_ok=True)
942
- for fn in os.listdir(saved_frames):
943
- shutil.copy2(os.path.join(saved_frames, fn), os.path.join(user_frames, fn))
944
- return jsonify({'success': True, 'originalSid': orig_sid, 'pages': data['pages']})
945
- except Exception as e: return jsonify({'success': False, 'message': str(e)})
946
 
947
  if __name__ == '__main__':
948
  try: gpu_warmup()
 
57
  if type == 'speech':
58
  classes += " tail-bottom"
59
  elif type == 'thought':
60
+ classes += " tail-bottom" # Allow thought bubbles to rotate too
61
+ elif type == 'reaction':
62
+ classes += " tail-bottom"
63
 
64
  return {
65
  'dialog': dialog,
 
72
  'font': "'Comic Neue', cursive"
73
  }
74
 
75
+ def panel(image="", time=0.0):
76
+ return {'image': image, 'time': time}
77
 
78
  class Page:
79
  def __init__(self, panels, bubbles):
 
85
  # ======================================================
86
  @spaces.GPU(duration=300)
87
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
88
+ print(f"🚀 Generating 864x1080 Comic: {video_path}")
89
  import cv2
90
  import srt
91
  import numpy as np
92
  from backend.subtitles.subs_real import get_real_subtitles
93
 
94
  cap = cv2.VideoCapture(video_path)
95
+ if not cap.isOpened(): raise Exception("Cannot open video")
 
 
96
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
97
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
98
  duration = total_frames / fps
99
  cap.release()
100
 
101
+ # Subtitles
102
  user_srt = os.path.join(user_dir, 'subs.srt')
103
  try:
104
  get_real_subtitles(video_path)
105
  if os.path.exists('test1.srt'):
106
  shutil.move('test1.srt', user_srt)
107
  elif not os.path.exists(user_srt):
108
+ with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
 
109
  except:
110
+ with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
 
111
 
112
  with open(user_srt, 'r', encoding='utf-8') as f:
113
+ try: all_subs = list(srt.parse(f.read()))
114
+ except: all_subs = []
 
 
115
 
116
  valid_subs = [s for s in all_subs if s.content.strip()]
117
  if valid_subs:
 
126
  selected_moments = []
127
  if not raw_moments:
128
  times = np.linspace(1, max(1, duration-1), total_panels_needed)
129
+ for t in times: selected_moments.append({'text': '', 'start': t, 'end': t+1})
 
130
  elif len(raw_moments) <= total_panels_needed:
131
  selected_moments = raw_moments
132
  else:
 
137
  cap = cv2.VideoCapture(video_path)
138
  count = 0
139
  frame_files_ordered = []
140
+ frame_times = []
141
 
142
  for i, moment in enumerate(selected_moments):
143
  mid = (moment['start'] + moment['end']) / 2
144
  cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
145
  ret, frame = cap.read()
146
  if ret:
147
+ # 🎯 EXTRACT 1920x1080 (Full HD 16:9)
148
+ # This ensures high quality. Frontend handles fitting into 864x1080.
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)
157
+ frame_times.append(mid)
158
  count += 1
159
  cap.release()
160
 
161
+ with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
 
162
 
163
  bubbles_list = []
164
  for i, f in enumerate(frame_files_ordered):
165
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
 
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 Positioning for 864x1080
172
+ # Panels are roughly 430x540
173
  pos_idx = i % 4
174
  if pos_idx == 0: bx, by = 150, 50
175
+ elif pos_idx == 1: bx, by = 580, 50
176
+ elif pos_idx == 2: bx, by = 150, 600
177
+ elif pos_idx == 3: bx, by = 580, 600
178
  else: bx, by = 50, 50
179
 
180
  bubbles_list.append(bubble(dialog=dialogue, x=bx, y=by, type=b_type))
 
184
  start_idx = i * 4
185
  end_idx = start_idx + 4
186
  p_frames = frame_files_ordered[start_idx:end_idx]
187
+ p_times = frame_times[start_idx:end_idx]
188
  p_bubbles = bubbles_list[start_idx:end_idx]
189
 
190
  while len(p_frames) < 4:
191
  fname = f"empty_{i}_{len(p_frames)}.png"
192
+ img = np.zeros((1080, 1920, 3), dtype=np.uint8); img[:] = (30,30,30)
 
193
  cv2.imwrite(os.path.join(frames_dir, fname), img)
194
  p_frames.append(fname)
195
+ p_times.append(0.0)
196
  p_bubbles.append(bubble(dialog="", x=-999, y=-999, type='speech'))
197
 
198
  if p_frames:
199
+ # Pass image AND timestamp to frontend
200
+ pg_panels = [panel(image=p_frames[j], time=p_times[j]) for j in range(len(p_frames))]
201
  pages.append(Page(panels=pg_panels, bubbles=p_bubbles))
202
 
203
+ # Convert to Dict
204
  result = []
205
  for pg in pages:
206
  p_data = [p if isinstance(p, dict) else p.__dict__ for p in pg.panels]
207
  b_data = [b if isinstance(b, dict) else b.__dict__ for b in pg.bubbles]
208
  result.append({'panels': p_data, 'bubbles': b_data})
209
+
210
  return result
211
 
212
  @spaces.GPU
213
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
214
  import cv2
215
  import json
216
+ if not os.path.exists(metadata_path): return {"success": False, "message": "No metadata"}
217
+ with open(metadata_path, 'r') as f: meta = json.load(f)
 
 
 
218
 
219
  t = meta[fname]['time'] if isinstance(meta[fname], dict) else meta[fname]
220
  cap = cv2.VideoCapture(video_path)
 
227
  cap.release()
228
 
229
  if ret:
230
+ frame = cv2.resize(frame, (1920, 1080))
231
  cv2.imwrite(os.path.join(frames_dir, fname), frame)
232
+ if isinstance(meta[fname], dict): meta[fname]['time'] = new_t
233
+ else: meta[fname] = new_t
234
+ with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
235
+ return {"success": True, "message": f"Time: {new_t:.2f}s", "new_time": new_t}
 
 
 
236
  return {"success": False}
237
 
238
  @spaces.GPU
 
245
  cap.release()
246
 
247
  if ret:
248
+ frame = cv2.resize(frame, (1920, 1080))
249
  cv2.imwrite(os.path.join(frames_dir, fname), frame)
250
  if os.path.exists(metadata_path):
251
+ with open(metadata_path, 'r') as f: meta = json.load(f)
 
252
  if fname in meta:
253
+ if isinstance(meta[fname], dict): meta[fname]['time'] = float(ts)
254
+ else: meta[fname] = float(ts)
255
+ with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
256
+ return {"success": True, "message": f"Jumped to {ts}s", "new_time": ts}
257
+ return {"success": False, "message": "Invalid timestamp"}
 
 
 
258
 
259
  # ======================================================
260
  # 💻 BACKEND CLASS
 
292
  json.dump({'message': msg, 'progress': prog}, f)
293
 
294
  # ======================================================
295
+ # 🌐 FRONTEND
296
  # ======================================================
297
  INDEX_HTML = '''
298
+ <!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; }
299
 
300
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
301
  .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; }
302
 
303
+ #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 150px; flex-direction: column; align-items: center; }
 
 
 
 
 
 
 
 
 
304
 
305
  h1 { color: #fff; margin-bottom: 20px; }
306
  .file-input { display: none; }
307
  .file-label { display: block; padding: 15px; background: #e67e22; color: white; border-radius: 8px; cursor: pointer; font-weight: bold; margin-bottom: 10px; transition:0.2s; }
 
308
 
309
  .page-input-group { margin: 20px 0; text-align: left; }
310
  .page-input-group label { font-weight: bold; font-size: 14px; display: block; margin-bottom: 5px; color: #ccc; }
 
314
  .loader { width: 100px; height: 10px; background: #e67e22; margin: 20px auto; animation: load 1s infinite alternate; }
315
  @keyframes load { from { width: 20px; } to { width: 100px; } }
316
 
317
+ /* === 864x1080 PAGE LAYOUT === */
318
+ .comic-wrapper { max-width: 1000px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; gap: 40px; }
319
+ .page-wrapper { display: flex; flex-direction: column; align-items: center; }
320
+ .page-title { text-align: center; color: #eee; margin-bottom: 10px; font-size: 20px; font-weight: bold; }
 
 
 
 
 
321
 
322
+ /* 🎯 864x1080 + 5px White Border */
323
  .comic-page {
324
+ width: 864px;
325
+ height: 1080px;
326
  background: white;
327
  box-shadow: 0 5px 30px rgba(0,0,0,0.6);
328
  position: relative; overflow: hidden;
329
+ border: 5px solid #ffffff;
330
  flex-shrink: 0;
331
  }
332
 
333
+ /* 🎯 White Gap between panels */
334
  .comic-grid {
335
+ width: 100%; height: 100%; position: relative;
336
+ background: #ffffff; /* White lines */
337
+ --y: 50%; --t1: 100%; --t2: 100%; --b1: 100%; --b2: 100%;
338
+ --gap: 5px;
339
  }
340
 
341
  .panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
342
+
343
+ /* 🎯 NO CUT: Object Fit Contain = Show Whole Image */
344
+ .panel img {
345
+ width: 100%; height: 100%;
346
+ object-fit: contain; /* Ensures 16:9 fits in 4:5 without cutting. Zoom to fill. */
347
+ transform-origin: center;
348
+ transition: transform 0.05s ease-out;
349
+ display: block;
350
+ }
351
  .panel img.panning { cursor: grabbing; transition: none; }
352
  .panel.selected { outline: 4px solid #3498db; z-index: 5; }
353
 
 
358
  .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; }
359
 
360
  .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); }
361
+ .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; } .h-t2 { background: #3498db; left: var(--t2); top: 50%; margin-top: -15px; }
362
+ .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; } .h-b2 { background: #2ecc71; left: var(--b2); top: 100%; margin-top: -15px; }
 
 
363
 
364
  /* BUBBLES */
365
+ .speech-bubble { position: absolute; display: flex; justify-content: center; align-items: center; min-width: 60px; min-height: 40px; z-index: 10; cursor: move; font-weight: bold; text-align: center; overflow: visible; line-height: 1.2; --tail-pos: 50%; }
366
+ .bubble-text { padding: 0.8em; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; border-radius: inherit; pointer-events: none; }
 
 
 
 
 
 
 
 
 
367
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
368
 
369
+ /* SPEECH */
370
+ .speech-bubble.speech { --b: 3em; --h: 1.8em; --t: 0.6; --p: var(--tail-pos, 50%); --r: 1.2em; background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); padding: 0; 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); }
371
+ .speech-bubble.speech:before { content: ""; position: absolute; width: var(--b); height: var(--h); background: inherit; border-bottom-left-radius: 100%; pointer-events: none; z-index: 1; -webkit-mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%); mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%); }
 
 
 
 
 
 
 
 
 
372
  .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))); }
373
+ .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); }
374
+ .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; }
375
+ .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; }
376
+
377
+ /* THOUGHT - FIXED */
378
+ .speech-bubble.thought { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 2px dashed #555; border-radius: 50%; }
379
  .speech-bubble.thought::before { display:none; }
380
  .thought-dot { position: absolute; background-color: var(--bubble-fill, #fff); border: 2px solid #555; border-radius: 50%; z-index: -1; }
381
+ .thought-dot-1 { width: 15px; height: 15px; } .thought-dot-2 { width: 10px; height: 10px; }
382
+ /* Rotatable Thought Tail */
383
+ .speech-bubble.thought.tail-bottom .thought-dot-1 { bottom: -20px; left: 20px; } .speech-bubble.thought.tail-bottom .thought-dot-2 { bottom: -32px; left: 10px; }
384
+ .speech-bubble.thought.tail-top .thought-dot-1 { top: -20px; left: 20px; } .speech-bubble.thought.tail-top .thought-dot-2 { top: -32px; left: 10px; }
385
+ .speech-bubble.thought.tail-right .thought-dot-1 { right: -20px; bottom: 20px; } .speech-bubble.thought.tail-right .thought-dot-2 { right: -32px; bottom: 10px; }
386
+ .speech-bubble.thought.tail-left .thought-dot-1 { left: -20px; bottom: 20px; } .speech-bubble.thought.tail-left .thought-dot-2 { left: -32px; bottom: 10px; }
 
 
 
387
 
388
+ .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%); }
389
+ .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; }
 
 
 
 
390
 
391
  .resize-handle { position: absolute; bottom:-5px; right:-5px; width:15px; height:15px; background:#3498db; border:1px solid white; cursor:se-resize; display:none; }
392
  .speech-bubble.selected .resize-handle { display:block; }
 
403
  .reset-btn { background: #c0392b; color: white; }
404
  .secondary-btn { background: #f39c12; color: white; }
405
  .save-btn { background: #8e44ad; color: white; }
 
406
  .tip { text-align:center; padding:10px; background:#e74c3c; color:white; font-weight:bold; margin-bottom:20px; border-radius:5px; }
 
 
 
407
  </style>
408
  </head> <body>
409
 
410
  <div id="upload-container">
411
  <div class="upload-box">
412
+ <h1>⚡ 864x1080 Comic</h1>
413
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
414
  <label for="file-upload" class="file-label">📁 Choose Video</label>
415
  <span id="fn" style="margin-bottom:10px; display:block; color:#aaa;">No file selected</span>
416
+ <div class="page-input-group"> <label>📚 Total Pages:</label> <input type="number" id="page-count" value="4" min="1" max="15"> </div>
 
 
 
 
 
417
  <button class="submit-btn" onclick="upload()">🚀 Generate</button>
 
 
 
 
 
 
 
 
 
 
418
  </div>
419
  </div>
420
 
421
  <div id="editor-container">
422
  <div class="tip">👉 Drag Right-Side Dots to reveal 4 panels! | 📜 Scroll to Zoom/Pan</div>
423
  <div class="comic-wrapper" id="comic-container"></div>
 
 
424
  <div class="edit-controls">
425
  <h4>✏️ Editor</h4>
426
+ <div class="control-group"> <button onclick="saveComic()" class="save-btn">💾 Save</button> </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  <div class="control-group">
428
+ <label>💬 Bubbles:</label>
429
+ <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>
430
+ <div class="button-grid"> <button onclick="addBubble()" class="action-btn">Add</button> <button onclick="deleteBubble()" class="reset-btn">Delete</button> </div>
431
+ <div id="tail-controls"> <button onclick="rotateTail()" class="secondary-btn" style="margin-top:5px;">🔄 Rotate Tail</button> </div>
 
 
432
  </div>
433
+ <div class="control-group">
434
+ <label>🖼️ Time Frame (s):</label>
435
+ <input type="text" id="timestamp-input" placeholder="Click panel to see time">
436
+ <button onclick="gotoTimestamp()" class="action-btn">Go</button>
 
 
 
 
 
 
437
  </div>
438
+ <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>
 
 
 
 
 
 
 
439
  </div>
440
  </div>
441
 
442
  <script>
443
  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);}); }
444
  let sid = localStorage.getItem('comic_sid') || genUUID();
445
+ let selectedBubble = null, selectedPanel = null;
 
446
  let dragType = null, activeObj = null, dragStart = {x:0, y:0};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
 
448
  async function upload() {
449
  const f = document.getElementById('file-upload').files[0];
 
451
  if(!f) return alert("Select video");
452
  sid = genUUID(); localStorage.setItem('comic_sid', sid);
453
  document.querySelector('.upload-box').style.display='none';
 
454
  const fd = new FormData(); fd.append('file', f); fd.append('target_pages', pCount); fd.append('sid', sid);
455
  const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
456
+ if(r.ok) setInterval(checkStatus, 1500);
 
457
  }
458
 
459
  async function checkStatus() {
460
  try {
461
  const r = await fetch(`/status?sid=${sid}`); const d = await r.json();
462
+ if(d.progress >= 100) { document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='flex'; loadNewComic(); }
 
463
  } catch(e) {}
464
  }
465
 
 
467
  const r = await fetch(`/output/pages.json?sid=${sid}`);
468
  const data = await r.json();
469
  const cleanData = data.map(p => ({
470
+ panels: p.panels.map(pan => ({ src: `/frames/${pan.image}?sid=${sid}`, time: pan.time })),
471
+ 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, tailPos: b.tail_pos }))
 
 
 
472
  }));
473
  renderFromState(cleanData);
 
474
  }
475
 
476
  function renderFromState(pagesData) {
477
  const con = document.getElementById('comic-container'); con.innerHTML = '';
478
  pagesData.forEach((page, pageIdx) => {
 
 
479
  const div = document.createElement('div'); div.className = 'comic-page';
480
  const grid = document.createElement('div'); grid.className = 'comic-grid';
 
481
  page.panels.forEach((pan, idx) => {
482
  const pDiv = document.createElement('div'); pDiv.className = 'panel';
483
  const img = document.createElement('img');
484
  img.src = pan.src.includes('?') ? pan.src : pan.src + `?sid=${sid}`;
485
  img.dataset.zoom = 100; img.dataset.translateX = 0; img.dataset.translateY = 0;
486
+ img.dataset.time = pan.time.toFixed(2); // Store Time
487
+
488
  img.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); selectPanel(pDiv); dragType = 'pan'; activeObj = img; dragStart = {x:e.clientX, y:e.clientY}; img.classList.add('panning'); };
489
+ 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); };
490
  pDiv.appendChild(img); grid.appendChild(pDiv);
491
  });
 
492
  grid.append(createHandle('h-t1', grid, 't1'), createHandle('h-t2', grid, 't2'), createHandle('h-b1', grid, 'b1'), createHandle('h-b2', grid, 'b2'));
493
+ if(page.bubbles) { page.bubbles.forEach((bData) => { if(bData.text) grid.appendChild(createBubbleHTML(bData)); }); }
494
+ div.appendChild(grid); con.appendChild(div);
 
 
 
 
 
 
 
 
 
 
495
  });
496
  }
497
 
498
+ 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; }
 
 
 
 
499
 
500
  function createBubbleHTML(data) {
501
  const b = document.createElement('div');
502
  const type = data.type || 'speech';
 
 
503
  let className = `speech-bubble ${type}`;
504
  if(type === 'speech') className += ' tail-bottom';
505
+ else if(type === 'thought') className += ' tail-bottom'; // Use tail logic for position
506
  b.className = className;
507
 
508
  b.dataset.type = type;
509
  b.style.left = data.left; b.style.top = data.top;
 
 
 
 
 
 
510
  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); } }
511
 
512
  const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || 'Text'; b.appendChild(textSpan);
513
  const resizer = document.createElement('div'); resizer.className = 'resize-handle';
514
  resizer.onmousedown = (e) => { e.stopPropagation(); dragType='resize'; activeObj={b:b, startW:b.offsetWidth, startH:b.offsetHeight, mx:e.clientX, my:e.clientY}; };
515
  b.appendChild(resizer);
516
+
517
  b.onmousedown = (e) => { if(e.target === resizer) return; e.stopPropagation(); selectBubble(b); dragType = 'bubble'; activeObj = b; dragStart = {x: e.clientX, y: e.clientY}; };
518
  b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
519
  return b;
 
522
  function editBubbleText(bubble) {
523
  const textSpan = bubble.querySelector('.bubble-text');
524
  const newText = prompt("Edit Text:", textSpan.textContent);
525
+ if(newText !== null) textSpan.textContent = newText;
526
  }
527
 
528
  document.addEventListener('mousemove', (e) => {
 
548
  }
549
  });
550
 
551
+ document.addEventListener('mouseup', () => { if(activeObj && activeObj.classList) activeObj.classList.remove('panning'); dragType = null; activeObj = null; });
 
 
 
 
552
 
553
  function selectBubble(el) {
554
+ if(selectedBubble) selectedBubble.classList.remove('selected'); selectedBubble = el; el.classList.add('selected');
 
555
  document.getElementById('bubble-type').value = el.dataset.type;
556
+ document.getElementById('tail-controls').style.display = (el.dataset.type === 'speech' || el.dataset.type === 'thought') ? 'block' : 'none';
557
  }
558
+
559
  function selectPanel(el) {
560
  if(selectedPanel) selectedPanel.classList.remove('selected');
561
  selectedPanel = el; el.classList.add('selected');
562
+ // 🎯 POPULATE TIME BOX
563
+ const img = el.querySelector('img');
564
+ if(img && img.dataset.time) {
565
+ document.getElementById('timestamp-input').value = img.dataset.time;
566
+ }
567
  }
 
 
568
 
569
+ function addBubble() { const grid = document.querySelector('.comic-grid'); if(grid) { const b = createBubbleHTML({ text: "Text", left: "50%", top: "50%" }); grid.appendChild(b); selectBubble(b); } }
570
+ function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); selectedBubble=null; } }
571
+
572
  function updateBubbleType() {
573
  if(!selectedBubble) return;
574
  const type = document.getElementById('bubble-type').value;
575
  const oldB = selectedBubble;
 
 
576
  const data = {
577
  text: oldB.querySelector('.bubble-text').textContent,
578
  left: oldB.style.left, top: oldB.style.top, width: oldB.style.width, height: oldB.style.height,
579
+ type: type
 
 
580
  };
581
  const newB = createBubbleHTML(data);
582
  oldB.parentElement.replaceChild(newB, oldB);
583
+ selectBubble(newB);
584
  }
 
 
 
585
 
586
+ // 🎯 ROTATE FOR ALL TYPES
587
+ function rotateTail() {
588
+ if(!selectedBubble) return;
589
+ const positions = ['tail-bottom', 'tail-right', 'tail-top', 'tail-left'];
590
+ let current = positions.find(p => selectedBubble.classList.contains(p)) || 'tail-bottom';
591
+ selectedBubble.classList.remove(current);
592
+ let next = positions[(positions.indexOf(current)+1)%4];
593
+ selectedBubble.classList.add(next);
594
+ }
595
+
596
  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})`; }
 
597
 
598
+ async function gotoTimestamp() {
599
  if(!selectedPanel) return alert("Select a panel");
600
+ let v = document.getElementById('timestamp-input').value.trim();
601
+ if(!v) return;
 
 
 
 
 
 
 
 
 
 
 
602
  const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0];
603
+ const r = await fetch(`/goto_timestamp?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, timestamp:v}) });
 
604
  const d = await r.json();
605
+ if(d.success) {
606
+ img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
607
+ img.dataset.time = d.new_time; // Update local time
608
+ }
609
  }
610
 
611
  async function exportComic() {
 
615
  const a = document.createElement('a'); a.href=u; a.download=`Page-${i+1}.png`; a.click();
616
  }
617
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
618
  </script>
619
  </body> </html> '''
620
 
621
  @app.route('/')
622
+ def index(): return INDEX_HTML
 
623
 
624
  @app.route('/uploader', methods=['POST'])
625
  def upload():
626
+ sid = request.args.get('sid'); f = request.files.get('file'); pages = request.form.get('target_pages', 4)
627
+ gen = EnhancedComicGenerator(sid); gen.cleanup(); f.save(gen.video_path)
628
+ threading.Thread(target=gen.run, args=(pages,)).start()
 
 
 
 
 
 
 
 
 
 
629
  return jsonify({'success': True})
630
 
631
  @app.route('/status')
 
636
  return jsonify({'progress': 0, 'message': "Waiting..."})
637
 
638
  @app.route('/output/<path:filename>')
639
+ def get_output(filename): return send_from_directory(os.path.join(BASE_USER_DIR, request.args.get('sid'), 'output'), filename)
 
 
 
640
  @app.route('/frames/<path:filename>')
641
+ def get_frame(filename): return send_from_directory(os.path.join(BASE_USER_DIR, request.args.get('sid'), 'frames'), filename)
 
 
 
 
 
 
 
 
 
642
 
643
  @app.route('/goto_timestamp', methods=['POST'])
644
  def go_time():
645
+ sid = request.args.get('sid'); d = request.get_json()
 
646
  gen = EnhancedComicGenerator(sid)
647
+ return jsonify(gen.get_frame_at_timestamp(d['filename'], float(d['timestamp'])))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648
 
649
  if __name__ == '__main__':
650
  try: gpu_warmup()