tester343 commited on
Commit
b91bf5d
·
verified ·
1 Parent(s): ff994fe

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +291 -150
app_enhanced.py CHANGED
@@ -24,14 +24,12 @@ def gpu_warmup():
24
  return True
25
 
26
  # ======================================================
27
- # 💾 PERSISTENT STORAGE
28
  # ======================================================
29
  if os.path.exists('/data'):
30
  BASE_STORAGE_PATH = '/data'
31
- print("✅ Using Persistent Storage at /data")
32
  else:
33
  BASE_STORAGE_PATH = '.'
34
- print("⚠️ Using Ephemeral Storage")
35
 
36
  BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
37
  SAVED_COMICS_DIR = os.path.join(BASE_STORAGE_PATH, "saved_comics")
@@ -43,7 +41,7 @@ os.makedirs(SAVED_COMICS_DIR, exist_ok=True)
43
  # 🔧 APP CONFIG
44
  # ======================================================
45
  app = Flask(__name__)
46
- app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500MB Upload Limit
47
 
48
  def generate_save_code(length=8):
49
  chars = string.ascii_uppercase + string.digits
@@ -55,11 +53,11 @@ def generate_save_code(length=8):
55
  # ======================================================
56
  # 🧱 DATA CLASSES
57
  # ======================================================
