tester343 commited on
Commit
e829592
·
verified ·
1 Parent(s): 2eb576e

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +191 -207
app_enhanced.py CHANGED
@@ -1,4 +1,4 @@
1
- import spaces # <--- CRITICAL: MUST BE THE FIRST IMPORT
2
  import os
3
  import time
4
  import threading
@@ -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")
@@ -39,11 +37,8 @@ SAVED_COMICS_DIR = os.path.join(BASE_STORAGE_PATH, "saved_comics")
39
  os.makedirs(BASE_USER_DIR, exist_ok=True)
40
  os.makedirs(SAVED_COMICS_DIR, exist_ok=True)
41
 
42
- # ======================================================
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
@@ -53,39 +48,27 @@ def generate_save_code(length=8):
53
  return code
54
 
55
  # ======================================================
56
- # 🧱 DATA CLASSES
57
  # ======================================================
58
- def bubble(dialog="", x=50, y=50, type='speech'):
59
  return {
60
  'dialog': dialog,
61
  'bubble_offset_x': int(x),
62
  'bubble_offset_y': int(y),
63
  'type': type,
64
  'tail_pos': '50%',
65
- 'classes': f'speech-bubble {type} tail-bottom'
 
66
  }
67
 
68
- def panel(image=""):
69
- return {'image': image}
70
-
71
- class Page:
72
- def __init__(self, panels, bubbles):
73
- self.panels = panels
74
- self.bubbles = bubbles
75
-
76
- # ======================================================
77
- # 🧠 GPU GENERATION (SQUARE PADDING + TEXT)
78
- # ======================================================
79
  @spaces.GPU(duration=300)
80
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
81
- print(f"🚀 Generating Square Comic: {video_path}")
82
-
83
  import cv2
84
  import srt
85
  import numpy as np
86
  from backend.subtitles.subs_real import get_real_subtitles
87
 
88
- # 1. Video Setup
89
  cap = cv2.VideoCapture(video_path)
90
  if not cap.isOpened(): raise Exception("Cannot open video")
91
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
@@ -93,30 +76,26 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
93
  duration = total_frames / fps
94
  cap.release()
95
 
96
- # 2. SUBTITLES
97
  user_srt = os.path.join(user_dir, 'subs.srt')
98
  try:
99
- print("🎙️ Extracting subtitles...")
100
  get_real_subtitles(video_path)
101
- if os.path.exists('test1.srt'):
102
- shutil.move('test1.srt', user_srt)
103
- elif not os.path.exists(user_srt):
104
- with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
105
- except Exception as e:
106
- print(f"⚠️ Subtitle error: {e}")
107
  with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
108
 
109
  with open(user_srt, 'r', encoding='utf-8') as f:
110
  try: all_subs = list(srt.parse(f.read()))
111
  except: all_subs = []
112
 
113
- valid_subs = [s for s in all_subs if s.content and s.content.strip()]
114
  if valid_subs:
115
  raw_moments = [{'text': s.content.strip(), 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
116
  else:
117
  raw_moments = []
118
 
119
- # 3. Frame Selection (4 per page)
120
  panels_per_page = 4
121
  total_panels_needed = int(target_pages) * panels_per_page
122
 
@@ -130,7 +109,6 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
130
  indices = np.linspace(0, len(raw_moments) - 1, total_panels_needed, dtype=int)
131
  selected_moments = [raw_moments[i] for i in indices]
132
 
133
- # 4. Extract & PAD TO SQUARE
134
  frame_metadata = {}
135
  cap = cv2.VideoCapture(video_path)
136
  count = 0
@@ -138,53 +116,30 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
138
 
139
  for i, moment in enumerate(selected_moments):
140
  mid = (moment['start'] + moment['end']) / 2
141
- if mid > duration: mid = duration - 0.5
142
  cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
143
  ret, frame = cap.read()
144
-
145
  if ret:
146
- # ----------------------------------------------------
147
- # 🎯 SQUARE PADDING LOGIC (0% Cut)
148
- # ----------------------------------------------------
149
- h, w = frame.shape[:2]
150
- # Determine square size based on max dimension
151
- sq_dim = max(h, w)
152
-
153
- # Create black square canvas
154
- square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
155
-
156
- # Calculate centering offsets
157
- x_off = (sq_dim - w) // 2
158
- y_off = (sq_dim - h) // 2
159
-
160
- # Place original frame in center
161
- square_img[y_off:y_off+h, x_off:x_off+w] = frame
162
-
163
- # Optionally resize to standard high res (e.g. 1024x1024) to normalize
164
- square_img = cv2.resize(square_img, (1024, 1024))
165
-
166
  fname = f"frame_{count:04d}.png"
167
  p = os.path.join(frames_dir, fname)
168
- cv2.imwrite(p, square_img)
169
-
170
  frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
171
  frame_files_ordered.append(fname)
172
  count += 1
173
-
174
  cap.release()
 
175
  with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
176
 
177
- # 5. Bubbles
178
  bubbles_list = []
179
  for f in frame_files_ordered:
180
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
181
  b_type = 'speech'
182
  if '(' in dialogue: b_type = 'narration'
183
  elif '!' in dialogue: b_type = 'reaction'
184
- # Position bubbles somewhat centrally to avoid black bars
185
- bubbles_list.append(bubble(dialog=dialogue, x=50, y=20, type=b_type))
186
 
187
- # 6. Pages
188
  pages = []
189
  for i in range(int(target_pages)):
190
  start_idx = i * 4
@@ -194,22 +149,17 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
194
 
195
  while len(p_frames) < 4:
196
  fname = f"empty_{i}_{len(p_frames)}.png"
197
- img = np.zeros((1024, 1024, 3), dtype=np.uint8); img[:] = (30,30,30)
198
  cv2.imwrite(os.path.join(frames_dir, fname), img)
199
  p_frames.append(fname)
200
  p_bubbles.append(bubble(dialog="", type='speech'))
201
 
202
  if p_frames:
203
- pg_panels = [panel(image=f) for f in p_frames]
204
- pages.append(Page(panels=pg_panels, bubbles=p_bubbles))
205
-
206
- result = []
207
- for pg in pages:
208
- p_data = [p if isinstance(p, dict) else p.__dict__ for p in pg.panels]
209
- b_data = [b if isinstance(b, dict) else b.__dict__ for b in pg.bubbles]
210
- result.append({'panels': p_data, 'bubbles': b_data})
211
-
212
- return result
213
 
214
  @spaces.GPU
215
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
@@ -217,8 +167,7 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
217
  import json
218
  if not os.path.exists(metadata_path): return {"success": False, "message": "No metadata"}
219
  with open(metadata_path, 'r') as f: meta = json.load(f)
220
- if fname not in meta: return {"success": False, "message": "Frame not found"}
221
-
222
  t = meta[fname]['time'] if isinstance(meta[fname], dict) else meta[fname]
223
  cap = cv2.VideoCapture(video_path)
224
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
@@ -230,52 +179,13 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
230
  cap.release()
231
 
232
  if ret:
233
- # Re-apply Square Padding logic
234
- h, w = frame.shape[:2]
235
- sq_dim = max(h, w)
236
- square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
237
- x_off = (sq_dim - w) // 2
238
- y_off = (sq_dim - h) // 2
239
- square_img[y_off:y_off+h, x_off:x_off+w] = frame
240
- square_img = cv2.resize(square_img, (1024, 1024))
241
-
242
- p = os.path.join(frames_dir, fname)
243
- cv2.imwrite(p, square_img)
244
-
245
  if isinstance(meta[fname], dict): meta[fname]['time'] = new_t
246
  else: meta[fname] = new_t
247
  with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
248
  return {"success": True, "message": f"Time: {new_t:.2f}s"}
249
- return {"success": False, "message": "End of video"}
250
-
251
- @spaces.GPU
252
- def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
253
- import cv2
254
- import json
255
- cap = cv2.VideoCapture(video_path)
256
- cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
257
- ret, frame = cap.read()
258
- cap.release()
259
-
260
- if ret:
261
- h, w = frame.shape[:2]
262
- sq_dim = max(h, w)
263
- square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
264
- x_off = (sq_dim - w) // 2
265
- y_off = (sq_dim - h) // 2
266
- square_img[y_off:y_off+h, x_off:x_off+w] = frame
267
- square_img = cv2.resize(square_img, (1024, 1024))
268
-
269
- p = os.path.join(frames_dir, fname)
270
- cv2.imwrite(p, square_img)
271
- if os.path.exists(metadata_path):
272
- with open(metadata_path, 'r') as f: meta = json.load(f)
273
- if fname in meta:
274
- if isinstance(meta[fname], dict): meta[fname]['time'] = float(ts)
275
- else: meta[fname] = float(ts)
276
- with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
277
- return {"success": True, "message": f"Jumped to {ts}s"}
278
- return {"success": False, "message": "Invalid timestamp"}
279
 
280
  # ======================================================
281
  # 💻 BACKEND CLASS
@@ -299,7 +209,7 @@ class EnhancedComicGenerator:
299
 
300
  def run(self, target_pages):
301
  try:
302
- self.write_status("Generating...", 5)
303
  data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, int(target_pages))
304
  with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
305
  json.dump(data, f, indent=2)
@@ -313,81 +223,60 @@ class EnhancedComicGenerator:
313
  json.dump({'message': msg, 'progress': prog}, f)
314
 
315
  # ======================================================
316
- # 🌐 ROUTES & FRONTEND
317
  # ======================================================
318
  INDEX_HTML = '''
319
- <!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; }
320
 
321
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
322
- .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; }
323
 
324
  #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 150px; }
325
 
326
- h1 { color: #fff; margin-bottom: 20px; font-weight: 600; }
327
  .file-input { display: none; }
328
- .file-label { display: block; padding: 15px; background: #e74c3c; color: white; border-radius: 8px; cursor: pointer; font-weight: bold; margin-bottom: 10px; transition:0.2s; }
329
- .file-label:hover { background: #c0392b; }
330
 
331
  .page-input-group { margin: 20px 0; text-align: left; }
332
  .page-input-group label { font-weight: bold; font-size: 14px; display: block; margin-bottom: 5px; color: #ccc; }
333
- .page-input-group input { width: 100%; padding: 12px; border: 2px solid #555; background: #222; color: white; border-radius: 8px; font-size: 16px; box-sizing: border-box; }
334
 
335
- .submit-btn { width: 100%; padding: 15px; background: #3498db; color: white; border: none; border-radius: 8px; font-size: 18px; font-weight: bold; cursor: pointer; transition: 0.2s; }
336
- .submit-btn:hover { background: #2980b9; }
 
337
 
338
- .loader { width: 120px; height: 20px; background: radial-gradient(circle 10px, #e67e22 100%, transparent 0); background-size: 20px 20px; animation: ball 1s infinite linear; margin: 20px auto; }
339
- @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
340
-
341
- /* === SQUARE COMIC LAYOUT (800x800) === */
342
  .comic-wrapper { max-width: 1000px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; gap: 40px; }
343
  .page-wrapper { display: flex; flex-direction: column; align-items: center; }
344
  .page-title { text-align: center; color: #eee; margin-bottom: 10px; font-size: 20px; font-weight: bold; }
345
 
346
  .comic-page {
347
- width: 800px;
348
- height: 800px;
349
  background: white;
350
- box-shadow: 0 4px 30px rgba(0,0,0,0.5);
351
- position: relative;
352
- overflow: hidden;
353
  border: 6px solid #000;
354
  }
355
 
356
- /* === GRID CSS === */
357
  .comic-grid {
358
- width: 100%; height: 100%;
359
- position: relative;
360
- background: #000;
361
-
362
- /* Grid Variables */
363
- --y: 50%;
364
- --t1: 100%; --t2: 100%; /* Hidden Right by default */
365
- --b1: 100%; --b2: 100%; /* Hidden Right by default */
366
- --gap: 3px;
367
  }
368
 
369
  .panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
370
-
371
- /* IMAGE: Cover ensures it fills. Zoom allows control. */
372
- .panel img {
373
- width: 100%; height: 100%;
374
- object-fit: cover;
375
- transform-origin: center;
376
- transition: transform 0.05s ease-out;
377
- display: block;
378
- }
379
  .panel img.panning { cursor: grabbing; transition: none; }
380
- .panel.selected { outline: 4px solid #2196F3; z-index: 5; }
381
 
382
- /* === CLIP PATHS === */
383
  .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; }
384
  .panel:nth-child(2) { clip-path: polygon(calc(var(--t1) + var(--gap)) 0, 100% 0, 100% calc(var(--y) - var(--gap)), calc(var(--t2) + var(--gap)) calc(var(--y) - var(--gap))); z-index: 1; }
385
  .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; }
386
  .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; }
387
 
388
- /* === HANDLES === */
389
  .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); }
390
- .handle:hover { transform: translate(-50%, -50%) scale(1.3); }
391
  .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; }
392
  .h-t2 { background: #3498db; left: var(--t2); top: 50%; margin-top: -15px; }
393
  .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; }
@@ -396,15 +285,24 @@ INDEX_HTML = '''
396
  /* SPEECH BUBBLES */
397
  .speech-bubble {
398
  position: absolute; display: flex; justify-content: center; align-items: center;
399
- width: 200px; height: 100px; min-width: 50px; min-height: 30px; box-sizing: border-box;
400
- z-index: 10; cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold;
401
- font-size: 16px; text-align: center;
402
  overflow: visible; line-height: 1.2; --tail-pos: 50%;
403
  }
404
  .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; }
