tester343 commited on
Commit
ec587d0
·
verified ·
1 Parent(s): 05177b9

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +241 -80
app_enhanced.py CHANGED
@@ -1,4 +1,4 @@
1
- import spaces
2
  import os
3
  import time
4
  import threading
@@ -70,6 +70,14 @@ def bubble(dialog="", x=50, y=20, type='speech'):
70
  'font': "'Comic Neue', cursive"
71
  }
72
 
 
 
 
 
 
 
 
 
73
  # ======================================================
74
  # 🧠 GPU GENERATION
75
  # ======================================================
@@ -82,23 +90,32 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
82
  from backend.subtitles.subs_real import get_real_subtitles
83
 
84
  cap = cv2.VideoCapture(video_path)
85
- if not cap.isOpened(): raise Exception("Cannot open video")
 
 
86
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
87
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
88
  duration = total_frames / fps
89
  cap.release()
90
 
 
91
  user_srt = os.path.join(user_dir, 'subs.srt')
92
  try:
93
  get_real_subtitles(video_path)
94
- if os.path.exists('test1.srt'): shutil.move('test1.srt', user_srt)
95
- elif not os.path.exists(user_srt): with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
 
 
 
96
  except:
97
- with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
 
98
 
99
  with open(user_srt, 'r', encoding='utf-8') as f:
100
- try: all_subs = list(srt.parse(f.read()))
101
- except: all_subs = []
 
 
102
 
103
  valid_subs = [s for s in all_subs if s.content.strip()]
104
  if valid_subs:
@@ -106,13 +123,15 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
106
  else:
107
  raw_moments = []
108
 
 
109
  panels_per_page = 4
110
  total_panels_needed = int(target_pages) * panels_per_page
111
 
112
  selected_moments = []
113
  if not raw_moments:
114
  times = np.linspace(1, max(1, duration-1), total_panels_needed)
115
- for t in times: selected_moments.append({'text': '', 'start': t, 'end': t+1})
 
116
  elif len(raw_moments) <= total_panels_needed:
117
  selected_moments = raw_moments
118
  else:
@@ -129,7 +148,7 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
129
  cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
130
  ret, frame = cap.read()
131
  if ret:
132
- # 1280x720 (No Crop)
133
  frame = cv2.resize(frame, (1280, 720))
134
  fname = f"frame_{count:04d}.png"
135
  p = os.path.join(frames_dir, fname)
@@ -139,15 +158,18 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
139
  count += 1
140
  cap.release()
141
 
142
- with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
 
143
 
144
  bubbles_list = []
145
  for i, f in enumerate(frame_files_ordered):
146
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
 
147
  b_type = 'speech'
148
  if '(' in dialogue: b_type = 'narration'
149
  elif '!' in dialogue: b_type = 'reaction'
150
 
 
151
  pos_idx = i % 4
152
  if pos_idx == 0: bx, by = 150, 50
153
  elif pos_idx == 1: bx, by = 550, 50
@@ -166,23 +188,33 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
166
 
167
  while len(p_frames) < 4:
168
  fname = f"empty_{i}_{len(p_frames)}.png"
169
- img = np.zeros((720, 1280, 3), dtype=np.uint8); img[:] = (30,30,30)
 
170
  cv2.imwrite(os.path.join(frames_dir, fname), img)
171
  p_frames.append(fname)
172
  p_bubbles.append(bubble(dialog="", x=-999, y=-999, type='speech'))
173
 
174
  if p_frames:
175
- pg_panels = [{'image': f} for f in p_frames]
176
- pages.append({'panels': pg_panels, 'bubbles': p_bubbles})
177
-
178
- return pages
 
 
 
 
 
 
179
 
180
  @spaces.GPU
181
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
182
  import cv2
183
  import json
184
- if not os.path.exists(metadata_path): return {"success": False, "message": "No metadata"}
185
- with open(metadata_path, 'r') as f: meta = json.load(f)
 
 
 
186
 