58
- def bubble(dialog="", bubble_offset_x=50, bubble_offset_y=50, type='speech'):
59
  return {
60
  'dialog': dialog,
61
- 'bubble_offset_x': int(bubble_offset_x),
62
- 'bubble_offset_y': int(bubble_offset_y),
63
  'type': type,
64
  'tail_pos': '50%',
65
  'classes': f'speech-bubble {type} tail-bottom'
@@ -74,12 +72,11 @@ class Page:
74
  self.bubbles = bubbles
75
 
76
  # ======================================================
77
- # 🧠 GPU GENERATION
78
  # ======================================================
79
  @spaces.GPU(duration=120)
80
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
81
- print(f"🚀 Generating: {video_path}")
82
-
83
  import cv2
84
  import srt
85
  import numpy as np
@@ -103,9 +100,9 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
103
  valid_subs = [s for s in all_subs if s.content.strip()]
104
  raw_moments = [{'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
105
 
106
- if target_pages <= 0: target_pages = 1
107
  panels_per_page = 4
108
- total_panels_needed = target_pages * panels_per_page
109
 
110
  selected_moments = []
111
  if not raw_moments:
@@ -130,15 +127,11 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
130
 
131
  if ret:
132
  # ----------------------------------------------------
133
- # 🎯 QUALITY FIX: EXTRACT AT HD (1280px width)
134
- # Do NOT crop to square here. Keep 16:9 Aspect Ratio.
135
- # This allows the frontend "Zoom" to reveal details.
136
  # ----------------------------------------------------
137
- h, w = frame.shape[:2]
138
- aspect = w / h
139
- new_w = 1280
140
- new_h = int(new_w / aspect)
141
- frame = cv2.resize(frame, (new_w, new_h))
142
 
143
  fname = f"frame_{count:04d}.png"
144
  p = os.path.join(frames_dir, fname)
@@ -151,24 +144,27 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
151
  cap.release()
152
  with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
153
 
 
154
  bubbles_list = []
155
  for f in frame_files_ordered:
156
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
157
  b_type = 'speech'
158
  if '(' in dialogue: b_type = 'narration'
159
  elif '!' in dialogue: b_type = 'reaction'
160
- bubbles_list.append(bubble(dialog=dialogue, bubble_offset_x=50, bubble_offset_y=50, type=b_type))
161
 
 
162
  pages = []
163
- for i in range(target_pages):
164
  start_idx = i * 4
165
  end_idx = start_idx + 4
166
  p_frames = frame_files_ordered[start_idx:end_idx]
167
  p_bubbles = bubbles_list[start_idx:end_idx]
168
 
 
169
  while len(p_frames) < 4:
170
  fname = f"empty_{i}_{len(p_frames)}.png"
171
- img = np.zeros((720, 1280, 3), dtype=np.uint8); img[:] = (40,40,40)
172
  cv2.imwrite(os.path.join(frames_dir, fname), img)
173
  p_frames.append(fname)
174
  p_bubbles.append(bubble(dialog="", type='speech'))
@@ -205,12 +201,7 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
205
  cap.release()
206
 
207
  if ret:
208
- h, w = frame.shape[:2]
209
- aspect = w / h
210
- new_w = 1280
211
- new_h = int(new_w / aspect)
212
- frame = cv2.resize(frame, (new_w, new_h))
213
-
214
  p = os.path.join(frames_dir, fname)
215
  cv2.imwrite(p, frame)
216
 
@@ -220,6 +211,30 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
220
  return {"success": True, "message": f"Time: {new_t:.2f}s"}
221
  return {"success": False, "message": "End of video"}
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  # ======================================================
224
  # 💻 BACKEND CLASS
225
  # ======================================================
@@ -242,7 +257,7 @@ class EnhancedComicGenerator:
242
 
243
  def run(self, target_pages):
244
  try:
245
- self.write_status("Waiting for GPU...", 5)
246
  data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, int(target_pages))
247
  with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
248
  json.dump(data, f, indent=2)
@@ -256,15 +271,15 @@ class EnhancedComicGenerator:
256
  json.dump({'message': msg, 'progress': prog}, f)
257
 
258
  # ======================================================
259
- # 🌐 ROUTES & FRONTEND
260
  # ======================================================
261
  INDEX_HTML = '''
262
- <!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&display=swap" rel="stylesheet"> <style> * { box-sizing: border-box; } body { background-color: #222; font-family: 'Lato', sans-serif; color: #eee; margin: 0; min-height: 100vh; }
263
 
264
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
265
  .upload-box { max-width: 500px; width: 100%; padding: 40px; background: #333; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.5); text-align: center; }
266
 
267
- #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 100px; }
268
 
269
  h1 { color: #fff; margin-bottom: 20px; font-weight: 600; }
270
  .file-input { display: none; }
@@ -282,18 +297,18 @@ INDEX_HTML = '''
282
  @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
283
 
284
  /* === SQUARE COMIC LAYOUT (800x800) === */
285
- .comic-wrapper { max-width: 1000px; margin: 0 auto; }
286
- .page-wrapper { margin: 30px auto; display: flex; flex-direction: column; align-items: center; }
287
- .page-title { text-align: center; color: #eee; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
288
 
289
  .comic-page {
290
  width: 800px;
291
  height: 800px;
292
  background: white;
293
- box-shadow: 0 4px 20px rgba(0,0,0,0.5);
294
  position: relative;
295
  overflow: hidden;
296
- border: 4px solid #000;
297
  }
298
 
299
  /* === GRID CSS === */
@@ -304,8 +319,8 @@ INDEX_HTML = '''
304
 
305
  /* Grid Variables */
306
  --y: 50%;
307
- --t1: 100%; --t2: 100%; /* Hidden by default */
308
- --b1: 100%; --b2: 100%; /* Hidden by default */
309
  --gap: 3px;
310
  }
311
 
@@ -332,13 +347,13 @@ INDEX_HTML = '''
332
 
333
  /* === HANDLES === */
334
  .handle {
335
- position: absolute; width: 24px; height: 24px;
336
- border: 2px solid white; border-radius: 50%;
337
  transform: translate(-50%, -50%);
338
  z-index: 101; cursor: ew-resize;
339
- box-shadow: 0 2px 4px rgba(0,0,0,0.8);
340
  }
341
- .handle:hover { transform: scale(1.3); }
342
 
343
  .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; }
344
  .h-t2 { background: #3498db; left: var(--t2); top: 50%; margin-top: -15px; }
@@ -348,34 +363,42 @@ INDEX_HTML = '''
348
  /* SPEECH BUBBLES */
349
  .speech-bubble {
350
  position: absolute; display: flex; justify-content: center; align-items: center;
351
- width: 150px; height: 80px; min-width: 50px; min-height: 30px; box-sizing: border-box;
352
  z-index: 10; cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold;
353
  font-size: 16px; text-align: center;
354
  overflow: visible; line-height: 1.2; --tail-pos: 50%;
355
  }
356
- .bubble-text { padding: 0.5em; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; white-space: pre-wrap; pointer-events: none; }
357
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
358
 
359
  .speech-bubble.speech {
360
- background: #fff; color: #000; border: 2px solid #000;
361
  border-radius: 50%;
362
  }
363
  .speech-bubble.speech::after {
364
- content: ''; position: absolute; bottom: -10px; left: var(--tail-pos);
365
- border: 10px solid transparent; border-top-color: #000; border-bottom: 0; margin-left: -10px;
366
  }
367
-
 
 
368
  /* CONTROLS */
369
- .edit-controls { position: fixed; bottom: 20px; right: 20px; width: 260px; background: rgba(44, 62, 80, 0.95); color: white; padding: 15px; border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 900; font-size: 13px; max-height: 90vh; overflow-y: auto; }
370
  .edit-controls h4 { margin: 0 0 10px 0; color: #4ECDC4; text-align: center; }
371
  .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
372
- button, input, select { width: 100%; margin-top: 5px; padding: 6px; border-radius: 4px; border: 1px solid #ddd; cursor: pointer; font-weight: bold; font-size: 12px; }
 
373
  .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
 
374
  .action-btn { background: #4CAF50; color: white; }
375
  .reset-btn { background: #e74c3c; color: white; }
376
  .secondary-btn { background: #f39c12; color: white; }
 
377
 
378
  .tip { text-align:center; padding:10px; background:#e74c3c; color:white; font-weight:bold; margin-bottom:20px; border-radius:5px; }
 
 
 
379
  </style>
380
  </head> <body>
381
 
@@ -392,7 +415,13 @@ INDEX_HTML = '''
392
  </div>
393
 
394
  <button class="submit-btn" onclick="upload()">🚀 Generate</button>
 
395
 
 
 
 
 
 
396
  <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
397
  <div class="loader" style="margin:0 auto;"></div>
398
  <p id="status-text" style="margin-top:10px;">Starting...</p>
@@ -401,18 +430,27 @@ INDEX_HTML = '''
401
  </div>
402
 
403
  <div id="editor-container">
404
- <div class="tip">👉 Drag Blue/Green dots from the RIGHT edge to reveal hidden panels!</div>
405
  <div class="comic-wrapper" id="comic-container"></div>
406
 
407
  <div class="edit-controls">
408
  <h4>✏️ Editor</h4>
409
 
 
 
 
 
 
410
  <div class="control-group">
411
  <label>💬 Bubbles:</label>
412
  <div class="button-grid">
413
  <button onclick="addBubble()" class="action-btn">Add Text</button>
414
  <button onclick="deleteBubble()" class="reset-btn">Delete</button>
415
  </div>
 
 
 
 
416
  </div>
417
 
418
  <div class="control-group">
@@ -431,38 +469,110 @@ INDEX_HTML = '''
431
 
432
  <div class="control-group">
433
  <button onclick="exportComic()" class="action-btn" style="background:#3498db;">📥 Export PNG</button>
434
- <button onclick="location.reload()" class="reset-btn" style="margin-top:10px;">🏠 Start Over</button>
435
  </div>
436
  </div>
437
  </div>
438
 
 
 
 
 
 
 
 
 
439
  <script>
440
  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);}); }
441
  let sid = localStorage.getItem('comic_sid') || genUUID();
442
  localStorage.setItem('comic_sid', sid);
443
  let interval, selectedBubble = null, selectedPanel = null;
444
  let dragType = null, activeObj = null, dragStart = {x:0, y:0};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
 
 
 
 
 
 
 
 
 
 
446
  async function upload() {
447
  const f = document.getElementById('file-upload').files[0];
448
  const pCount = document.getElementById('page-count').value;
449
  if(!f) return alert("Select video");
450
-
451
  sid = genUUID(); localStorage.setItem('comic_sid', sid);
452
  document.querySelector('.upload-box').style.display='none';
453
  document.getElementById('loading-view').style.display='flex';
454
-
455
- const fd = new FormData();
456
- fd.append('file', f);
457
- fd.append('target_pages', pCount);
458
-
459
  const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
460
  if(r.ok) interval = setInterval(checkStatus, 1500);
461
- else {
462
- const d = await r.json();
463
- alert("Upload failed: " + (d.message || "Server Error"));
464
- location.reload();
465
- }
466
  }