405
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
406
- .speech-bubble.speech { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 3px solid #000; border-radius: 50%; }
 
 
407
  .speech-bubble.speech::after { content: ''; position: absolute; bottom: -12px; left: var(--tail-pos); border: 12px solid transparent; border-top-color: #000; border-bottom: 0; margin-left: -12px; }
 
 
 
 
 
 
 
 
408
  .resize-handle { position: absolute; bottom:-5px; right:-5px; width:15px; height:15px; background:#3498db; border:1px solid white; cursor:se-resize; display:none; }
409
  .speech-bubble.selected .resize-handle { display:block; }
410
 
@@ -416,10 +314,9 @@ INDEX_HTML = '''
416
  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; }
417
  .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
418
  .color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
419
- .action-btn { background: #4CAF50; color: white; }
420
- .reset-btn { background: #e74c3c; color: white; }
421
- .secondary-btn { background: #f39c12; color: white; }
422
- .save-btn { background: #9b59b6; color: white; }
423
 
424
  .tip { text-align:center; padding:10px; background:#e74c3c; color:white; font-weight:bold; margin-bottom:20px; border-radius:5px; }
425
  .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; }
@@ -430,7 +327,7 @@ INDEX_HTML = '''
430
 
431
  <div id="upload-container">
432
  <div class="upload-box">
433
- <h1>⚡ Square HD Comic Gen</h1>
434
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
435
  <label for="file-upload" class="file-label">📁 Choose Video</label>
436
  <span id="fn" style="margin-bottom:10px; display:block; color:#aaa;">No file selected</span>
@@ -441,21 +338,23 @@ INDEX_HTML = '''
441
  </div>
442
 
443
  <button class="submit-btn" onclick="upload()">🚀 Generate</button>
444
- <button id="restore-draft-btn" class="reset-btn" style="display:none; margin-top:10px; background:#27ae60;" onclick="restoreDraft()">📂 Restore Draft</button>
445
 
446
  <div style="margin-top:20px; border-top:1px solid #555; padding-top:10px;">
447
  <input type="text" id="load-code" placeholder="ENTER SAVE CODE" style="width:70%; display:inline-block;">
448
  <button onclick="loadComic()" style="width:25%; display:inline-block; background:#9b59b6; color:white;">Load</button>
449
  </div>
450
  <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
451
- <div class="loader" style="margin:0 auto;"></div>
452
- <p id="status-text" style="margin-top:10px;">Starting...</p>
453
  </div>
454
  </div>
455
  </div>
 
456
  <div id="editor-container">
457
- <div class="tip">👉 Drag Blue/Green dots from the RIGHT edge to reveal hidden square panels!</div>
458
  <div class="comic-wrapper" id="comic-container"></div>
 
459
 
460
  <div class="edit-controls">
461
  <h4>✏️ Editor</h4>
@@ -464,19 +363,35 @@ INDEX_HTML = '''
464
  <button onclick="undo()" style="background:#7f8c8d; color:white;">↩️ Undo</button>
465
  <button onclick="saveComic()" class="save-btn">💾 Save Comic</button>
466
  </div>
 
467
  <div class="control-group">
468
- <label>💬 Bubbles:</label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  <div class="button-grid">
470
- <button onclick="addBubble()" class="action-btn">Add Text</button>
471
  <button onclick="deleteBubble()" class="reset-btn">Delete</button>
472
  </div>
473
- <div class="color-grid">
474
- <input type="color" id="bub-fill" value="#ffffff" onchange="updateBubbleColor()">
475
- <input type="color" id="bub-text" value="#000000" onchange="updateBubbleColor()">
476
- </div>
477
  </div>
 
478
  <div class="control-group">
479
  <label>🖼️ Image Control:</label>
 
480
  <div class="button-grid">
481
  <button onclick="adjustFrame('backward')" class="secondary-btn">⬅️ Frame</button>
482
  <button onclick="adjustFrame('forward')" class="action-btn">Frame ➡️</button>
@@ -484,8 +399,7 @@ INDEX_HTML = '''
484
  </div>
485
 
486
  <div class="control-group">
487
- <label>🔍 Zoom (Mouse Wheel):</label>
488
- <!-- Min zoom 20 allowed to zoom OUT to see whole square image -->
489
  <input type="range" id="zoom-slider" min="20" max="300" value="100" step="5" oninput="handleZoom(this.value)" disabled>
490
  <button onclick="resetPanelTransform()" class="secondary-btn">Reset View</button>
491
  </div>
@@ -496,6 +410,7 @@ INDEX_HTML = '''
496
  </div>
497
  </div>
498
  </div>
 
499
  <div class="modal-overlay" id="save-modal">
500
  <div class="modal-content">
501
  <h2>✅ Comic Saved!</h2>
@@ -503,6 +418,7 @@ INDEX_HTML = '''
503
  <button onclick="closeModal()">Close</button>
504
  </div>
505
  </div>
 
506
  <script>
507
  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);}); }
508
  let sid = localStorage.getItem('comic_sid') || genUUID();
@@ -521,13 +437,17 @@ INDEX_HTML = '''
521
  bubbles.push({
522
  text: b.querySelector('.bubble-text').textContent,
523
  left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
524
- colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') }
 
 
525
  });
526
  });
527
  const panels = [];
528
  grid.querySelectorAll('.panel').forEach(pan => {
529
  const img = pan.querySelector('img');
530
- panels.push({ zoom: img.dataset.zoom, tx: img.dataset.translateX, ty: img.dataset.translateY });
 
 
531
  });
532
  state.push({ layout, bubbles, panels });
533
  });
@@ -561,6 +481,7 @@ INDEX_HTML = '''
561
  if(pi < panels.length) {
562
  const img = panels[pi].querySelector('img');
563
  img.dataset.zoom = pData.zoom; img.dataset.translateX = pData.tx; img.dataset.translateY = pData.ty;
 
564
  updateImageTransform(img);
565
  }
566
  });
@@ -575,6 +496,7 @@ INDEX_HTML = '''
575
  setTimeout(() => restoreFromState(JSON.parse(localStorage.getItem('comic_draft_'+sid))), 500);
576
  });
577
  }
 
