tester343 commited on
Commit
56683fe
·
verified ·
1 Parent(s): 28962fd

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +119 -80
app_enhanced.py CHANGED
@@ -54,14 +54,12 @@ def generate_save_code(length=8):
54
  # ======================================================
55
  def bubble(dialog="", x=50, y=20, type='speech'):
56
  classes = f"speech-bubble {type}"
57
- # Default tails for types that usually have them
58
  if type == 'speech':
59
  classes += " tail-bottom"
60
  elif type == 'thought':
61
  classes += " pos-bl"
62
  elif type == 'reaction':
63
  classes += " tail-bottom"
64
- # Narration doesn't need a tail class by default
65
 
66
  return {
67
  'dialog': dialog,
@@ -100,6 +98,7 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
100
  duration = total_frames / fps
101
  cap.release()
102
 
 
103
  user_srt = os.path.join(user_dir, 'subs.srt')
104
  try:
105
  get_real_subtitles(video_path)
@@ -144,13 +143,10 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
144
  cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
145
  ret, frame = cap.read()
146
  if ret:
147
- # 1920x1080 (HD 16:9) - No Crop
148
  frame = cv2.resize(frame, (1920, 1080))
149
-
150
  fname = f"frame_{count:04d}.png"
151
  p = os.path.join(frames_dir, fname)
152
  cv2.imwrite(p, frame)
153
-
154
  frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
155
  frame_files_ordered.append(fname)
156
  frame_times.append(mid)
@@ -163,11 +159,15 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
163
  for i, f in enumerate(frame_files_ordered):
164
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
165
 
 
166
  b_type = 'speech'
167
- if '(' in dialogue: b_type = 'narration'
168
- elif '!' in dialogue: b_type = 'reaction'
169
- elif '?' in dialogue: b_type = 'speech'
 
 
170
 
 
171
  pos_idx = i % 4
172
  if pos_idx == 0: bx, by = 150, 80
173
  elif pos_idx == 1: bx, by = 580, 80
@@ -288,7 +288,7 @@ class EnhancedComicGenerator:
288
  # 🌐 ROUTES & FRONTEND
289
  # ======================================================
290
  INDEX_HTML = '''
291
- <!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; }
292
 
293
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
294
  .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; }
@@ -311,7 +311,7 @@ INDEX_HTML = '''
311
  .loader { width: 100px; height: 10px; background: #e67e22; margin: 20px auto; animation: load 1s infinite alternate; }
312
  @keyframes load { from { width: 20px; } to { width: 100px; } }
313
 
314
- /* === 864x1080 LAYOUT + 5px WHITE BORDER === */
315
  .comic-wrapper {
316
  max-width: 1000px; margin: 0 auto;
317
  display: flex; flex-direction: column; align-items: center;
@@ -324,30 +324,34 @@ INDEX_HTML = '''
324
  background: white;
325
  box-shadow: 0 5px 30px rgba(0,0,0,0.6);
326
  position: relative; overflow: hidden;
327
- border: 5px solid #ffffff;
328
  flex-shrink: 0;
329
  }
330
 
331
  .comic-grid {
332
  width: 100%; height: 100%; position: relative;
333
- background: #ffffff; /* White background creates gaps */
334
  --y: 50%; --t1: 100%; --t2: 100%; --b1: 100%; --b2: 100%;
335
- --gap: 5px; /* 5px Gap */
336
  }
337
 
338
- .panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
 
 
 
 
 
 
339
 
340
- /* === IMAGE NO CUT (Object Fit Contain) === */
341
  .panel img {
342
  width: 100%; height: 100%;
343
- object-fit: contain; /* Prevents cutting */
344
- background: #000; /* Letterbox fill */
345
  transform-origin: center;
346
  transition: transform 0.05s ease-out;
347
  display: block;
348
  }
349
  .panel img.panning { cursor: grabbing; transition: none; }
350
- .panel.selected { outline: 4px solid #3498db; z-index: 5; }
351
 
352
  /* Clip Paths */
353
  .panel:nth-child(1) { clip-path: polygon(0 0, calc(var(--t1) - var(--gap)) 0, calc(var(--t2) - var(--gap)) calc(var(--y) - var(--gap)), 0 calc(var(--y) - var(--gap))); z-index: 1; }
@@ -355,14 +359,13 @@ INDEX_HTML = '''
355
  .panel:nth-child(3) { clip-path: polygon(0 calc(var(--y) + var(--gap)), calc(var(--b1) - var(--gap)) calc(var(--y) + var(--gap)), calc(var(--b2) - var(--gap)) 100%, 0 100%); z-index: 1; }
356
  .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; }
357
 
358
- /* Handles */
359
  .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); }