467
 
468
  async function checkStatus() {
@@ -473,16 +583,15 @@ INDEX_HTML = '''
473
  } catch(e) {}
474
  }
475
 
476
- function loadNewComic() {
477
- fetch(`/output/pages.json?sid=${sid}`).then(r=>r.json()).then(data => {
478
- const cleanData = data.map(p => ({
479
- panels: p.panels.map(pan => ({ src: `/frames/${pan.image}?sid=${sid}` })),
480
- bubbles: p.bubbles.map(b => ({
481
- text: b.dialog, left: (b.bubble_offset_x||50)+'px', top: (b.bubble_offset_y||20)+'px'
482
- }))
483
- }));
484
- renderFromState(cleanData);
485
- });
486
  }
487
 
488
  function renderFromState(pagesData) {
@@ -493,45 +602,18 @@ INDEX_HTML = '''
493
  const div = document.createElement('div'); div.className = 'comic-page';
494
  const grid = document.createElement('div'); grid.className = 'comic-grid';
495
 
496
- // Panels
497
  page.panels.forEach(pan => {
498
  const pDiv = document.createElement('div'); pDiv.className = 'panel';
499
  const img = document.createElement('img');
500
  img.src = pan.src.includes('?') ? pan.src : pan.src + `?sid=${sid}`;
501
- // Init transform data
502
  img.dataset.zoom = 100; img.dataset.translateX = 0; img.dataset.translateY = 0;
503
-
504
- // MOUSE EVENTS
505
- img.onmousedown = (e) => {
506
- e.preventDefault(); e.stopPropagation();
507
- selectPanel(pDiv);
508
- dragType = 'pan'; activeObj = img;
509
- dragStart = {x:e.clientX, y:e.clientY};
510
- img.classList.add('panning');
511
- };
512
-
513
- // 🚀 ZOOM WITH WHEEL
514
- img.onwheel = (e) => {
515
- e.preventDefault();
516
- let zoom = parseFloat(img.dataset.zoom);
517
- zoom += e.deltaY * -0.1;
518
- zoom = Math.min(Math.max(100, zoom), 300);
519
- img.dataset.zoom = zoom;
520
- updateImageTransform(img);
521
- if(selectedPanel === pDiv) document.getElementById('zoom-slider').value = zoom;
522
- };
523
-
524
- pDiv.appendChild(img);
525
- grid.appendChild(pDiv);
526
  });
527
 
528
- // Handles
529
- grid.append(createHandle('h-t1', grid, 't1')); grid.append(createHandle('h-t2', grid, 't2'));
530
- grid.append(createHandle('h-b1', grid, 'b1')); grid.append(createHandle('h-b2', grid, 'b2'));
531
-
532
- // Bubbles (Appended to Grid)
533
  (page.bubbles || []).forEach(bData => { grid.appendChild(createBubbleHTML(bData)); });
534
-
535
  div.appendChild(grid); pageWrapper.appendChild(div); con.appendChild(pageWrapper);
536
  });
537
  }
@@ -546,12 +628,16 @@ INDEX_HTML = '''
546
  const b = document.createElement('div');
547
  b.className = `speech-bubble speech`;
548
  b.style.left = data.left; b.style.top = data.top;
 
 
 
549
 
550
  const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || 'Text'; b.appendChild(textSpan);
 
 
 
551
 
552
- b.onmousedown = (e) => {
553
- e.stopPropagation(); selectBubble(b); dragType = 'bubble'; activeObj = b; dragStart = {x: e.clientX, y: e.clientY};
554
- };
555
  b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
556
  return b;
557
  }
@@ -559,37 +645,35 @@ INDEX_HTML = '''
559
  function editBubbleText(bubble) {
560
  const textSpan = bubble.querySelector('.bubble-text');
561
  const newText = prompt("Edit Text:", textSpan.textContent);
562
- if(newText !== null) textSpan.textContent = newText;
563
  }
564
 
565
- // GLOBAL INTERACTION
566
  document.addEventListener('mousemove', (e) => {
567
  if(!dragType) return;
568
-
569
  if(dragType === 'handle') {
570
  const rect = activeObj.grid.getBoundingClientRect();
571
  let x = (e.clientX - rect.left) / rect.width * 100;
572
  activeObj.grid.style.setProperty(`--${activeObj.var}`, Math.max(0, Math.min(100, x))+'%');
573
- }
574
- else if(dragType === 'pan') {
575
  const dx = e.clientX - dragStart.x; const dy = e.clientY - dragStart.y;
576
  const img = activeObj;
577
- let cx = parseFloat(img.dataset.translateX);
578
- let cy = parseFloat(img.dataset.translateY);
579
- img.dataset.translateX = cx + dx;
580
- img.dataset.translateY = cy + dy;
581
- updateImageTransform(img);
582
- dragStart = {x: e.clientX, y: e.clientY};
583
- }
584
- else if(dragType === 'bubble') {
585
  const rect = activeObj.parentElement.getBoundingClientRect();
586
  activeObj.style.left = (e.clientX - rect.left - (activeObj.offsetWidth/2)) + 'px';
587
  activeObj.style.top = (e.clientY - rect.top - (activeObj.offsetHeight/2)) + 'px';
 
 
 
 
588
  }
589
  });
590
 
591
  document.addEventListener('mouseup', () => {
592
  if(activeObj && activeObj.classList) activeObj.classList.remove('panning');
 
593
  dragType = null; activeObj = null;
594
  });
595
 