187
  t = meta[fname]['time'] if isinstance(meta[fname], dict) else meta[fname]
188
  cap = cv2.VideoCapture(video_path)
@@ -197,9 +229,12 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
197
  if ret:
198
  frame = cv2.resize(frame, (1280, 720))
199
  cv2.imwrite(os.path.join(frames_dir, fname), frame)
200
- if isinstance(meta[fname], dict): meta[fname]['time'] = new_t
201
- else: meta[fname] = new_t
202
- with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
 
 
 
203
  return {"success": True, "message": f"Time: {new_t:.2f}s"}
204
  return {"success": False}
205
 
@@ -216,14 +251,21 @@ def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
216
  frame = cv2.resize(frame, (1280, 720))
217
  cv2.imwrite(os.path.join(frames_dir, fname), frame)
218
  if os.path.exists(metadata_path):
219
- with open(metadata_path, 'r') as f: meta = json.load(f)
 
220
  if fname in meta:
221
- if isinstance(meta[fname], dict): meta[fname]['time'] = float(ts)
222
- else: meta[fname] = float(ts)
223
- with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
 
 
 
224
  return {"success": True, "message": f"Jumped to {ts}s"}
225
  return {"success": False}
226
 
 
 
 
227
  class EnhancedComicGenerator:
228
  def __init__(self, sid):
229
  self.sid = sid
@@ -257,10 +299,10 @@ class EnhancedComicGenerator:
257
  json.dump({'message': msg, 'progress': prog}, f)
258
 
259
  # ======================================================
260
- # 🌐 FRONTEND
261
  # ======================================================
262
  INDEX_HTML = '''
263
- <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Fixed Comic Editor</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; }
264
 
265
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
266
  .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; }
@@ -279,9 +321,12 @@ INDEX_HTML = '''
279
  h1 { color: #fff; margin-bottom: 20px; }
280
  .file-input { display: none; }
281
  .file-label { display: block; padding: 15px; background: #e67e22; color: white; border-radius: 8px; cursor: pointer; font-weight: bold; margin-bottom: 10px; transition:0.2s; }
 
 
282
  .page-input-group { margin: 20px 0; text-align: left; }
283
  .page-input-group label { font-weight: bold; font-size: 14px; display: block; margin-bottom: 5px; color: #ccc; }
284
  .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; }
 
285
  .submit-btn { width: 100%; padding: 15px; background: #2980b9; color: white; border: none; border-radius: 8px; font-size: 18px; font-weight: bold; cursor: pointer; }
286
  .loader { width: 100px; height: 10px; background: #e67e22; margin: 20px auto; animation: load 1s infinite alternate; }
287
  @keyframes load { from { width: 20px; } to { width: 100px; } }
@@ -302,15 +347,13 @@ INDEX_HTML = '''
302
  background: white;
303
  box-shadow: 0 5px 30px rgba(0,0,0,0.6);
304
  position: relative; overflow: hidden;
305
- border: 5px solid #ffffff;
306
  flex-shrink: 0;
307
  }
308
 
309
  .comic-grid {
310
- width: 100%; height: 100%; position: relative;
311
- background: #ffffff;
312
- --y: 50%; --t1: 100%; --t2: 100%; --b1: 100%; --b2: 100%;
313
- --gap: 5px;
314
  }
315
 
316
  .panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
@@ -357,18 +400,30 @@ INDEX_HTML = '''
357
  mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
358
  }
359
  .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))); }
360
- .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); }
361
- .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; }
362
- .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; }
363
-
364
- .speech-bubble.thought { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 2px dashed #555; border-radius: 50%; }
 
365
  .speech-bubble.thought::before { display:none; }
366
  .thought-dot { position: absolute; background-color: var(--bubble-fill, #fff); border: 2px solid #555; border-radius: 50%; z-index: -1; }
367
- .thought-dot-1 { width: 15px; height: 15px; bottom:-15px; left:20px; } .thought-dot-2 { width: 10px; height: 10px; bottom:-25px; left:10px; }
368
-
369
- .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%); }
 
 
 
 
 
 
370
 
371
- .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; }
 
 
 
 
 