578
  async function upload() {
579
  const f = document.getElementById('file-upload').files[0];
580
  const pCount = document.getElementById('page-count').value;
@@ -601,11 +523,15 @@ INDEX_HTML = '''
601
  const data = await r.json();
602
  const cleanData = data.map(p => ({
603
  panels: p.panels.map(pan => ({ src: `/frames/${pan.image}?sid=${sid}` })),
604
- bubbles: p.bubbles.map(b => ({ text: b.dialog, left: (b.bubble_offset_x||50)+'px', top: (b.bubble_offset_y||20)+'px' }))
 
 
 
605
  }));
606
  renderFromState(cleanData);
607
  saveState();
608
  }
 
609
  function renderFromState(pagesData) {
610
  const con = document.getElementById('comic-container'); con.innerHTML = '';
611
  pagesData.forEach((page, pageIdx) => {
@@ -621,12 +547,12 @@ INDEX_HTML = '''
621
  img.dataset.zoom = 100; img.dataset.translateX = 0; img.dataset.translateY = 0;
622
  img.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); selectPanel(pDiv); dragType = 'pan'; activeObj = img; dragStart = {x:e.clientX, y:e.clientY}; img.classList.add('panning'); };
623
 
624
- // 🚀 ZOOM WHEEL LOGIC (Min zoom 20%)
625
  img.onwheel = (e) => {
626
  e.preventDefault();
627
  let zoom = parseFloat(img.dataset.zoom);
628
  zoom += e.deltaY * -0.1;
629
- zoom = Math.min(Math.max(20, zoom), 300); // Allow zoom out to 20%
630
  img.dataset.zoom = zoom;
631
  updateImageTransform(img);
632
  if(selectedPanel === pDiv) document.getElementById('zoom-slider').value = zoom;
@@ -650,11 +576,16 @@ INDEX_HTML = '''
650
 
651
  function createBubbleHTML(data) {
652
  const b = document.createElement('div');
653
- b.className = `speech-bubble speech`;
 
 
654
  b.style.left = data.left; b.style.top = data.top;
655
  if(data.width) b.style.width = data.width;
656
  if(data.height) b.style.height = data.height;
 
657
  if(data.colors) { b.style.setProperty('--bubble-fill', data.colors.fill); b.style.setProperty('--bubble-text', data.colors.text); }
 
 
658
 
659
  const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || 'Text'; b.appendChild(textSpan);
660
  const resizer = document.createElement('div'); resizer.className = 'resize-handle';
@@ -672,6 +603,7 @@ INDEX_HTML = '''
672
  if(newText !== null) { textSpan.textContent = newText; saveState(); }
673
  }