@@ -603,29 +687,14 @@ INDEX_HTML = '''
603
 
604
  function addBubble() {
605
  const grid = document.querySelector('.comic-grid');
606
- if(grid) { const b = createBubbleHTML({ text: "Text", left: "50%", top: "50%" }); grid.appendChild(b); selectBubble(b); }
607
- }
608
- function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); selectedBubble=null; } }
609
-
610
- function handleZoom(val) {
611
- if(selectedPanel) {
612
- const img = selectedPanel.querySelector('img');
613
- img.dataset.zoom = val;
614
- updateImageTransform(img);
615
- }
616
- }
617
- function updateImageTransform(img) {
618
- const z = (img.dataset.zoom||100)/100, x = img.dataset.translateX||0, y = img.dataset.translateY||0;
619
- img.style.transform = `translateX(${x}px) translateY(${y}px) scale(${z})`;
620
- }
621
- function resetPanelTransform() {
622
- if(selectedPanel) {
623
- const img = selectedPanel.querySelector('img');
624
- img.dataset.zoom=100; img.dataset.translateX=0; img.dataset.translateY=0;
625
- updateImageTransform(img);
626
- document.getElementById('zoom-slider').value = 100;
627
- }
628
  }
 
 
 
 
 
 
629
 
630
  async function adjustFrame(dir) {
631
  if(!selectedPanel) return alert("Click a panel first");
@@ -634,7 +703,7 @@ INDEX_HTML = '''
634
  const r = await fetch(`/regenerate_frame?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, direction:dir}) });
635
  const d = await r.json();
636
  if(d.success) img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
637
- img.style.opacity='1';
638
  }
639
 
640
  async function exportComic() {
@@ -644,6 +713,42 @@ INDEX_HTML = '''
644
  const a = document.createElement('a'); a.href=u; a.download=`Page-${i+1}.png`; a.click();
645
  }
646
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
647
  </script>
648
  </body> </html> '''
649
 
@@ -656,15 +761,11 @@ def upload():
656
  sid = request.args.get('sid') or request.form.get('sid')
657
  if not sid: return jsonify({'success': False, 'message': 'Missing session ID'}), 400
658
 
659
- file = None
660
- if 'file' in request.files:
661
- file = request.files['file']
662
-
663
  if not file or file.filename == '':
664
  return jsonify({'success': False, 'message': 'No file uploaded'}), 400
665
 
666
  target_pages = request.form.get('target_pages', 4)
667
-
668
  gen = EnhancedComicGenerator(sid)
669
  gen.cleanup()
670
  file.save(gen.video_path)
@@ -697,6 +798,46 @@ def regen():
697
  gen = EnhancedComicGenerator(sid)
698
  return jsonify(regen_frame_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], d['direction']))
699
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
700
  if __name__ == '__main__':
701
  try: gpu_warmup()
702
  except: pass
 
24
  return True
25
 
26
  # ======================================================
27
+ # 💾 STORAGE SETUP
28
  # ======================================================
29
  if os.path.exists('/data'):
30
  BASE_STORAGE_PATH = '/data'
 
31
  else:
32
  BASE_STORAGE_PATH = '.'
 
33
 
34
  BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
35
  SAVED_COMICS_DIR = os.path.join(BASE_STORAGE_PATH, "saved_comics")
 
41
  # 🔧 APP CONFIG
42
  # ======================================================
43
  app = Flask(__name__)
44
+ app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500MB Limit
45
 
46
  def generate_save_code(length=8):
47
  chars = string.ascii_uppercase + string.digits
 
53
  # ======================================================
54
  # 🧱 DATA CLASSES
55
  # ======================================================
56
+ def bubble(dialog="", x=50, y=50, type='speech'):
57
  return {
58
  'dialog': dialog,
59
+ 'bubble_offset_x': int(x),
60
+ 'bubble_offset_y': int(y),
61
  'type': type,
62
  'tail_pos': '50%',
63
  'classes': f'speech-bubble {type} tail-bottom'
 
72
  self.bubbles = bubbles
73
 
74
  # ======================================================
75
+ # 🧠 GPU GENERATION (FAST & HD)
76
  # ======================================================
77
  @spaces.GPU(duration=120)
78
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
79
+ print(f"🚀 Generating HD Comic: {video_path}")
 
80
  import cv2
81
  import srt
82
  import numpy as np
 
100
  valid_subs = [s for s in all_subs if s.content.strip()]
101
  raw_moments = [{'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
102
 
103
+ # Calculate Frames (4 per page)
104
  panels_per_page = 4
105
+ total_panels_needed = int(target_pages) * panels_per_page
106
 
107
  selected_moments = []
108
  if not raw_moments:
 
127
 
128
  if ret:
129
  # ----------------------------------------------------
130
+ # 🎯 KEEP HD RESOLUTION (1280x720)
131
+ # We do NOT crop to square here.
132
+ # The CSS 'object-fit: cover' + Zoom handles the square layout.
133
  # ----------------------------------------------------
134
+ frame = cv2.resize(frame, (1280, 720))
 
 
 
 
135
 
136
  fname = f"frame_{count:04d}.png"
137
  p = os.path.join(frames_dir, fname)
 
144
  cap.release()
145
  with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
146
 
147
+ # Bubbles (Default Center)
148
  bubbles_list = []
149
  for f in frame_files_ordered:
150
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
151
  b_type = 'speech'
152
  if '(' in dialogue: b_type = 'narration'
153
  elif '!' in dialogue: b_type = 'reaction'
154
+ bubbles_list.append(bubble(dialog=dialogue, x=50, y=50, type=b_type))
155
 
156
+ # Pages (4 Panels)
157
  pages = []
158
+ for i in range(int(target_pages)):
159
  start_idx = i * 4
160
  end_idx = start_idx + 4
161
  p_frames = frame_files_ordered[start_idx:end_idx]
162
  p_bubbles = bubbles_list[start_idx:end_idx]
163
 
164
+ # Fill empty
165
  while len(p_frames) < 4:
166
  fname = f"empty_{i}_{len(p_frames)}.png"
167
+ img = np.zeros((720, 1280, 3), dtype=np.uint8); img[:] = (30,30,30)
168
  cv2.imwrite(os.path.join(frames_dir, fname), img)
169
  p_frames.append(fname)
170
  p_bubbles.append(bubble(dialog="", type='speech'))
 
201
  cap.release()
202
 
203
  if ret:
204
+ frame = cv2.resize(frame, (1280, 720)) # HD
 
 
 
 
 
205
  p = os.path.join(frames_dir, fname)
206
  cv2.imwrite(p, frame)
207
 
 
211
  return {"success": True, "message": f"Time: {new_t:.2f}s"}
212
  return {"success": False, "message": "End of video"}
213
 
214
+ @spaces.GPU
215
+ def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
216
+ import cv2
217
+ import json
218
+
219
+ cap = cv2.VideoCapture(video_path)
220
+ cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
221
+ ret, frame = cap.read()
222
+ cap.release()
223
+
224
+ if ret:
225
+ frame = cv2.resize(frame, (1280, 720)) # HD
226
+ p = os.path.join(frames_dir, fname)
227
+ cv2.imwrite(p, frame)
228
+
229
+ if os.path.exists(metadata_path):
230
+ with open(metadata_path, 'r') as f: meta = json.load(f)
231
+ if fname in meta:
232
+ if isinstance(meta[fname], dict): meta[fname]['time'] = float(ts)
233
+ else: meta[fname] = float(ts)
234
+ with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
235
+ return {"success": True, "message": f"Jumped to {ts}s"}
236
+ return {"success": False, "message": "Invalid timestamp"}
237
+
238
  # ======================================================
239
  # 💻 BACKEND CLASS
240
  # ======================================================
 
257
 
258
  def run(self, target_pages):
259
  try:
260
+ self.write_status("Generating...", 5)
261
  data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, int(target_pages))
262
  with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
263
  json.dump(data, f, indent=2)
 
271
  json.dump({'message': msg, 'progress': prog}, f)
272
 
273
  # ======================================================
274
+ # 🌐 FRONTEND
275
  # ======================================================
276
  INDEX_HTML = '''
277
+ <!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&display=swap" rel="stylesheet"> <style> * { box-sizing: border-box; } body { background-color: #222; font-family: 'Comic Neue', sans-serif; color: #eee; margin: 0; min-height: 100vh; }
278
 
279
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
280
  .upload-box { max-width: 500px; width: 100%; padding: 40px; background: #333; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.5); text-align: center; }
281
 
282
+ #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 150px; }
283
 
284
  h1 { color: #fff; margin-bottom: 20px; font-weight: 600; }
285
  .file-input { display: none; }
 
297
  @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
298
 
299
  /* === SQUARE COMIC LAYOUT (800x800) === */
300
+ .comic-wrapper { max-width: 1000px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; gap: 40px; }
301
+ .page-wrapper { display: flex; flex-direction: column; align-items: center; }
302
+ .page-title { text-align: center; color: #eee; margin-bottom: 10px; font-size: 20px; font-weight: bold; }
303
 
304
  .comic-page {
305
  width: 800px;
306
  height: 800px;
307
  background: white;
308
+ box-shadow: 0 4px 30px rgba(0,0,0,0.5);
309
  position: relative;
310
  overflow: hidden;
311
+ border: 6px solid #000;
312
  }
313
 
314
  /* === GRID CSS === */
 
319
 
320
  /* Grid Variables */
321
  --y: 50%;
322
+ --t1: 100%; --t2: 100%; /* Hidden Right by default */
323
+ --b1: 100%; --b2: 100%; /* Hidden Right by default */
324
  --gap: 3px;
325
  }
326
 
 
347
 
348
  /* === HANDLES === */
349
  .handle {
350
+ position: absolute; width: 26px; height: 26px;
351
+ border: 3px solid white; border-radius: 50%;
352
  transform: translate(-50%, -50%);
353
  z-index: 101; cursor: ew-resize;
354
+ box-shadow: 0 2px 5px rgba(0,0,0,0.8);
355
  }
356
+ .handle:hover { transform: translate(-50%, -50%) scale(1.3); }
357
 
358
  .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; }
359
  .h-t2 { background: #3498db; left: var(--t2); top: 50%; margin-top: -15px; }
 
363
  /* SPEECH BUBBLES */
364
  .speech-bubble {
365
  position: absolute; display: flex; justify-content: center; align-items: center;
366
+ width: 180px; height: 100px; min-width: 50px; min-height: 30px; box-sizing: border-box;
367
  z-index: 10; cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold;
368
  font-size: 16px; text-align: center;
369
  overflow: visible; line-height: 1.2; --tail-pos: 50%;
370
  }
371
+ .bubble-text { padding: 0.8em; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; white-space: pre-wrap; pointer-events: none; border-radius: inherit; }
372
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
373
 
374
  .speech-bubble.speech {
375
+ background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 3px solid #000;
376
  border-radius: 50%;
377
  }
378
  .speech-bubble.speech::after {
379
+ content: ''; position: absolute; bottom: -12px; left: var(--tail-pos);
380
+ border: 12px solid transparent; border-top-color: #000; border-bottom: 0; margin-left: -12px;
381
  }
382
+ .resize-handle { position: absolute; bottom:-5px; right:-5px; width:15px; height:15px; background:#3498db; border:1px solid white; cursor:se-resize; display:none; }
383
+ .speech-bubble.selected .resize-handle { display:block; }
384
+
385
  /* CONTROLS */
386
+ .edit-controls { position: fixed; bottom: 20px; right: 20px; width: 280px; background: rgba(44, 62, 80, 0.95); color: white; padding: 15px; border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 900; font-size: 13px; max-height: 90vh; overflow-y: auto; }
387
  .edit-controls h4 { margin: 0 0 10px 0; color: #4ECDC4; text-align: center; }
388
  .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
389
+ .control-group label { font-weight: bold; display: block; margin-bottom: 5px; }
390
+ button, input, select { width: 100%; margin-top: 5px; padding: 8px; border-radius: 4px; border: 1px solid #ddd; cursor: pointer; font-weight: bold; font-size: 13px; }
391
  .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
392
+ .color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
393
  .action-btn { background: #4CAF50; color: white; }
394
  .reset-btn { background: #e74c3c; color: white; }
395
  .secondary-btn { background: #f39c12; color: white; }
396
+ .save-btn { background: #9b59b6; color: white; }
397
 
398
  .tip { text-align:center; padding:10px; background:#e74c3c; color:white; font-weight:bold; margin-bottom:20px; border-radius:5px; }
399
+ .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; }
400
+ .modal-content { background: white; padding: 30px; border-radius: 12px; width: 90%; max-width: 400px; text-align: center; color: #333; }
401
+ .code { font-size: 24px; font-weight: bold; letter-spacing: 3px; background: #eee; padding: 10px; margin: 15px 0; display: inline-block; font-family: monospace; }
402
  </style>
403
  </head> <body>
404
 
 
415
  </div>
416
 
417
  <button class="submit-btn" onclick="upload()">🚀 Generate</button>
418
+ <button id="restore-draft-btn" class="reset-btn" style="display:none; margin-top:10px; background:#27ae60;" onclick="restoreDraft()">📂 Restore Draft</button>
419
 
420
+ <div style="margin-top:20px; border-top:1px solid #555; padding-top:10px;">
421
+ <input type="text" id="load-code" placeholder="ENTER SAVE CODE" style="width:70%; display:inline-block;">
422
+ <button onclick="loadComic()" style="width:25%; display:inline-block; background:#9b59b6; color:white;">Load</button>
423
+ </div>
424
+
425
  <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
426
  <div class="loader" style="margin:0 auto;"></div>
427
  <p id="status-text" style="margin-top:10px;">Starting...</p>
 
430
  </div>
431
 
432
  <div id="editor-container">
433
+ <div class="tip">👉 Drag Blue/Green dots from the RIGHT edge to reveal hidden square panels!</div>
434
  <div class="comic-wrapper" id="comic-container"></div>
435
 
436
  <div class="edit-controls">
437
  <h4>✏️ Editor</h4>
438
 
439
+ <div class="control-group">
440
+ <button onclick="undo()" style="background:#7f8c8d; color:white;">↩️ Undo</button>
441
+ <button onclick="saveComic()" class="save-btn">💾 Save Comic</button>
442
+ </div>
443
+
444
  <div class="control-group">
445
  <label>💬 Bubbles:</label>
446
  <div class="button-grid">
447
  <button onclick="addBubble()" class="action-btn">Add Text</button>
448
  <button onclick="deleteBubble()" class="reset-btn">Delete</button>
449
  </div>
450
+ <div class="color-grid">
451
+ <input type="color" id="bub-fill" value="#ffffff" onchange="updateBubbleColor()">
452
+ <input type="color" id="bub-text" value="#000000" onchange="updateBubbleColor()">
453
+ </div>
454
  </div>
455
 
456
  <div class="control-group">
 
469
 
470
  <div class="control-group">
471
  <button onclick="exportComic()" class="action-btn" style="background:#3498db;">📥 Export PNG</button>
472
+ <button onclick="location.reload()" class="reset-btn" style="margin-top:10px;">🏠 Home</button>
473
  </div>
474
  </div>
475
  </div>
476
 
477
+ <div class="modal-overlay" id="save-modal">
478
+ <div class="modal-content">
479
+ <h2>✅ Comic Saved!</h2>
480
+ <div class="code" id="modal-code">XXXX</div>
481
+ <button onclick="closeModal()">Close</button>
482
+ </div>
483
+ </div>
484
+
485
  <script>
486
  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);}); }
487
  let sid = localStorage.getItem('comic_sid') || genUUID();
488
  localStorage.setItem('comic_sid', sid);
489
  let interval, selectedBubble = null, selectedPanel = null;
490
  let dragType = null, activeObj = null, dragStart = {x:0, y:0};
491
+ let historyStack = [];
492
+
493
+ // HISTORY & UNDO
494
+ function saveState() {
495
+ // Lightweight state save (Bubbles + Layout vars)
496
+ const state = [];
497
+ document.querySelectorAll('.comic-page').forEach(pg => {
498
+ const grid = pg.querySelector('.comic-grid');
499
+ const layout = { t1: grid.style.getPropertyValue('--t1'), t2: grid.style.getPropertyValue('--t2'), b1: grid.style.getPropertyValue('--b1'), b2: grid.style.getPropertyValue('--b2') };
500
+ const bubbles = [];
501
+ grid.querySelectorAll('.speech-bubble').forEach(b => {
502
+ bubbles.push({
503
+ text: b.querySelector('.bubble-text').textContent,
504
+ left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
505
+ colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') }
506
+ });
507
+ });
508
+ const panels = [];
509
+ grid.querySelectorAll('.panel').forEach(pan => {
510
+ const img = pan.querySelector('img');
511
+ panels.push({ zoom: img.dataset.zoom, tx: img.dataset.translateX, ty: img.dataset.translateY });
512
+ });
513
+ state.push({ layout, bubbles, panels });
514
+ });
515
+ historyStack.push(JSON.stringify(state));
516
+ if(historyStack.length > 20) historyStack.shift();
517
+ localStorage.setItem('comic_draft_'+sid, JSON.stringify(state));
518
+ }
519
+
520
+ function undo() {
521
+ if(historyStack.length > 1) {
522
+ historyStack.pop(); // Remove current
523
+ const prev = JSON.parse(historyStack[historyStack.length-1]);
524
+ restoreFromState(prev);
525
+ }
526
+ }
527
+
528
+ function restoreFromState(stateData) {
529
+ if(!stateData) return;
530
+ const pages = document.querySelectorAll('.comic-page');
531
+ stateData.forEach((pgData, i) => {
532
+ if(i >= pages.length) return;
533
+ const grid = pages[i].querySelector('.comic-grid');
534
+ if(pgData.layout) {
535
+ grid.style.setProperty('--t1', pgData.layout.t1); grid.style.setProperty('--t2', pgData.layout.t2);
536
+ grid.style.setProperty('--b1', pgData.layout.b1); grid.style.setProperty('--b2', pgData.layout.b2);
537
+ }
538
+ // Restore bubbles
539
+ grid.querySelectorAll('.speech-bubble').forEach(b=>b.remove());
540
+ pgData.bubbles.forEach(bData => {
541
+ const b = createBubbleHTML(bData);
542
+ grid.appendChild(b);
543
+ });
544
+ // Restore Panels
545
+ const panels = grid.querySelectorAll('.panel');
546
+ pgData.panels.forEach((pData, pi) => {
547
+ if(pi < panels.length) {
548
+ const img = panels[pi].querySelector('img');
549
+ img.dataset.zoom = pData.zoom; img.dataset.translateX = pData.tx; img.dataset.translateY = pData.ty;
550
+ updateImageTransform(img);
551
+ }
552
+ });
553
+ });
554
+ }
555
 
556
+ if(localStorage.getItem('comic_draft_'+sid)) document.getElementById('restore-draft-btn').style.display='inline-block';
557
+ function restoreDraft() {
558
+ document.getElementById('upload-container').style.display='none';
559
+ document.getElementById('editor-container').style.display='block';
560
+ loadNewComic().then(() => {
561
+ setTimeout(() => restoreFromState(JSON.parse(localStorage.getItem('comic_draft_'+sid))), 500);
562
+ });
563
+ }
564
+
565
  async function upload() {
566
  const f = document.getElementById('file-upload').files[0];
567
  const pCount = document.getElementById('page-count').value;
568
  if(!f) return alert("Select video");
 
569
  sid = genUUID(); localStorage.setItem('comic_sid', sid);
570
  document.querySelector('.upload-box').style.display='none';
571
  document.getElementById('loading-view').style.display='flex';
572
+ const fd = new FormData(); fd.append('file', f); fd.append('target_pages', pCount); fd.append('sid', sid);
 
 
 
 
573
  const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
574
  if(r.ok) interval = setInterval(checkStatus, 1500);
575
+ else { const d = await r.json(); alert("Upload failed: " + d.message); location.reload(); }
 
 
 
 
576
  }
577
 
578
  async function checkStatus() {
 
583
  } catch(e) {}
584
  }
585
 
586
+ async function loadNewComic() {
587
+ const r = await fetch(`/output/pages.json?sid=${sid}`);
588
+ const data = await r.json();
589
+ const cleanData = data.map(p => ({
590
+ panels: p.panels.map(pan => ({ src: `/frames/${pan.image}?sid=${sid}` })),
591
+ bubbles: p.bubbles.map(b => ({ text: b.dialog, left: (b.bubble_offset_x||50)+'px', top: (b.bubble_offset_y||20)+'px' }))
592
+ }));
593
+ renderFromState(cleanData);
594
+ saveState();
 
595
  }
596
 
597
  function renderFromState(pagesData) {
 
602
  const div = document.createElement('div'); div.className = 'comic-page';
603
  const grid = document.createElement('div'); grid.className = 'comic-grid';
604
 
 
605
  page.panels.forEach(pan => {
606
  const pDiv = document.createElement('div'); pDiv.className = 'panel';
607
  const img = document.createElement('img');
608
  img.src = pan.src.includes('?') ? pan.src : pan.src + `?sid=${sid}`;
 
609
  img.dataset.zoom = 100; img.dataset.translateX = 0; img.dataset.translateY = 0;
610
+ img.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); selectPanel(pDiv); dragType = 'pan'; activeObj = img; dragStart = {x:e.clientX, y:e.clientY}; img.classList.add('panning'); };
611
+ img.onwheel = (e) => { e.preventDefault(); let zoom = parseFloat(img.dataset.zoom); zoom += e.deltaY * -0.1; zoom = Math.min(Math.max(100, zoom), 300); img.dataset.zoom = zoom; updateImageTransform(img); if(selectedPanel === pDiv) document.getElementById('zoom-slider').value = zoom; saveState(); };
612
+ pDiv.appendChild(img); grid.appendChild(pDiv);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
613
  });
614
 
615
+ grid.append(createHandle('h-t1', grid, 't1'), createHandle('h-t2', grid, 't2'), createHandle('h-b1', grid, 'b1'), createHandle('h-b2', grid, 'b2'));
 
 
 
 
616
  (page.bubbles || []).forEach(bData => { grid.appendChild(createBubbleHTML(bData)); });
 
617
  div.appendChild(grid); pageWrapper.appendChild(div); con.appendChild(pageWrapper);
618
  });
619
  }
 
628
  const b = document.createElement('div');
629
  b.className = `speech-bubble speech`;
630
  b.style.left = data.left; b.style.top = data.top;
631
+ if(data.width) b.style.width = data.width;
632
+ if(data.height) b.style.height = data.height;
633
+ if(data.colors) { b.style.setProperty('--bubble-fill', data.colors.fill); b.style.setProperty('--bubble-text', data.colors.text); }
634
 
635
  const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || 'Text'; b.appendChild(textSpan);
636
+ const resizer = document.createElement('div'); resizer.className = 'resize-handle';
637
+ resizer.onmousedown = (e) => { e.stopPropagation(); dragType='resize'; activeObj={b:b, startW:b.offsetWidth, startH:b.offsetHeight, mx:e.clientX, my:e.clientY}; };
638
+ b.appendChild(resizer);
639
 
640
+ b.onmousedown = (e) => { if(e.target === resizer) return; e.stopPropagation(); selectBubble(b); dragType = 'bubble'; activeObj = b; dragStart = {x: e.clientX, y: e.clientY}; };
 
 
641
  b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
642
  return b;
643
  }
 
645
  function editBubbleText(bubble) {
646
  const textSpan = bubble.querySelector('.bubble-text');
647
  const newText = prompt("Edit Text:", textSpan.textContent);
648
+ if(newText !== null) { textSpan.textContent = newText; saveState(); }
649
  }
650
 
 
651
  document.addEventListener('mousemove', (e) => {
652
  if(!dragType) return;
 
653
  if(dragType === 'handle') {
654
  const rect = activeObj.grid.getBoundingClientRect();
655
  let x = (e.clientX - rect.left) / rect.width * 100;
656
  activeObj.grid.style.setProperty(`--${activeObj.var}`, Math.max(0, Math.min(100, x))+'%');
657
+ } else if(dragType === 'pan') {
 
658
  const dx = e.clientX - dragStart.x; const dy = e.clientY - dragStart.y;
659
  const img = activeObj;
660
+ img.dataset.translateX = parseFloat(img.dataset.translateX) + dx;
661
+ img.dataset.translateY = parseFloat(img.dataset.translateY) + dy;
662
+ updateImageTransform(img); dragStart = {x: e.clientX, y: e.clientY};
663
+ } else if(dragType === 'bubble') {
 
 
 
 
664
  const rect = activeObj.parentElement.getBoundingClientRect();
665
  activeObj.style.left = (e.clientX - rect.left - (activeObj.offsetWidth/2)) + 'px';
666
  activeObj.style.top = (e.clientY - rect.top - (activeObj.offsetHeight/2)) + 'px';
667
+ } else if(dragType === 'resize') {
668
+ const dx = e.clientX - activeObj.mx; const dy = e.clientY - activeObj.my;
669
+ activeObj.b.style.width = (activeObj.startW + dx) + 'px';
670
+ activeObj.b.style.height = (activeObj.startH + dy) + 'px';
671
  }
672
  });
673
 
674
  document.addEventListener('mouseup', () => {
675
  if(activeObj && activeObj.classList) activeObj.classList.remove('panning');
676
+ if(dragType) saveState();
677
  dragType = null; activeObj = null;
678
  });
679
 
 
687
 
688
  function addBubble() {
689
  const grid = document.querySelector('.comic-grid');
690
+ if(grid) { const b = createBubbleHTML({ text: "Text", left: "50%", top: "50%" }); grid.appendChild(b); selectBubble(b); saveState(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
691
  }
692
+ function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); selectedBubble=null; saveState(); } }
693
+ function updateBubbleColor() { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-fill', document.getElementById('bub-fill').value); selectedBubble.style.setProperty('--bubble-text', document.getElementById('bub-text').value); saveState(); } }
694
+
695
+ function handleZoom(val) { if(selectedPanel) { const img = selectedPanel.querySelector('img'); img.dataset.zoom = val; updateImageTransform(img); saveState(); } }
696
+ 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})`; }
697
+ 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(); } }
698
 