372
 
373
  .resize-handle { position: absolute; bottom:-5px; right:-5px; width:15px; height:15px; background:#3498db; border:1px solid white; cursor:se-resize; display:none; }
374
  .speech-bubble.selected .resize-handle { display:block; }
@@ -387,43 +442,103 @@ INDEX_HTML = '''
387
  .save-btn { background: #8e44ad; color: white; }
388
 
389
  .tip { text-align:center; padding:10px; background:#e74c3c; color:white; font-weight:bold; margin-bottom:20px; border-radius:5px; }
 
 
 
390
  </style>
391
  </head> <body>
392
 
393
  <div id="upload-container">
394
  <div class="upload-box">
395
- <h1>⚡ Square HD Comic</h1>
396
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
397
  <label for="file-upload" class="file-label">📁 Choose Video</label>
398
  <span id="fn" style="margin-bottom:10px; display:block; color:#aaa;">No file selected</span>
399
- <div class="page-input-group"> <label>📚 Total Pages:</label> <input type="number" id="page-count" value="4" min="1" max="15"> </div>
 
 
 
 
 
400
  <button class="submit-btn" onclick="upload()">🚀 Generate</button>
401
  <button id="restore-draft-btn" class="reset-btn" style="display:none; margin-top:10px;" onclick="restoreDraft()">📂 Restore Draft</button>
402
- <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;"> <div class="loader"></div> <p id="status-text" style="margin-top:10px;">Processing...</p> </div>
 
 
 
 
 
 
 
 
403
  </div>
404
  </div>
405
 
406
  <div id="editor-container">
407
- <div class="tip">👉 Drag dots from RIGHT edge to LEFT to reveal 4 panels!</div>
408
  <div class="comic-wrapper" id="comic-container"></div>
409
  <input type="file" id="image-uploader" style="display: none;" accept="image/*">
 
410
  <div class="edit-controls">
411
  <h4>✏️ Editor</h4>
412
- <div class="control-group"> <button onclick="undo()" style="background:#7f8c8d; color:white;">↩️ Undo</button> <button onclick="saveComic()" class="save-btn">💾 Save Comic</button> </div>
 
 
 
 
 
413
  <div class="control-group">
414
  <label>💬 Bubble Styling:</label>
415
- <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>
416
- <select id="font-select" onchange="updateFont()"> <option value="'Comic Neue', cursive">Comic Neue</option> <option value="'Bangers', cursive">Bangers</option> <option value="'Lato', sans-serif">Modern</option> </select>
417
- <div class="color-grid"> <input type="color" id="bub-fill" value="#ffffff" onchange="updateColors()"> <input type="color" id="bub-text" value="#000000" onchange="updateColors()"> </div>
418
- <div class="button-grid"> <button onclick="addBubble()" class="action-btn">Add</button> <button onclick="deleteBubble()" class="reset-btn">Delete</button> </div>
419
- <div id="tail-controls">
420
- <button onclick="rotateTail()" class="secondary-btn" style="margin-top:5px;">🔄 Rotate Tail</button>
421
- <input type="range" min="10" max="90" value="50" oninput="slideTail(this.value)" title="Tail Pos">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  </div>
423
  </div>
424
- <div class="control-group"> <label>🖼️ Image:</label> <div class="button-grid"> <button onclick="adjustFrame('backward')" class="secondary-btn">⬅️ Frame</button> <button onclick="adjustFrame('forward')" class="action-btn">Frame ➡️</button> </div> </div>
425
- <div class="control-group"> <label>🔍 Zoom (Scroll):</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</button> </div>
426
- <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>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  </div>
428
  </div>
429
 
@@ -465,7 +580,13 @@ INDEX_HTML = '''
465
  localStorage.setItem('comic_draft_'+sid, JSON.stringify(state));
466
  }
467
 