360
  .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; }
361
  .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; }
363
  .h-b2 { background: #2ecc71; left: var(--b2); top: 100%; margin-top: -15px; }
364
 
365
- /* === BUBBLES === */
366
  .speech-bubble {
367
  position: absolute; display: flex; justify-content: center; align-items: center;
368
  min-width: 60px; min-height: 40px; box-sizing: border-box;
@@ -376,42 +379,21 @@ INDEX_HTML = '''
376
  }
377
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
378
 
379
- /* SPEECH */
380
- .speech-bubble.speech {
381
- --b: 3em; --h: 1.8em; --t: 0.6; --p: var(--tail-pos, 50%); --r: 1.2em;
382
- background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); padding: 0;
383
- 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);
384
- }
385
- .speech-bubble.speech:before {
386
- content: ""; position: absolute; width: var(--b); height: var(--h);
387
- background: inherit; border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
388
- -webkit-mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
389
- mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
390
- }
391
  .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))); }
392
  .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); }
393
  .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; }
394
  .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; }
395
 
396
- /* THOUGHT */
397
  .speech-bubble.thought { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 2px dashed #555; border-radius: 50%; }
398
  .speech-bubble.thought::before { display:none; }
399
  .thought-dot { position: absolute; background-color: var(--bubble-fill, #fff); border: 2px solid #555; border-radius: 50%; z-index: -1; }
400
- .thought-dot-1 { width: 15px; height: 15px; } .thought-dot-2 { width: 10px; height: 10px; }
401
- /* Positions */
402
- .speech-bubble.thought.tail-bottom .thought-dot-1 { bottom: -18px; left: 20%; } .speech-bubble.thought.tail-bottom .thought-dot-2 { bottom: -30px; left: 15%; }
403
- .speech-bubble.thought.tail-top .thought-dot-1 { top: -18px; right: 20%; } .speech-bubble.thought.tail-top .thought-dot-2 { top: -30px; right: 15%; }
404
- .speech-bubble.thought.tail-left .thought-dot-1 { left: -18px; top: 40%; } .speech-bubble.thought.tail-left .thought-dot-2 { left: -30px; top: 35%; }
405
- .speech-bubble.thought.tail-right .thought-dot-1 { right: -18px; bottom: 40%; } .speech-bubble.thought.tail-right .thought-dot-2 { right: -30px; bottom: 35%; }
406
-
407
- .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%); }
408
 
409
- /* 🎯 FIXED NARRATION SIZE */
410
- .speech-bubble.narration {
411
- background: #fff8dc; border: 2px solid #000; color: #000;
412
- border-radius: 0; font-family: 'Lato';
413
- width: 200px !important; height: 60px !important;
414
- }
415
 
416
  .resize-handle { position: absolute; bottom:-5px; right:-5px; width:15px; height:15px; background:#3498db; border:1px solid white; cursor:se-resize; display:none; }
417
  .speech-bubble.selected .resize-handle { display:block; }
@@ -507,9 +489,9 @@ INDEX_HTML = '''
507
  </div>
508
 
509
  <div class="control-group">
510
- <label>🖼️ Time Frame (Seconds Only):</label>
511
  <div class="timestamp-controls">
512
- <input type="text" id="timestamp-input" placeholder="Seconds (e.g 125.5)">
513
  <button onclick="gotoTimestamp()" class="action-btn">Go</button>
514
  </div>
515
  <label style="margin-top:10px">🖼️ Image Control:</label>
@@ -549,6 +531,23 @@ INDEX_HTML = '''
549
  let dragType = null, activeObj = null, dragStart = {x:0, y:0};
550
  let historyStack = [];
551
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
  function saveState() {
553
  const state = [];
554
  document.querySelectorAll('.comic-page').forEach(pg => {
@@ -781,11 +780,11 @@ INDEX_HTML = '''
781
  const img = el.querySelector('img');
782
  document.getElementById('zoom-slider').value = img.dataset.zoom || 100;
783
 
784
- // 🎯 POPULATE TIME BOX
785
- if(img.dataset.time) document.getElementById('timestamp-input').value = img.dataset.time;
786
  }
787
 
788
- function addBubble() { const grid = document.querySelector('.comic-grid'); if(grid) { const b = createBubbleHTML({ text: "Text", left: "50%", top: "50%" }); grid.appendChild(b); selectBubble(b); saveState(); } }
789
  function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); selectedBubble=null; saveState(); } }