674
 
 
675
  document.addEventListener('mousemove', (e) => {
676
  if(!dragType) return;
677
  if(dragType === 'handle') {
@@ -701,25 +633,65 @@ INDEX_HTML = '''
701
  dragType = null; activeObj = null;
702
  });
703
 
704
- function selectBubble(el) { if(selectedBubble) selectedBubble.classList.remove('selected'); selectedBubble = el; el.classList.add('selected'); }
 
 
 
 
 
 
705
  function selectPanel(el) {
706
  if(selectedPanel) selectedPanel.classList.remove('selected');
707
  selectedPanel = el; el.classList.add('selected');
708
  document.getElementById('zoom-slider').disabled = false;
709
  document.getElementById('zoom-slider').value = el.querySelector('img').dataset.zoom;
710
  }
 
 
711
 
712
- function addBubble() {
713
- const grid = document.querySelector('.comic-grid');
714
- if(grid) { const b = createBubbleHTML({ text: "Text", left: "50%", top: "50%" }); grid.appendChild(b); selectBubble(b); saveState(); }
 
 
 
 
 
 
 
 
 
 
 
715
  }
716
- function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); selectedBubble=null; saveState(); } }
717
- 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(); } }
 
 
 
 
 
 
 
 
718
 
719
  function handleZoom(val) { if(selectedPanel) { const img = selectedPanel.querySelector('img'); img.dataset.zoom = val; updateImageTransform(img); saveState(); } }
720
  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})`; }
721
  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(); } }
722
 
 
 
 
 
 
 
 
 
 
 
 
 
 
723
  async function adjustFrame(dir) {
724
  if(!selectedPanel) return alert("Click a panel first");
725
  const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0];
@@ -759,7 +731,7 @@ INDEX_HTML = '''
759
  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%' };
760
  const bubbles = [];
761
  grid.querySelectorAll('.speech-bubble').forEach(b => {
762
- 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') } });
763
  });
764
  const panels = [];
765
  grid.querySelectorAll('.panel').forEach(pan => {
@@ -785,8 +757,7 @@ def upload():
785
  if not sid: return jsonify({'success': False, 'message': 'Missing session ID'}), 400
786
 
787
  file = request.files.get('file')
788
- if not file or file.filename == '':
789
- return jsonify({'success': False, 'message': 'No file uploaded'}), 400
790
 
791
  target_pages = request.form.get('target_pages', 4)
792
  gen = EnhancedComicGenerator(sid)
@@ -821,6 +792,23 @@ def regen():
821
  gen = EnhancedComicGenerator(sid)
822
  return jsonify(regen_frame_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], d['direction']))
823
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
824
  @app.route('/save_comic', methods=['POST'])
825
  def save_comic():
826
  sid = request.args.get('sid')
@@ -829,16 +817,13 @@ def save_comic():
829
  save_code = generate_save_code()
830
  save_dir = os.path.join(SAVED_COMICS_DIR, save_code)
831
  os.makedirs(save_dir, exist_ok=True)
832
-
833
- user_frames = os.path.join(BASE_USER_DIR, sid, 'frames')
834
- saved_frames = os.path.join(save_dir, 'frames')
835
- if os.path.exists(user_frames):
836
- if os.path.exists(saved_frames): shutil.rmtree(saved_frames)
837
- shutil.copytree(user_frames, saved_frames)
838
-
839
  with open(os.path.join(save_dir, 'comic_state.json'), 'w') as f:
840
  json.dump({'originalSid': sid, 'pages': data['pages'], 'savedAt': time.time()}, f)
841
-
842
  return jsonify({'success': True, 'code': save_code})
843
  except Exception as e: return jsonify({'success': False, 'message': str(e)})
844
 
@@ -855,7 +840,6 @@ def load_comic(code):
855
  os.makedirs(user_frames, exist_ok=True)
856
  for fn in os.listdir(saved_frames):
857
  shutil.copy2(os.path.join(saved_frames, fn), os.path.join(user_frames, fn))
858
-
859
  return jsonify({'success': True, 'originalSid': orig_sid, 'pages': data['pages']})
860
  except Exception as e: return jsonify({'success': False, 'message': str(e)})
861
 
 
1
+ import spaces
2
  import os
3
  import time
4
  import threading
 
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")
 
37
  os.makedirs(BASE_USER_DIR, exist_ok=True)
38
  os.makedirs(SAVED_COMICS_DIR, exist_ok=True)
39
 
 
 
 
40
  app = Flask(__name__)
41
+ app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024
42
 
43
  def generate_save_code(length=8):
44
  chars = string.ascii_uppercase + string.digits
 
48
  return code
49
 
50
  # ======================================================
51
+ # 🧠 DATA & GPU LOGIC
52
  # ======================================================
53
+ def bubble(dialog="", x=50, y=20, type='speech'):
54
  return {
55
  'dialog': dialog,
56
  'bubble_offset_x': int(x),
57
  'bubble_offset_y': int(y),
58
  'type': type,
59
  'tail_pos': '50%',
60
+ 'colors': {'fill': '#ffffff', 'text': '#000000'},
61
+ 'font': "'Comic Neue', cursive"
62
  }
63
 
 
 
 
 
 
 
 
 
 
 
 
64
  @spaces.GPU(duration=300)
65
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
66
+ print(f"🚀 Generating HD Comic: {video_path}")
 
67
  import cv2
68
  import srt
69
  import numpy as np
70
  from backend.subtitles.subs_real import get_real_subtitles
71
 
 
72
  cap = cv2.VideoCapture(video_path)
73
  if not cap.isOpened(): raise Exception("Cannot open video")
74
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
 
76
  duration = total_frames / fps
77
  cap.release()
78
 
79
+ # Subtitles
80
  user_srt = os.path.join(user_dir, 'subs.srt')
81
  try:
 
82
  get_real_subtitles(video_path)
83
+ if os.path.exists('test1.srt'): shutil.move('test1.srt', user_srt)
84
+ 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")
85
+ except:
 
 
 
86
  with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
87
 
88
  with open(user_srt, 'r', encoding='utf-8') as f:
89
  try: all_subs = list(srt.parse(f.read()))
90
  except: all_subs = []
91
 
92
+ valid_subs = [s for s in all_subs if s.content.strip()]
93
  if valid_subs:
94
  raw_moments = [{'text': s.content.strip(), 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
95
  else:
96
  raw_moments = []
97
 
98
+ # 4 Panels Per Page
99
  panels_per_page = 4
100
  total_panels_needed = int(target_pages) * panels_per_page
101
 
 
109
  indices = np.linspace(0, len(raw_moments) - 1, total_panels_needed, dtype=int)
110
  selected_moments = [raw_moments[i] for i in indices]
111
 
 
112
  frame_metadata = {}
113
  cap = cv2.VideoCapture(video_path)
114
  count = 0
 
116
 
117
  for i, moment in enumerate(selected_moments):
118
  mid = (moment['start'] + moment['end']) / 2
 
119
  cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
120
  ret, frame = cap.read()
 
121
  if ret:
122
+ # 🎯 EXTRACT FULL HD (1280x720) - NO CROP
123
+ # Frontend handles Zoom/Fit
124
+ frame = cv2.resize(frame, (1280, 720))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  fname = f"frame_{count:04d}.png"
126
  p = os.path.join(frames_dir, fname)
127
+ cv2.imwrite(p, frame)
 
128
  frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
129
  frame_files_ordered.append(fname)
130
  count += 1
 
131
  cap.release()
132
+
133
  with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
134
 
 
135
  bubbles_list = []
136
  for f in frame_files_ordered:
137
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
138
  b_type = 'speech'
139
  if '(' in dialogue: b_type = 'narration'
140
  elif '!' in dialogue: b_type = 'reaction'
141
+ bubbles_list.append(bubble(dialog=dialogue, x=50, y=30, type=b_type))
 
142
 
 
143
  pages = []
144
  for i in range(int(target_pages)):
145
  start_idx = i * 4
 
149
 
150
  while len(p_frames) < 4:
151
  fname = f"empty_{i}_{len(p_frames)}.png"
152
+ img = np.zeros((720, 1280, 3), dtype=np.uint8); img[:] = (30,30,30)
153
  cv2.imwrite(os.path.join(frames_dir, fname), img)
154
  p_frames.append(fname)
155
  p_bubbles.append(bubble(dialog="", type='speech'))
156
 
157
  if p_frames:
158
+ # Create Page Object
159
+ pg_panels = [{'image': f} for f in p_frames]
160
+ pages.append({'panels': pg_panels, 'bubbles': p_bubbles})
161
+
162
+ return pages
 
 
 
 
 
163
 
164
  @spaces.GPU
165
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
 
167
  import json
168
  if not os.path.exists(metadata_path): return {"success": False, "message": "No metadata"}
169
  with open(metadata_path, 'r') as f: meta = json.load(f)
170
+
 
171
  t = meta[fname]['time'] if isinstance(meta[fname], dict) else meta[fname]
172
  cap = cv2.VideoCapture(video_path)
173
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
 
179
  cap.release()
180
 
181
  if ret:
182
+ frame = cv2.resize(frame, (1280, 720))
183
+ cv2.imwrite(os.path.join(frames_dir, fname), frame)
 
 
 
 
 
 
 
 
 
 
184
  if isinstance(meta[fname], dict): meta[fname]['time'] = new_t
185
  else: meta[fname] = new_t
186
  with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
187
  return {"success": True, "message": f"Time: {new_t:.2f}s"}
188
+ return {"success": False}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
  # ======================================================
191
  # 💻 BACKEND CLASS
 
209
 
210
  def run(self, target_pages):
211
  try:
212
+ self.write_status("Generating...", 10)
213
  data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, int(target_pages))
214
  with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
215
  json.dump(data, f, indent=2)
 
223
  json.dump({'message': msg, 'progress': prog}, f)
224
 
225
  # ======================================================
226
+ # 🌐 FRONTEND
227
  # ======================================================
228
  INDEX_HTML = '''
229
+ <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Ultimate Square 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; }
230
 
231
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
232
+ .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; }
233
 
234
  #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 150px; }
235
 
236
+ h1 { color: #fff; margin-bottom: 20px; }
237
  .file-input { display: none; }
238
+ .file-label { display: block; padding: 15px; background: #e67e22; color: white; border-radius: 8px; cursor: pointer; font-weight: bold; margin-bottom: 10px; transition:0.2s; }
239
+ .file-label:hover { background: #d35400; }
240
 
241
  .page-input-group { margin: 20px 0; text-align: left; }
242
  .page-input-group label { font-weight: bold; font-size: 14px; display: block; margin-bottom: 5px; color: #ccc; }
243
+ .page-input-group input { width: 100%; padding: 12px; border: 2px solid #555; background: #2c3e50; color: white; border-radius: 8px; }
244
 
245
+ .submit-btn { width: 100%; padding: 15px; background: #2980b9; color: white; border: none; border-radius: 8px; font-size: 18px; font-weight: bold; cursor: pointer; }
246
+ .loader { width: 100px; height: 10px; background: #e67e22; margin: 20px auto; animation: load 1s infinite alternate; }
247
+ @keyframes load { from { width: 20px; } to { width: 100px; } }
248
 
249
+ /* === SQUARE LAYOUT === */
 
 
 
250
  .comic-wrapper { max-width: 1000px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; gap: 40px; }
251
  .page-wrapper { display: flex; flex-direction: column; align-items: center; }
252
  .page-title { text-align: center; color: #eee; margin-bottom: 10px; font-size: 20px; font-weight: bold; }
253
 
254
  .comic-page {
255
+ width: 800px; height: 800px;
 
256
  background: white;
257
+ box-shadow: 0 5px 30px rgba(0,0,0,0.6);
258
+ position: relative; overflow: hidden;
 
259
  border: 6px solid #000;
260
  }
261
 
 
262
  .comic-grid {
263
+ width: 100%; height: 100%; position: relative; background: #000;
264
+ --y: 50%; --t1: 100%; --t2: 100%; --b1: 100%; --b2: 100%; --gap: 3px;
 
 
 
 
 
 
 
265
  }
266
 
267
  .panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
268
+ .panel img { width: 100%; height: 100%; object-fit: cover; transform-origin: center; transition: transform 0.05s ease-out; display: block; }
 
 
 
 
 
 
 
 
269
  .panel img.panning { cursor: grabbing; transition: none; }
270
+ .panel.selected { outline: 4px solid #3498db; z-index: 5; }
271
 
272
+ /* Clip Paths */
273
  .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; }
274
  .panel:nth-child(2) { clip-path: polygon(calc(var(--t1) + var(--gap)) 0, 100% 0, 100% calc(var(--y) - var(--gap)), calc(var(--t2) + var(--gap)) calc(var(--y) - var(--gap))); z-index: 1; }
275
  .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; }
276
  .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; }
277
 
278
+ /* Handles */
279
  .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); }
 
280
  .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; }
281
  .h-t2 { background: #3498db; left: var(--t2); top: 50%; margin-top: -15px; }
282
  .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; }
 
285
  /* SPEECH BUBBLES */
286
  .speech-bubble {
287
  position: absolute; display: flex; justify-content: center; align-items: center;
288
+ min-width: 60px; min-height: 40px; box-sizing: border-box;
289
+ z-index: 10; cursor: move; font-weight: bold; text-align: center;
 
290
  overflow: visible; line-height: 1.2; --tail-pos: 50%;
291
  }
292
  .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; }
293
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
294
+
295
+ /* Bubble Styles */
296
+ .speech-bubble.speech { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 2px solid #000; border-radius: 50%; width: 180px; height: 100px; }
297
  .speech-bubble.speech::after { content: ''; position: absolute; bottom: -12px; left: var(--tail-pos); border: 12px solid transparent; border-top-color: #000; border-bottom: 0; margin-left: -12px; }
298
+
299
+ .speech-bubble.thought { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 2px dashed #555; border-radius: 50%; width: 180px; height: 100px; }
300
+ .speech-bubble.thought .dots { position: absolute; bottom:-20px; left:20px; width:15px; height:15px; background:#fff; border:2px solid #555; border-radius:50%; }
301
+
302
+ .speech-bubble.reaction { background: #ff0; border: 3px solid red; color: red; font-family: 'Bangers'; text-transform: uppercase; width: 200px; height: 120px; 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%); }
303
+
304
+ .speech-bubble.narration { background: #eee; border: 2px solid #000; color: #000; border-radius: 0; width: 300px; height: 60px; font-family: 'Lato'; bottom: 10px; left: 50%; transform: translateX(-50%); }
305
+
306
  .resize-handle { position: absolute; bottom:-5px; right:-5px; width:15px; height:15px; background:#3498db; border:1px solid white; cursor:se-resize; display:none; }
307
  .speech-bubble.selected .resize-handle { display:block; }
308
 
 
314
  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; }
315
  .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
316
  .color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
317
+ .action-btn { background: #27ae60; color: white; }
318
+ .reset-btn { background: #c0392b; color: white; }
319
+ .save-btn { background: #8e44ad; color: white; }
 
320
 
321
  .tip { text-align:center; padding:10px; background:#e74c3c; color:white; font-weight:bold; margin-bottom:20px; border-radius:5px; }
322
  .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; }
 
327
 
328
  <div id="upload-container">
329
  <div class="upload-box">
330
+ <h1>⚡ Ultimate Square Comic</h1>
331
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
332
  <label for="file-upload" class="file-label">📁 Choose Video</label>
333
  <span id="fn" style="margin-bottom:10px; display:block; color:#aaa;">No file selected</span>
 
338
  </div>
339
 
340
  <button class="submit-btn" onclick="upload()">🚀 Generate</button>
341
+ <button id="restore-draft-btn" class="reset-btn" style="display:none; margin-top:10px;" onclick="restoreDraft()">📂 Restore Draft</button>
342
 
343
  <div style="margin-top:20px; border-top:1px solid #555; padding-top:10px;">
344
  <input type="text" id="load-code" placeholder="ENTER SAVE CODE" style="width:70%; display:inline-block;">
345
  <button onclick="loadComic()" style="width:25%; display:inline-block; background:#9b59b6; color:white;">Load</button>
346
  </div>
347
  <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
348
+ <div class="loader"></div>
349
+ <p id="status-text" style="margin-top:10px;">Analyzing Video...</p>
350
  </div>
351
  </div>
352
  </div>
353
+
354
  <div id="editor-container">
355
+ <div class="tip">👉 Drag Right-Side Dots to reveal 4 panels! | 📜 Scroll to Zoom/Pan</div>
356
  <div class="comic-wrapper" id="comic-container"></div>
357
+ <input type="file" id="image-uploader" style="display: none;" accept="image/*">
358
 
359
  <div class="edit-controls">
360
  <h4>✏️ Editor</h4>
 
363
  <button onclick="undo()" style="background:#7f8c8d; color:white;">↩️ Undo</button>
364
  <button onclick="saveComic()" class="save-btn">💾 Save Comic</button>
365
  </div>
366
+
367
  <div class="control-group">
368
+ <label>💬 Bubble Styling:</label>
369
+ <select id="bubble-type" onchange="updateBubbleType()">
370
+ <option value="speech">Speech 💬</option>
371
+ <option value="thought">Thought 💭</option>
372
+ <option value="reaction">Reaction 💥</option>
373
+ <option value="narration">Narration ⬜</option>
374
+ </select>
375
+ <select id="font-select" onchange="updateFont()">
376
+ <option value="'Comic Neue', cursive">Comic Neue</option>
377
+ <option value="'Bangers', cursive">Bangers</option>
378
+ <option value="'Gloria Hallelujah', cursive">Handwritten</option>
379
+ <option value="'Lato', sans-serif">Modern</option>
380
+ </select>
381
+ <div class="color-grid">
382
+ <input type="color" id="bub-fill" value="#ffffff" onchange="updateColors()" title="Fill">
383
+ <input type="color" id="bub-text" value="#000000" onchange="updateColors()" title="Text">
384
+ </div>
385
  <div class="button-grid">
386
+ <button onclick="addBubble()" class="action-btn">Add</button>
387
  <button onclick="deleteBubble()" class="reset-btn">Delete</button>
388
  </div>
389
+ <input type="range" min="10" max="90" value="50" oninput="slideTail(this.value)" title="Tail Pos">
 
 
 
390
  </div>
391
+
392
  <div class="control-group">
393
  <label>🖼️ Image Control:</label>
394
+ <button onclick="replaceImage()" class="action-btn">Replace Image</button>
395
  <div class="button-grid">
396
  <button onclick="adjustFrame('backward')" class="secondary-btn">⬅️ Frame</button>
397
  <button onclick="adjustFrame('forward')" class="action-btn">Frame ➡️</button>
 
399
  </div>
400
 
401
  <div class="control-group">
402
+ <label>🔍 Zoom (Scroll Wheel):</label>
 
403
  <input type="range" id="zoom-slider" min="20" max="300" value="100" step="5" oninput="handleZoom(this.value)" disabled>
404
  <button onclick="resetPanelTransform()" class="secondary-btn">Reset View</button>
405
  </div>
 
410
  </div>
411
  </div>
412
  </div>
413
+
414
  <div class="modal-overlay" id="save-modal">
415
  <div class="modal-content">
416
  <h2>✅ Comic Saved!</h2>
 
418
  <button onclick="closeModal()">Close</button>
419
  </div>
420
  </div>
421
+
422
  <script>
423
  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);}); }
424
  let sid = localStorage.getItem('comic_sid') || genUUID();
 
437
  bubbles.push({
438
  text: b.querySelector('.bubble-text').textContent,
439
  left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
440
+ type: b.dataset.type, font: b.style.fontFamily,
441
+ colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') },
442
+ tailPos: b.style.getPropertyValue('--tail-pos')
443
  });
444
  });
445
  const panels = [];
446
  grid.querySelectorAll('.panel').forEach(pan => {
447
  const img = pan.querySelector('img');
448
+ const srcParts = img.src.split('frames/');
449
+ const fname = srcParts.length > 1 ? srcParts[1].split('?')[0] : '';
450
+ panels.push({ image: fname, zoom: img.dataset.zoom, tx: img.dataset.translateX, ty: img.dataset.translateY });
451
  });
452
  state.push({ layout, bubbles, panels });
453
  });
 
481
  if(pi < panels.length) {
482
  const img = panels[pi].querySelector('img');
483
  img.dataset.zoom = pData.zoom; img.dataset.translateX = pData.tx; img.dataset.translateY = pData.ty;
484
+ if(pData.image) img.src = `/frames/${pData.image}?sid=${sid}`;
485
  updateImageTransform(img);
486
  }
487
  });
 
496
  setTimeout(() => restoreFromState(JSON.parse(localStorage.getItem('comic_draft_'+sid))), 500);
497
  });
498
  }
499
+
500
  async function upload() {
501
  const f = document.getElementById('file-upload').files[0];
502
  const pCount = document.getElementById('page-count').value;
 
523
  const data = await r.json();
524
  const cleanData = data.map(p => ({
525
  panels: p.panels.map(pan => ({ src: `/frames/${pan.image}?sid=${sid}` })),
526
+ bubbles: p.bubbles.map(b => ({
527
+ text: b.dialog, left: (b.bubble_offset_x||50)+'px', top: (b.bubble_offset_y||20)+'px', type: b.type,
528
+ colors: b.colors, font: b.font
529
+ }))
530
  }));
531
  renderFromState(cleanData);
532
  saveState();
533
  }
534
+
535
  function renderFromState(pagesData) {
536
  const con = document.getElementById('comic-container'); con.innerHTML = '';
537
  pagesData.forEach((page, pageIdx) => {
 
547
  img.dataset.zoom = 100; img.dataset.translateX = 0; img.dataset.translateY = 0;
548
  img.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); selectPanel(pDiv); dragType = 'pan'; activeObj = img; dragStart = {x:e.clientX, y:e.clientY}; img.classList.add('panning'); };
549
 
550
+ // 🚀 ZOOM: Allows zooming out to 20% to fit 16:9 images in squares
551
  img.onwheel = (e) => {
552
  e.preventDefault();
553
  let zoom = parseFloat(img.dataset.zoom);
554
  zoom += e.deltaY * -0.1;
555
+ zoom = Math.min(Math.max(20, zoom), 300);
556
  img.dataset.zoom = zoom;
557
  updateImageTransform(img);
558
  if(selectedPanel === pDiv) document.getElementById('zoom-slider').value = zoom;
 
576
 
577
  function createBubbleHTML(data) {
578
  const b = document.createElement('div');
579
+ const type = data.type || 'speech';
580
+ b.className = `speech-bubble ${type}`;
581
+ b.dataset.type = type;
582
  b.style.left = data.left; b.style.top = data.top;
583
  if(data.width) b.style.width = data.width;
584
  if(data.height) b.style.height = data.height;
585
+ if(data.font) b.style.fontFamily = data.font;
586
  if(data.colors) { b.style.setProperty('--bubble-fill', data.colors.fill); b.style.setProperty('--bubble-text', data.colors.text); }
587
+ if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
588
+ if(type === 'thought') { const dots=document.createElement('div'); dots.className='dots'; b.appendChild(dots); }
589
 
590
  const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || 'Text'; b.appendChild(textSpan);
591
  const resizer = document.createElement('div'); resizer.className = 'resize-handle';
 
603
  if(newText !== null) { textSpan.textContent = newText; saveState(); }
604
  }
605
 
606
+ // --- GLOBAL MOUSE EVENTS ---
607
  document.addEventListener('mousemove', (e) => {
608
  if(!dragType) return;
609
  if(dragType === 'handle') {
 
633
  dragType = null; activeObj = null;
634
  });
635
 
636
+ // --- UI ACTIONS ---
637
+ function selectBubble(el) {
638
+ if(selectedBubble) selectedBubble.classList.remove('selected');
639
+ selectedBubble = el; el.classList.add('selected');
640
+ document.getElementById('bubble-type').value = el.dataset.type;
641
+ document.getElementById('font-select').value = el.style.fontFamily || "'Comic Neue', cursive";
642
+ }
643
  function selectPanel(el) {
644
  if(selectedPanel) selectedPanel.classList.remove('selected');
645
  selectedPanel = el; el.classList.add('selected');
646
  document.getElementById('zoom-slider').disabled = false;
647
  document.getElementById('zoom-slider').value = el.querySelector('img').dataset.zoom;
648
  }
649
+ 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(); } }
650
+ function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); selectedBubble=null; saveState(); } }
651
 
652
+ function updateBubbleType() {
653
+ if(!selectedBubble) return;
654
+ const type = document.getElementById('bubble-type').value;
655
+ const oldB = selectedBubble;
656
+ const data = {
657
+ text: oldB.querySelector('.bubble-text').textContent,
658
+ left: oldB.style.left, top: oldB.style.top, width: oldB.style.width, height: oldB.style.height,
659
+ type: type, font: oldB.style.fontFamily,
660
+ colors: { fill: oldB.style.getPropertyValue('--bubble-fill'), text: oldB.style.getPropertyValue('--bubble-text') },
661
+ tailPos: oldB.style.getPropertyValue('--tail-pos')
662
+ };
663
+ const newB = createBubbleHTML(data);
664
+ oldB.parentElement.replaceChild(newB, oldB);
665
+ selectBubble(newB); saveState();
666
  }
667
+
668
+ function updateColors() {
669
+ if(!selectedBubble) return;
670
+ selectedBubble.style.setProperty('--bubble-fill', document.getElementById('bub-fill').value);
671
+ selectedBubble.style.setProperty('--bubble-text', document.getElementById('bub-text').value);
672
+ saveState();
673
+ }
674
+
675
+ function updateFont() { if(selectedBubble) { selectedBubble.style.fontFamily = document.getElementById('font-select').value; saveState(); } }
676
+ function slideTail(val) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', val+'%'); saveState(); } }
677
 
678
  function handleZoom(val) { if(selectedPanel) { const img = selectedPanel.querySelector('img'); img.dataset.zoom = val; updateImageTransform(img); saveState(); } }
679
  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})`; }
680
  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(); } }
681
 
682
+ function replaceImage() {
683
+ if(!selectedPanel) return alert("Select a panel");
684
+ const inp = document.getElementById('image-uploader');
685
+ inp.onchange = async (e) => {
686
+ const fd = new FormData(); fd.append('image', e.target.files[0]);
687
+ const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd});
688
+ const d = await r.json();
689
+ if(d.success) { selectedPanel.querySelector('img').src = `/frames/${d.new_filename}?sid=${sid}`; saveState(); }
690
+ inp.value = '';
691
+ };
692
+ inp.click();
693
+ }
694
+
695
  async function adjustFrame(dir) {
696
  if(!selectedPanel) return alert("Click a panel first");
697
  const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0];
 
731
  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%' };
732
  const bubbles = [];
733
  grid.querySelectorAll('.speech-bubble').forEach(b => {
734
+ bubbles.push({ text: b.querySelector('.bubble-text').textContent, left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height, type: b.dataset.type, colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') }, tailPos: b.style.getPropertyValue('--tail-pos') });
735
  });
736
  const panels = [];
737
  grid.querySelectorAll('.panel').forEach(pan => {
 
757
  if not sid: return jsonify({'success': False, 'message': 'Missing session ID'}), 400
758
 
759
  file = request.files.get('file')
760
+ if not file or file.filename == '': return jsonify({'success': False, 'message': 'No file uploaded'}), 400
 
761
 
762
  target_pages = request.form.get('target_pages', 4)
763
  gen = EnhancedComicGenerator(sid)
 
792
  gen = EnhancedComicGenerator(sid)
793
  return jsonify(regen_frame_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], d['direction']))
794
 
795
+ @app.route('/goto_timestamp', methods=['POST'])
796
+ def go_time():
797
+ sid = request.args.get('sid')
798
+ d = request.get_json()
799
+ gen = EnhancedComicGenerator(sid)
800
+ return jsonify(get_frame_at_ts_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], float(d['timestamp'])))
801
+
802
+ @app.route('/replace_panel', methods=['POST'])
803
+ def rep_panel():
804
+ sid = request.args.get('sid')
805
+ f = request.files['image']
806
+ frames_dir = os.path.join(BASE_USER_DIR, sid, 'frames')
807
+ os.makedirs(frames_dir, exist_ok=True)
808
+ fname = f"replaced_{int(time.time() * 1000)}.png"
809
+ f.save(os.path.join(frames_dir, fname))
810
+ return jsonify({'success': True, 'new_filename': fname})
811
+
812
  @app.route('/save_comic', methods=['POST'])
813
  def save_comic():
814
  sid = request.args.get('sid')
 
817
  save_code = generate_save_code()
818
  save_dir = os.path.join(SAVED_COMICS_DIR, save_code)
819
  os.makedirs(save_dir, exist_ok=True)
820
+ user_frames_dir = os.path.join(BASE_USER_DIR, sid, 'frames')
821
+ saved_frames_dir = os.path.join(save_dir, 'frames')
822
+ if os.path.exists(user_frames_dir):
823
+ if os.path.exists(saved_frames_dir): shutil.rmtree(saved_frames_dir)
824
+ shutil.copytree(user_frames_dir, saved_frames_dir)
 
 
825
  with open(os.path.join(save_dir, 'comic_state.json'), 'w') as f:
826
  json.dump({'originalSid': sid, 'pages': data['pages'], 'savedAt': time.time()}, f)
 
827
  return jsonify({'success': True, 'code': save_code})
828
  except Exception as e: return jsonify({'success': False, 'message': str(e)})
829
 
 
840
  os.makedirs(user_frames, exist_ok=True)
841
  for fn in os.listdir(saved_frames):
842
  shutil.copy2(os.path.join(saved_frames, fn), os.path.join(user_frames, fn))
 
843
  return jsonify({'success': True, 'originalSid': orig_sid, 'pages': data['pages']})
844
  except Exception as e: return jsonify({'success': False, 'message': str(e)})
845