468
- function undo() { if(historyStack.length > 1) { historyStack.pop(); const prev = JSON.parse(historyStack[historyStack.length-1]); restoreFromState(prev); } }
 
 
 
 
 
 
469
 
470
  function restoreFromState(stateData) {
471
  if(!stateData) return;
@@ -473,7 +594,10 @@ INDEX_HTML = '''
473
  stateData.forEach((pgData, i) => {
474
  if(i >= pages.length) return;
475
  const grid = pages[i].querySelector('.comic-grid');
476
- 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); }
 
 
 
477
  grid.querySelectorAll('.speech-bubble').forEach(b=>b.remove());
478
  pgData.bubbles.forEach(bData => { const b = createBubbleHTML(bData); grid.appendChild(b); });
479
  const panels = grid.querySelectorAll('.panel');
@@ -489,7 +613,14 @@ INDEX_HTML = '''
489
  }
490
 
491
  if(localStorage.getItem('comic_draft_'+sid)) document.getElementById('restore-draft-btn').style.display='inline-block';
492
- function restoreDraft() { document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='flex'; loadNewComic().then(() => { setTimeout(() => restoreFromState(JSON.parse(localStorage.getItem('comic_draft_'+sid))), 500); }); }
 
 
 
 
 
 
 
493
  async function upload() {
494
  const f = document.getElementById('file-upload').files[0];
495
  const pCount = document.getElementById('page-count').value;
@@ -502,6 +633,7 @@ INDEX_HTML = '''
502
  if(r.ok) interval = setInterval(checkStatus, 1500);
503
  else { const d = await r.json(); alert("Upload failed: " + d.message); location.reload(); }
504
  }
 
505
  async function checkStatus() {
506
  try {
507
  const r = await fetch(`/status?sid=${sid}`); const d = await r.json();
@@ -509,15 +641,21 @@ INDEX_HTML = '''
509
  if(d.progress >= 100) { clearInterval(interval); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='flex'; loadNewComic(); }
510
  } catch(e) {}
511
  }
 
512
  async function loadNewComic() {
513
  const r = await fetch(`/output/pages.json?sid=${sid}`);
514
  const data = await r.json();
515
  const cleanData = data.map(p => ({
516
  panels: p.panels.map(pan => ({ src: `/frames/${pan.image}?sid=${sid}` })),
517
- 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 }))
 
 
 
518
  }));
519
- renderFromState(cleanData); saveState();
 
520
  }
 
521
  function renderFromState(pagesData) {
522
  const con = document.getElementById('comic-container'); con.innerHTML = '';
523
  pagesData.forEach((page, pageIdx) => {
@@ -525,6 +663,7 @@ INDEX_HTML = '''
525
  pageWrapper.innerHTML = `<h2 class="page-title">Page ${pageIdx + 1}</h2>`;
526
  const div = document.createElement('div'); div.className = 'comic-page';
527
  const grid = document.createElement('div'); grid.className = 'comic-grid';
 
528
  page.panels.forEach((pan, idx) => {
529
  const pDiv = document.createElement('div'); pDiv.className = 'panel';
530
  const img = document.createElement('img');
@@ -534,12 +673,28 @@ INDEX_HTML = '''
534
  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(); };
535
  pDiv.appendChild(img); grid.appendChild(pDiv);
536
  });
 
537
  grid.append(createHandle('h-t1', grid, 't1'), createHandle('h-t2', grid, 't2'), createHandle('h-b1', grid, 'b1'), createHandle('h-b2', grid, 'b2'));
538
- if(page.bubbles) { page.bubbles.forEach((bData, bIdx) => { if(bData.text) { const b = createBubbleHTML(bData); grid.appendChild(b); } }); }
 
 
 
 
 
 
 
 
 
 
539
  div.appendChild(grid); pageWrapper.appendChild(div); con.appendChild(pageWrapper);
540
  });
541
  }
542
- 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; }
 
 
 
 
 
543
 