790
 
791
  function updateBubbleType() {
@@ -811,6 +810,7 @@ INDEX_HTML = '''
811
  function rotateTail() {
812
  if(!selectedBubble) return;
813
  const type = selectedBubble.dataset.type;
 
814
  if(type === 'speech' || type === 'thought' || type === 'reaction') {
815
  const positions = ['tail-bottom', 'tail-right', 'tail-top', 'tail-left'];
816
  let current = positions.find(p => selectedBubble.classList.contains(p)) || 'tail-bottom';
@@ -832,7 +832,11 @@ INDEX_HTML = '''
832
  const fd = new FormData(); fd.append('image', e.target.files[0]);
833
  const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd});
834
  const d = await r.json();
835
- if(d.success) { selectedPanel.querySelector('img').src = `/frames/${d.new_filename}?sid=${sid}`; saveState(); }
 
 
 
 
836
  inp.value = '';
837
  };
838
  inp.click();
@@ -847,7 +851,7 @@ INDEX_HTML = '''
847
  if(d.success) {
848
  img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
849
  img.dataset.time = d.new_time.toFixed(2);
850
- document.getElementById('timestamp-input').value = d.new_time.toFixed(2);
851
  }
852
  img.style.opacity='1'; saveState();
853
  }
@@ -856,13 +860,17 @@ INDEX_HTML = '''
856
  if(!selectedPanel) return alert("Select a panel");
857
  let v = document.getElementById('timestamp-input').value.trim();
858
  if(!v) return;
 
 
 
 
859
  const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0];