699
  async function adjustFrame(dir) {
700
  if(!selectedPanel) return alert("Click a panel first");
 
703
  const r = await fetch(`/regenerate_frame?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, direction:dir}) });
704
  const d = await r.json();
705
  if(d.success) img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
706
+ img.style.opacity='1'; saveState();
707
  }
708
 
709
  async function exportComic() {
 
713
  const a = document.createElement('a'); a.href=u; a.download=`Page-${i+1}.png`; a.click();
714
  }
715
  }
716
+
717
+ async function saveComic() {
718
+ const r = await fetch(`/save_comic?sid=${sid}`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({pages:getCurrentState()})});
719
+ const d = await r.json();
720
+ if(d.success) { document.getElementById('modal-code').innerText=d.code; document.getElementById('save-modal').style.display='flex'; }
721
+ }
722
+ async function loadComic() {
723
+ const code = document.getElementById('load-code').value;
724
+ const r = await fetch(`/load_comic/${code}`);
725
+ const d = await r.json();
726
+ 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='block'; }
727
+ else alert(d.message);
728
+ }
729
+ function closeModal() { document.getElementById('save-modal').style.display='none'; }
730
+
731
+ function getCurrentState() {
732
+ // Full state grabber for save/load
733
+ const state = [];
734
+ document.querySelectorAll('.comic-page').forEach(pg => {
735
+ const grid = pg.querySelector('.comic-grid');
736
+ 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%' };
737
+ const bubbles = [];
738
+ grid.querySelectorAll('.speech-bubble').forEach(b => {
739
+ bubbles.push({ text: b.querySelector('.bubble-text').textContent, left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height, colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') } });
740
+ });
741
+ const panels = [];
742
+ grid.querySelectorAll('.panel').forEach(pan => {
743
+ const img = pan.querySelector('img');
744
+ const srcParts = img.src.split('frames/');
745
+ const fname = srcParts.length > 1 ? srcParts[1].split('?')[0] : '';
746
+ panels.push({ image: fname, zoom: img.dataset.zoom, tx: img.dataset.translateX, ty: img.dataset.translateY });
747
+ });
748
+ state.push({ layout, bubbles, panels });
749
+ });
750
+ return state;
751
+ }
752
  </script>
753
  </body> </html> '''
754
 
 
761
  sid = request.args.get('sid') or request.form.get('sid')
762
  if not sid: return jsonify({'success': False, 'message': 'Missing session ID'}), 400
763
 
764
+ file = request.files.get('file')
 
 
 
765
  if not file or file.filename == '':
766
  return jsonify({'success': False, 'message': 'No file uploaded'}), 400
767
 
768
  target_pages = request.form.get('target_pages', 4)
 
769
  gen = EnhancedComicGenerator(sid)
770
  gen.cleanup()
771
  file.save(gen.video_path)
 
798
  gen = EnhancedComicGenerator(sid)
799
  return jsonify(regen_frame_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], d['direction']))
800
 
801
+ @app.route('/save_comic', methods=['POST'])
802
+ def save_comic():
803
+ sid = request.args.get('sid')
804
+ try:
805
+ data = request.get_json()
806
+ save_code = generate_save_code()
807
+ save_dir = os.path.join(SAVED_COMICS_DIR, save_code)
808
+ os.makedirs(save_dir, exist_ok=True)
809
+
810
+ # Copy frames
811
+ user_frames = os.path.join(BASE_USER_DIR, sid, 'frames')
812
+ saved_frames = os.path.join(save_dir, 'frames')
813
+ if os.path.exists(user_frames):
814
+ if os.path.exists(saved_frames): shutil.rmtree(saved_frames)
815
+ shutil.copytree(user_frames, saved_frames)
816
+
817
+ with open(os.path.join(save_dir, 'comic_state.json'), 'w') as f:
818
+ json.dump({'originalSid': sid, 'pages': data['pages'], 'savedAt': time.time()}, f)
819
+
820
+ return jsonify({'success': True, 'code': save_code})
821
+ except Exception as e: return jsonify({'success': False, 'message': str(e)})
822
+
823
+ @app.route('/load_comic/<code>')
824
+ def load_comic(code):
825
+ code = code.upper()
826
+ save_dir = os.path.join(SAVED_COMICS_DIR, code)
827
+ if not os.path.exists(save_dir): return jsonify({'success': False, 'message': 'Code not found'})
828
+ try:
829
+ with open(os.path.join(save_dir, 'comic_state.json'), 'r') as f: data = json.load(f)
830
+ # Restore frames
831
+ orig_sid = data['originalSid']
832
+ saved_frames = os.path.join(save_dir, 'frames')
833
+ user_frames = os.path.join(BASE_USER_DIR, orig_sid, 'frames')
834
+ os.makedirs(user_frames, exist_ok=True)
835
+ for fn in os.listdir(saved_frames):
836
+ shutil.copy2(os.path.join(saved_frames, fn), os.path.join(user_frames, fn))
837
+
838
+ return jsonify({'success': True, 'originalSid': orig_sid, 'pages': data['pages']})
839
+ except Exception as e: return jsonify({'success': False, 'message': str(e)})
840
+
841
  if __name__ == '__main__':
842
  try: gpu_warmup()
843
  except: pass