544
  function createBubbleHTML(data) {
545
  const b = document.createElement('div');
@@ -575,6 +730,7 @@ INDEX_HTML = '''
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') {
@@ -597,18 +753,19 @@ INDEX_HTML = '''
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
 
602
  function selectBubble(el) {
603
  if(selectedBubble) selectedBubble.classList.remove('selected');
604
  selectedBubble = el; el.classList.add('selected');
605
  document.getElementById('bubble-type').value = el.dataset.type;
606
  document.getElementById('font-select').value = el.style.fontFamily || "'Comic Neue', cursive";
607
-
608
- // Show/Hide Tail Controls
609
- document.getElementById('tail-controls').style.display = (el.dataset.type === 'speech' || el.dataset.type === 'thought') ? 'block' : 'none';
610
  }
611
-
612
  function selectPanel(el) {
613
  if(selectedPanel) selectedPanel.classList.remove('selected');
614
  selectedPanel = el; el.classList.add('selected');
@@ -640,19 +797,6 @@ INDEX_HTML = '''
640
  function updateFont() { if(selectedBubble) { selectedBubble.style.fontFamily = document.getElementById('font-select').value; saveState(); } }
641
  function slideTail(val) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', val+'%'); saveState(); } }
642
 
643
- function rotateTail() {
644
- if(!selectedBubble) return;
645
- const type = selectedBubble.dataset.type;
646
- if(type === 'speech') {
647
- const positions = ['tail-bottom', 'tail-right', 'tail-top', 'tail-left'];
648
- let current = positions.find(p => selectedBubble.classList.contains(p)) || 'tail-bottom';
649
- selectedBubble.classList.remove(current);
650
- let next = positions[(positions.indexOf(current)+1)%4];
651
- selectedBubble.classList.add(next);
652
- }
653
- saveState();
654
- }
655
-
656
  function handleZoom(val) { if(selectedPanel) { const img = selectedPanel.querySelector('img'); img.dataset.zoom = val; updateImageTransform(img); saveState(); } }
657
  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})`; }
658
  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(); } }
@@ -749,6 +893,23 @@ def regen():
749
  gen = EnhancedComicGenerator(sid)
750
  return jsonify(regen_frame_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], d['direction']))
751
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
752
  @app.route('/save_comic', methods=['POST'])
753
  def save_comic():
754
  sid = request.args.get('sid')
 
1
+ import spaces # <--- CRITICAL: MUST BE THE FIRST IMPORT
2
  import os
3
  import time
4
  import threading
 
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):
78
+ self.panels = panels
79
+ self.bubbles = bubbles
80
+
81
  # ======================================================
82
  # 🧠 GPU GENERATION
83
  # ======================================================
 
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:
 
123
  else:
124
  raw_moments = []
125
 
126
+ # 4 Panels Per Page
127
  panels_per_page = 4
128
  total_panels_needed = int(target_pages) * panels_per_page
129
 
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:
 
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)
 
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
 
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)
 
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
 
 
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
268
+ # ======================================================
269
  class EnhancedComicGenerator:
270
  def __init__(self, sid):
271
  self.sid = sid
 
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; }
 
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; }
328
  .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; }
329
+
330
  .submit-btn { width: 100%; padding: 15px; background: #2980b9; color: white; border: none; border-radius: 8px; font-size: 18px; font-weight: bold; cursor: pointer; }
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; } }
 
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; }
 
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; }
 
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
 
 
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;
 
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');
 
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];
626
  const pCount = document.getElementById('page-count').value;
 
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();
 
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
+
645
  async function loadNewComic() {
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) => {
 
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');
 
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');
 
730
  const newText = prompt("Edit Text:", textSpan.textContent);
731
  if(newText !== null) { textSpan.textContent = newText; saveState(); }
732
  }
733
+
734
  document.addEventListener('mousemove', (e) => {
735
  if(!dragType) return;
736
  if(dragType === 'handle') {
 
753
  activeObj.b.style.height = (activeObj.startH + dy) + 'px';
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');
 
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(); } }
 
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')