860
- img.style.opacity = '0.5';
861
- const r = await fetch(`/goto_timestamp?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, timestamp:v}) });
862
  const d = await r.json();
863
  if(d.success) {
864
  img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
865
  img.dataset.time = d.new_time.toFixed(2);
 
866
  }
867
  img.style.opacity = '1'; saveState();
868
  }
@@ -892,13 +900,24 @@ INDEX_HTML = '''
892
  </body> </html> '''
893
 
894
  @app.route('/')
895
- def index(): return INDEX_HTML
 
896
 
897
  @app.route('/uploader', methods=['POST'])
898
  def upload():
899
- sid = request.args.get('sid'); f = request.files.get('file'); pages = request.form.get('target_pages', 4)
900
- gen = EnhancedComicGenerator(sid); gen.cleanup(); f.save(gen.video_path)
901
- threading.Thread(target=gen.run, args=(pages,)).start()
 
 
 
 
 
 
 
 
 
 
902
  return jsonify({'success': True})
903
 
904
  @app.route('/status')
@@ -909,25 +928,33 @@ def get_status():
909
  return jsonify({'progress': 0, 'message': "Waiting..."})
910
 
911
  @app.route('/output/<path:filename>')
912
- def get_output(filename): return send_from_directory(os.path.join(BASE_USER_DIR, request.args.get('sid'), 'output'), filename)
 
 
 
913
  @app.route('/frames/<path:filename>')
914
- def get_frame(filename): return send_from_directory(os.path.join(BASE_USER_DIR, request.args.get('sid'), 'frames'), filename)
 
 
915
 
916
  @app.route('/regenerate_frame', methods=['POST'])
917
  def regen():
918
- sid = request.args.get('sid'); d = request.get_json()
 
919
  gen = EnhancedComicGenerator(sid)
920
  return jsonify(regen_frame_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], d['direction']))
921
 
922
  @app.route('/goto_timestamp', methods=['POST'])
923
  def go_time():
924
- sid = request.args.get('sid'); d = request.get_json()
 
925
  gen = EnhancedComicGenerator(sid)
926
  return jsonify(get_frame_at_ts_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], float(d['timestamp'])))
927
 
928
  @app.route('/replace_panel', methods=['POST'])
929
  def rep_panel():
930
- sid = request.args.get('sid'); f = request.files['image']
 
931
  frames_dir = os.path.join(BASE_USER_DIR, sid, 'frames')
932
  os.makedirs(frames_dir, exist_ok=True)
933
  fname = f"replaced_{int(time.time() * 1000)}.png"
@@ -936,25 +963,37 @@ def rep_panel():
936
 
937
  @app.route('/save_comic', methods=['POST'])
938
  def save_comic():
939
- sid = request.args.get('sid'); data = request.get_json(); save_code = generate_save_code()
940
- save_dir = os.path.join(SAVED_COMICS_DIR, save_code); os.makedirs(save_dir, exist_ok=True)
941
- user_frames_dir = os.path.join(BASE_USER_DIR, sid, 'frames'); saved_frames_dir = os.path.join(save_dir, 'frames')
942
- if os.path.exists(user_frames_dir):
943
- if os.path.exists(saved_frames_dir): shutil.rmtree(saved_frames_dir)
944
- shutil.copytree(user_frames_dir, saved_frames_dir)
945
- with open(os.path.join(save_dir, 'comic_state.json'), 'w') as f: json.dump({'originalSid': sid, 'pages': data['pages'], 'savedAt': time.time()}, f)
946
- return jsonify({'success': True, 'code': save_code})
 
 
 
 
 
 
 
947
 
948
  @app.route('/load_comic/<code>')
949
  def load_comic(code):
950
- code = code.upper(); save_dir = os.path.join(SAVED_COMICS_DIR, code)
 
951
  if not os.path.exists(save_dir): return jsonify({'success': False, 'message': 'Code not found'})
952
- with open(os.path.join(save_dir, 'comic_state.json'), 'r') as f: data = json.load(f)
953
- orig_sid = data['originalSid']; saved_frames = os.path.join(save_dir, 'frames'); user_frames = os.path.join(BASE_USER_DIR, orig_sid, 'frames')
954
- os.makedirs(user_frames, exist_ok=True)
955
- for fn in os.listdir(saved_frames):
956
- if not os.path.exists(os.path.join(user_frames, fn)): shutil.copy2(os.path.join(saved_frames, fn), os.path.join(user_frames, fn))
957
- return jsonify({'success': True, 'originalSid': orig_sid, 'pages': data['pages']})
 
 
 
 
958
 
959
  if __name__ == '__main__':
960
  try: gpu_warmup()
 
54
  # ======================================================
55
  def bubble(dialog="", x=50, y=20, type='speech'):
56
  classes = f"speech-bubble {type}"
 
57
  if type == 'speech':
58
  classes += " tail-bottom"
59
  elif type == 'thought':
60
  classes += " pos-bl"
61
  elif type == 'reaction':
62
  classes += " tail-bottom"
 
63
 
64
  return {
65
  'dialog': dialog,
 
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)
 
143
  cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
144
  ret, frame = cap.read()
145
  if ret:
 
146
  frame = cv2.resize(frame, (1920, 1080))
 
147
  fname = f"frame_{count:04d}.png"
148
  p = os.path.join(frames_dir, fname)
149
  cv2.imwrite(p, frame)
 
150
  frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
151
  frame_files_ordered.append(fname)
152
  frame_times.append(mid)
 
159
  for i, f in enumerate(frame_files_ordered):
160
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
161
 
162
+ # 🎯 STRICT BUBBLE TYPE LOGIC (Prefer Speech)
163
  b_type = 'speech'
164
+ if '(' in dialogue:
165
+ b_type = 'narration'
166
+ elif '!' in dialogue and dialogue.isupper() and len(dialogue) < 10:
167
+ # Only use reaction if VERY short and yelling
168
+ b_type = 'reaction'
169
 
170
+ # Smart Positioning for 864x1080
171
  pos_idx = i % 4
172
  if pos_idx == 0: bx, by = 150, 80
173
  elif pos_idx == 1: bx, by = 580, 80
 
288
  # 🌐 ROUTES & FRONTEND
289
  # ======================================================
290
  INDEX_HTML = '''
291
+ <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>864x1080 Robust 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; }
292
 
293
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
294
  .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; }
 
311
  .loader { width: 100px; height: 10px; background: #e67e22; margin: 20px auto; animation: load 1s infinite alternate; }
312
  @keyframes load { from { width: 20px; } to { width: 100px; } }
313
 
314
+ /* === 864x1080 LAYOUT + WHITE BORDER === */
315
  .comic-wrapper {
316
  max-width: 1000px; margin: 0 auto;
317
  display: flex; flex-direction: column; align-items: center;
 
324
  background: white;
325
  box-shadow: 0 5px 30px rgba(0,0,0,0.6);
326
  position: relative; overflow: hidden;
327
+ border: 5px solid #ffffff; /* White Page Border */
328
  flex-shrink: 0;
329
  }
330
 
331
  .comic-grid {
332
  width: 100%; height: 100%; position: relative;
333
+ background: #ffffff; /* White Gap */
334
  --y: 50%; --t1: 100%; --t2: 100%; --b1: 100%; --b2: 100%;
335
+ --gap: 5px; /* Size of White Gap */
336
  }
337
 
338
+ /* 🎯 BLACK BORDER INSIDE THE PANEL */
339
+ .panel {
340
+ position: absolute; top: 0; left: 0; width: 100%; height: 100%;
341
+ overflow: hidden; background: #1a1a1a; cursor: grab;
342
+ border: 2px solid #000; /* Black Inner Border */
343
+ box-sizing: border-box;
344
+ }
345
 
 
346
  .panel img {
347
  width: 100%; height: 100%;
348
+ object-fit: contain; /* 0% Cut */
 
349
  transform-origin: center;
350
  transition: transform 0.05s ease-out;
351
  display: block;
352
  }
353
  .panel img.panning { cursor: grabbing; transition: none; }
354
+ .panel.selected { border: 4px solid #3498db; z-index: 5; }
355
 
356
  /* Clip Paths */
357
  .panel:nth-child(1) { clip-path: polygon(0 0, calc(var(--t1) - var(--gap)) 0, calc(var(--t2) - var(--gap)) calc(var(--y) - var(--gap)), 0 calc(var(--y) - var(--gap))); z-index: 1; }
 
359
  .panel:nth-child(3) { clip-path: polygon(0 calc(var(--y) + var(--gap)), calc(var(--b1) - var(--gap)) calc(var(--y) + var(--gap)), calc(var(--b2) - var(--gap)) 100%, 0 100%); z-index: 1; }
360
  .panel:nth-child(4) { clip-path: polygon(calc(var(--b1) + var(--gap)) calc(var(--y) + var(--gap)), 100% calc(var(--y) + var(--gap)), 100% 100%, calc(var(--b2) + var(--gap)) 100%); z-index: 1; }
361
 
 
362
  .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); }
363
  .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; }
364
  .h-t2 { background: #3498db; left: var(--t2); top: 50%; margin-top: -15px; }
365
  .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; }
366
  .h-b2 { background: #2ecc71; left: var(--b2); top: 100%; margin-top: -15px; }
367
 
368
+ /* BUBBLES */
369
  .speech-bubble {
370
  position: absolute; display: flex; justify-content: center; align-items: center;
371
  min-width: 60px; min-height: 40px; box-sizing: border-box;
 
379
  }
380
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
381
 
382
+ .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); }
383
+ .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%); }
 
 
 
 
 
 
 
 
 
 
384
  .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))); }
385
  .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); }
386
  .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; }
387
  .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; }
388
 
 
389
  .speech-bubble.thought { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 2px dashed #555; border-radius: 50%; }
390
  .speech-bubble.thought::before { display:none; }
391
  .thought-dot { position: absolute; background-color: var(--bubble-fill, #fff); border: 2px solid #555; border-radius: 50%; z-index: -1; }
392
+ .thought-dot-1 { width: 15px; height: 15px; bottom:-15px; left:20px; } .thought-dot-2 { width: 10px; height: 10px; bottom:-25px; left:10px; }
393
+ .speech-bubble.thought.pos-bl .thought-dot-1 { left: 20px; bottom: -20px; }
 
 
 
 
 
 
394
 
395
+ .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%); }
396
+ .speech-bubble.narration { background: #eee; border: 2px solid #000; color: #000; border-radius: 0; font-family: 'Lato'; bottom: 10px; left: 50%; transform: translateX(-50%); }
 
 
 
 
397
 
398
  .resize-handle { position: absolute; bottom:-5px; right:-5px; width:15px; height:15px; background:#3498db; border:1px solid white; cursor:se-resize; display:none; }
399
  .speech-bubble.selected .resize-handle { display:block; }
 
489
  </div>
490
 
491
  <div class="control-group">
492
+ <label>🖼️ Time Frame (MM:SS):</label>
493
  <div class="timestamp-controls">
494
+ <input type="text" id="timestamp-input" placeholder="Click panel for time (e.g. 02:15)">
495
  <button onclick="gotoTimestamp()" class="action-btn">Go</button>
496
  </div>
497
  <label style="margin-top:10px">🖼️ Image Control:</label>
 
531
  let dragType = null, activeObj = null, dragStart = {x:0, y:0};
532
  let historyStack = [];
533
 
534
+ // --- TIME HELPERS ---
535
+ function formatTime(s) {
536
+ s = parseFloat(s);
537
+ let m = Math.floor(s / 60);
538
+ let sec = Math.floor(s % 60);
539
+ let ms = Math.round((s % 1) * 100);
540
+ return `${m < 10 ? '0'+m : m}:${sec < 10 ? '0'+sec : sec}.${ms}`;
541
+ }
542
+
543
+ function parseTime(str) {
544
+ if(str.includes(':')) {
545
+ let p = str.split(':');
546
+ return parseInt(p[0]) * 60 + parseFloat(p[1]);
547
+ }
548
+ return parseFloat(str);
549
+ }
550
+
551
  function saveState() {
552
  const state = [];
553
  document.querySelectorAll('.comic-page').forEach(pg => {
 
780
  const img = el.querySelector('img');
781
  document.getElementById('zoom-slider').value = img.dataset.zoom || 100;
782
 
783
+ // 🎯 POPULATE TIME BOX (Formatted)
784
+ if(img.dataset.time) document.getElementById('timestamp-input').value = formatTime(img.dataset.time);
785
  }
786
 
787
+ function addBubble() { const grid = document.querySelector('.comic-grid'); if(grid) { const b = createBubbleHTML({ text: "Text", left: "50%", top: "50%", type: "speech" }); grid.appendChild(b); selectBubble(b); saveState(); } }
788
  function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); selectedBubble=null; saveState(); } }
789
 
790
  function updateBubbleType() {
 
810
  function rotateTail() {
811
  if(!selectedBubble) return;
812
  const type = selectedBubble.dataset.type;
813
+ // Allow rotation for Speech, Thought, AND Reaction
814
  if(type === 'speech' || type === 'thought' || type === 'reaction') {
815
  const positions = ['tail-bottom', 'tail-right', 'tail-top', 'tail-left'];
816
  let current = positions.find(p => selectedBubble.classList.contains(p)) || 'tail-bottom';
 
832
  const fd = new FormData(); fd.append('image', e.target.files[0]);
833
  const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd});
834
  const d = await r.json();
835
+ if(d.success) {
836
+ const img = selectedPanel.querySelector('img');
837
+ img.src = `/frames/${d.new_filename}?sid=${sid}`;
838
+ saveState();
839
+ }
840
  inp.value = '';
841
  };
842
  inp.click();
 
851
  if(d.success) {
852
  img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
853
  img.dataset.time = d.new_time.toFixed(2);
854
+ document.getElementById('timestamp-input').value = formatTime(d.new_time);
855
  }
856
  img.style.opacity='1'; saveState();
857
  }
 
860
  if(!selectedPanel) return alert("Select a panel");
861
  let v = document.getElementById('timestamp-input').value.trim();
862
  if(!v) return;
863
+ // Parse Time (MM:SS or Seconds)
864
+ let s = parseTime(v);
865
+ if(isNaN(s)) return alert("Invalid Time");
866
+
867
  const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0];
868
+ const r = await fetch(`/goto_timestamp?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, timestamp:s}) });
 
869
  const d = await r.json();
870
  if(d.success) {
871
  img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
872
  img.dataset.time = d.new_time.toFixed(2);
873
+ document.getElementById('timestamp-input').value = formatTime(d.new_time);
874
  }
875
  img.style.opacity = '1'; saveState();
876
  }
 
900
  </body> </html> '''
901
 
902
  @app.route('/')
903
+ def index():
904
+ return INDEX_HTML
905
 
906
  @app.route('/uploader', methods=['POST'])
907
  def upload():
908
+ sid = request.args.get('sid') or request.form.get('sid')
909
+ if not sid: return jsonify({'success': False, 'message': 'Missing session ID'}), 400
910
+
911
+ file = request.files.get('file')
912
+ if not file or file.filename == '': return jsonify({'success': False, 'message': 'No file uploaded'}), 400
913
+
914
+ target_pages = request.form.get('target_pages', 4)
915
+ gen = EnhancedComicGenerator(sid)
916
+ gen.cleanup()
917
+ file.save(gen.video_path)
918
+ gen.write_status("Starting...", 5)
919
+
920
+ threading.Thread(target=gen.run, args=(target_pages,)).start()
921
  return jsonify({'success': True})
922
 
923
  @app.route('/status')
 
928
  return jsonify({'progress': 0, 'message': "Waiting..."})
929
 
930
  @app.route('/output/<path:filename>')
931
+ def get_output(filename):
932
+ sid = request.args.get('sid')
933
+ return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
934
+
935
  @app.route('/frames/<path:filename>')
936
+ def get_frame(filename):
937
+ sid = request.args.get('sid')
938
+ return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
939
 
940
  @app.route('/regenerate_frame', methods=['POST'])
941
  def regen():
942
+ sid = request.args.get('sid')
943
+ d = request.get_json()
944
  gen = EnhancedComicGenerator(sid)
945
  return jsonify(regen_frame_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], d['direction']))
946
 
947
  @app.route('/goto_timestamp', methods=['POST'])
948
  def go_time():
949
+ sid = request.args.get('sid')
950
+ d = request.get_json()
951
  gen = EnhancedComicGenerator(sid)
952
  return jsonify(get_frame_at_ts_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], float(d['timestamp'])))
953
 
954
  @app.route('/replace_panel', methods=['POST'])
955
  def rep_panel():
956
+ sid = request.args.get('sid')
957
+ f = request.files['image']
958
  frames_dir = os.path.join(BASE_USER_DIR, sid, 'frames')
959
  os.makedirs(frames_dir, exist_ok=True)
960
  fname = f"replaced_{int(time.time() * 1000)}.png"
 
963
 
964
  @app.route('/save_comic', methods=['POST'])
965
  def save_comic():
966
+ sid = request.args.get('sid')
967
+ try:
968
+ data = request.get_json()
969
+ save_code = generate_save_code()
970
+ save_dir = os.path.join(SAVED_COMICS_DIR, save_code)
971
+ os.makedirs(save_dir, exist_ok=True)
972
+ user_frames_dir = os.path.join(BASE_USER_DIR, sid, 'frames')
973
+ saved_frames_dir = os.path.join(save_dir, 'frames')
974
+ if os.path.exists(user_frames_dir):
975
+ if os.path.exists(saved_frames_dir): shutil.rmtree(saved_frames_dir)
976
+ shutil.copytree(user_frames_dir, saved_frames_dir)
977
+ with open(os.path.join(save_dir, 'comic_state.json'), 'w') as f:
978
+ json.dump({'originalSid': sid, 'pages': data['pages'], 'savedAt': time.time()}, f)
979
+ return jsonify({'success': True, 'code': save_code})
980
+ except Exception as e: return jsonify({'success': False, 'message': str(e)})
981
 
982
  @app.route('/load_comic/<code>')
983
  def load_comic(code):
984
+ code = code.upper()
985
+ save_dir = os.path.join(SAVED_COMICS_DIR, code)
986
  if not os.path.exists(save_dir): return jsonify({'success': False, 'message': 'Code not found'})
987
+ try:
988
+ with open(os.path.join(save_dir, 'comic_state.json'), 'r') as f: data = json.load(f)
989
+ orig_sid = data['originalSid']
990
+ saved_frames = os.path.join(save_dir, 'frames')
991
+ user_frames = os.path.join(BASE_USER_DIR, orig_sid, 'frames')
992
+ os.makedirs(user_frames, exist_ok=True)
993
+ for fn in os.listdir(saved_frames):
994
+ shutil.copy2(os.path.join(saved_frames, fn), os.path.join(user_frames, fn))
995
+ return jsonify({'success': True, 'originalSid': orig_sid, 'pages': data['pages']})
996
+ except Exception as e: return jsonify({'success': False, 'message': str(e)})
997
 
998
  if __name__ == '__main__':
999
  try: gpu_warmup()