tester343 commited on
Commit
24559c0
·
verified ·
1 Parent(s): ab71d8e

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +165 -746
app_enhanced.py CHANGED
@@ -28,10 +28,8 @@ def gpu_warmup():
28
  # ======================================================
29
  if os.path.exists('/data'):
30
  BASE_STORAGE_PATH = '/data'
31
- print("✅ Using Persistent Storage at /data (Files saved for days/weeks)")
32
  else:
33
  BASE_STORAGE_PATH = '.'
34
- print("⚠️ Using Ephemeral/Local Storage (Files lost on restart)")
35
 
36
  BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
37
  SAVED_COMICS_DIR = os.path.join(BASE_STORAGE_PATH, "saved_comics")
@@ -83,18 +81,10 @@ def generate_save_code(length=8):
83
  # ======================================================
84
  @spaces.GPU(duration=300)
85
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages, panels_per_page_req):
86
- print(f"🚀 GPU Task Started: {video_path} | Pages: {target_pages} | Panels/Page: {panels_per_page_req}")
87
-
88
  import cv2
89
  import srt
90
  import numpy as np
91
- from backend.keyframes.keyframes import black_bar_crop
92
- from backend.simple_color_enhancer import SimpleColorEnhancer
93
- from backend.quality_color_enhancer import QualityColorEnhancer
94
- from backend.subtitles.subs_real import get_real_subtitles
95
- from backend.ai_bubble_placement import ai_bubble_placer
96
- from backend.ai_enhanced_core import face_detector
97
-
98
  cap = cv2.VideoCapture(video_path)
99
  if not cap.isOpened(): raise Exception("Cannot open video")
100
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
@@ -103,12 +93,8 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
103
  cap.release()
104
 
105
  user_srt = os.path.join(user_dir, 'subs.srt')
106
- try:
107
- get_real_subtitles(video_path)
108
- if os.path.exists('test1.srt'):
109
- shutil.move('test1.srt', user_srt)
110
- except:
111
- with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
112
 
113
  with open(user_srt, 'r', encoding='utf-8') as f:
114
  try: all_subs = list(srt.parse(f.read()))
@@ -117,18 +103,14 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
117
  valid_subs = [s for s in all_subs if s.content.strip()]
118
  raw_moments = [{'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
119
 
120
- if target_pages <= 0: target_pages = 1
121
  panels_per_page = int(panels_per_page_req)
122
- if panels_per_page <= 0: panels_per_page = 2
123
-
124
  total_panels_needed = target_pages * panels_per_page
125
 
126
  selected_moments = []
127
  if not raw_moments:
128
- times = np.linspace(1, duration-1, total_panels_needed)
129
  for t in times: selected_moments.append({'text': '', 'start': t, 'end': t+1})
130
- elif len(raw_moments) <= total_panels_needed:
131
- selected_moments = raw_moments
132
  else:
133
  indices = np.linspace(0, len(raw_moments) - 1, total_panels_needed, dtype=int)
134
  selected_moments = [raw_moments[i] for i in indices]
@@ -140,14 +122,12 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
140
 
141
  for i, moment in enumerate(selected_moments):
142
  mid = (moment['start'] + moment['end']) / 2
143
- if mid > duration: mid = duration - 1
144
  cap.set(cv2.CAP_PROP_POS_FRAMES, int(mid * fps))
145
  ret, frame = cap.read()
146
  if ret:
147
  fname = f"frame_{count:04d}.png"
148
  p = os.path.join(frames_dir, fname)
149
  cv2.imwrite(p, frame)
150
- os.sync()
151
  frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
152
  frame_files_ordered.append(fname)
153
  count += 1
@@ -155,30 +135,10 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
155
 
156
  with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
157
 
158
- try: black_bar_crop()
159
- except: pass
160
-
161
- se = SimpleColorEnhancer()
162
- qe = QualityColorEnhancer()
163
-
164
- for f in frame_files_ordered:
165
- p = os.path.join(frames_dir, f)
166
- try: se.enhance_single(p, p)
167
- except: pass
168
- try: qe.enhance_single(p, p)
169
- except: pass
170
-
171
  bubbles_list = []
172
  for f in frame_files_ordered:
173
- p = os.path.join(frames_dir, f)
174
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
175
-
176
- b_type = 'speech'
177
- if '(' in dialogue and ')' in dialogue: b_type = 'narration'
178
- elif '!' in dialogue and dialogue.isupper(): b_type = 'reaction'
179
- elif '?' in dialogue: b_type = 'speech'
180
-
181
- bubbles_list.append(bubble(dialog=dialogue, bubble_offset_x=-1, bubble_offset_y=-1, type=b_type))
182
 
183
  pages = []
184
  for i in range(target_pages):
@@ -188,75 +148,29 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
188
  p_bubbles = bubbles_list[start_idx:end_idx]
189
  if p_frames:
190
  pg_panels = [panel(image=f) for f in p_frames]
191
- pages.append(Page(panels=pg_panels, bubbles=p_bubbles))
192
-
193
- result = []
194
- for pg in pages:
195
- p_data = [p if isinstance(p, dict) else p.__dict__ for p in pg.panels]
196
- b_data = [b if isinstance(b, dict) else b.__dict__ for b in pg.bubbles]
197
- result.append({'panels': p_data, 'bubbles': b_data})
198
 
199
- return result
200
 
201
  @spaces.GPU
202
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
203
  import cv2
204
  import json
205
- from backend.simple_color_enhancer import SimpleColorEnhancer
206
-
207
- if not os.path.exists(metadata_path): return {"success": False, "message": "No metadata"}
208
  with open(metadata_path, 'r') as f: meta = json.load(f)
209
- if fname not in meta: return {"success": False, "message": "Frame not found"}
210
-
211
- t = meta[fname]['time'] if isinstance(meta[fname], dict) else meta[fname]
212
  cap = cv2.VideoCapture(video_path)
213
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
214
  offset = (1.0/fps) * (1 if direction == 'forward' else -1)
215
  new_t = max(0, t + offset)
216
-
217
  cap.set(cv2.CAP_PROP_POS_MSEC, new_t * 1000)
218
  ret, frame = cap.read()
219
  cap.release()
220
-
221
  if ret:
222
- p = os.path.join(frames_dir, fname)
223
- cv2.imwrite(p, frame)
224
- os.sync()
225
- try: SimpleColorEnhancer().enhance_single(p, p)
226
- except: pass
227
-
228
- if isinstance(meta[fname], dict): meta[fname]['time'] = new_t
229
- else: meta[fname] = new_t
230
  with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
231
- return {"success": True, "message": f"Adjusted to {new_t:.2f}s"}
232
- return {"success": False, "message": "End of video"}
233
-
234
- @spaces.GPU
235
- def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
236
- import cv2
237
- import json
238
- from backend.simple_color_enhancer import SimpleColorEnhancer
239
-
240
- cap = cv2.VideoCapture(video_path)
241
- cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
242
- ret, frame = cap.read()
243
- cap.release()
244
-
245
- if ret:
246
- p = os.path.join(frames_dir, fname)
247
- cv2.imwrite(p, frame)
248
- os.sync()
249
- try: SimpleColorEnhancer().enhance_single(p, p)
250
- except: pass
251
-
252
- if os.path.exists(metadata_path):
253
- with open(metadata_path, 'r') as f: meta = json.load(f)
254
- if fname in meta:
255
- if isinstance(meta[fname], dict): meta[fname]['time'] = float(ts)
256
- else: meta[fname] = float(ts)
257
- with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
258
- return {"success": True, "message": f"Jumped to {ts}s"}
259
- return {"success": False, "message": "Invalid timestamp"}
260
 
261
  # ======================================================
262
  # 💻 BACKEND CLASS
@@ -272,21 +186,12 @@ class EnhancedComicGenerator:
272
  os.makedirs(self.output_dir, exist_ok=True)
273
  self.metadata_path = os.path.join(self.frames_dir, 'frame_metadata.json')
274
 
275
- def cleanup(self):
276
- if os.path.exists(self.frames_dir): shutil.rmtree(self.frames_dir)
277
- if os.path.exists(self.output_dir): shutil.rmtree(self.output_dir)
278
- os.makedirs(self.frames_dir, exist_ok=True)
279
- os.makedirs(self.output_dir, exist_ok=True)
280
-
281
  def run(self, target_pages, panels_per_page):
282
  try:
283
- self.write_status("Waiting for GPU...", 5)
284
- data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, int(target_pages), int(panels_per_page))
285
- with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
286
- json.dump(data, f, indent=2)
287
  self.write_status("Complete!", 100)
288
  except Exception as e:
289
- traceback.print_exc()
290
  self.write_status(f"Error: {str(e)}", -1)
291
 
292
  def write_status(self, msg, prog):
@@ -297,619 +202,212 @@ class EnhancedComicGenerator:
297
  # 🌐 ROUTES & FULL UI
298
  # ======================================================
299
  INDEX_HTML = '''
300
- <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>🎬 Enhanced Comic Generator</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: #fdf6e3; font-family: 'Lato', sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
 
 
301
 
302
- #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
303
  .upload-box { max-width: 500px; width: 100%; padding: 40px; background: white; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.12); text-align: center; }
304
 
305
- #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 100px; }
306
 
307
- h1 { color: #2c3e50; margin-bottom: 20px; font-weight: 600; }
308
- .file-input { display: none; }
309
- .file-label { display: block; padding: 15px; background: #2c3e50; color: white; border-radius: 8px; cursor: pointer; font-weight: bold; margin-bottom: 10px; transition:0.2s; }
310
- .file-label:hover { background: #34495e; }
311
-
312
- .page-input-group { margin: 20px 0; text-align: left; }
313
- .page-input-group label { font-weight: bold; font-size: 14px; display: block; margin-bottom: 5px; color: #333; }
314
- .page-input-group input { width: 100%; padding: 12px; border: 2px solid #ddd; border-radius: 8px; font-size: 16px; box-sizing: border-box; }
315
-
316
- .submit-btn { width: 100%; padding: 15px; background: #e67e22; color: white; border: none; border-radius: 8px; font-size: 18px; font-weight: bold; cursor: pointer; transition: 0.2s; }
317
- .submit-btn:hover { background: #d35400; }
318
- .restore-btn { margin-top: 10px; background: #27ae60; color: white; padding: 12px; width: 100%; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
319
-
320
- .load-section { margin-top: 30px; padding-top: 20px; border-top: 2px solid #eee; }
321
- .load-input-group { display: flex; gap: 10px; margin-top: 10px; }
322
- .load-input-group input { flex: 1; padding: 12px; border: 2px solid #ddd; border-radius: 8px; font-size: 16px; text-transform: uppercase; letter-spacing: 2px; text-align: center; }
323
- .load-input-group button { padding: 12px 20px; background: #3498db; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
324
-
325
- .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; }
326
- @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
327
-
328
- /* COMIC LAYOUT */
329
- .comic-wrapper { max-width: 1000px; margin: 0 auto; }
330
- .page-wrapper { margin: 30px auto; display: flex; flex-direction: column; align-items: center; }
331
- .page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
332
-
333
- /* === SIZE 864x1080 === */
334
  .comic-page {
335
- background: white;
336
- width: 864px;
337
- height: 1080px;
338
- box-shadow: 0 4px 10px rgba(0,0,0,0.1);
339
- position: relative;
340
- overflow: hidden;
341
- border: 2px solid #000;
342
- padding: 10px;
343
  }
344
 
345
- /* === CUSTOM SLANT LAYOUT === */
346
- .comic-grid { width: 100%; height: 100%; position: relative; background: #000; display: block; }
347
-
348
- .panel { position: absolute; overflow: hidden; background: #000; cursor: pointer; border: 2px solid #000; }
349
- .comic-grid.layout-custom-slant .panel { border: none; background: transparent; width: 100%; height: 100%; }
350
 
351
- /* Top Panel (1) */
352
- /* Clip polygon uses custom props split-l and split-r (0-100%) */
353
- .comic-grid.layout-custom-slant .panel:nth-child(1) {
354
- top: 0; left: 0;
355
- z-index:2;
356
- clip-path: polygon(0 0, 100% 0, 100% var(--split-r, 55%), 0 var(--split-l, 45%));
357
  }
358
 
359
- /* Bottom Panel (2) */
360
- .comic-grid.layout-custom-slant .panel:nth-child(2) {
361
- top: 0; left: 0;
362
- z-index:1;
363
- clip-path: polygon(0 var(--split-l, 45%), 100% var(--split-r, 55%), 100% 100%, 0 100%);
364
  }
365
 
366
- .panel.selected { z-index: 20; border-color: #2196F3; }
367
 
368
- /* DRAG POINTS */
369
  .split-handle {
370
- position: absolute; width: 24px; height: 24px;
371
- background: #2196F3; border: 3px solid white; border-radius: 50%;
372
- cursor: ns-resize; z-index: 1000; box-shadow: 0 2px 5px rgba(0,0,0,0.4);
373
- }
374
- .split-handle:hover { transform: scale(1.2); }
375
- .split-handle.left { left: -12px; top: var(--split-l, 45%); }
376
- .split-handle.right { right: -12px; top: var(--split-r, 55%); }
377
-
378
-
379
- .panel img {
380
- width: 100%; height: 100%;
381
- object-fit: contain; /* DEFAULT: Contain to show full image */
382
- transition: transform 0.1s ease-out;
383
- transform-origin: center center;
384
- pointer-events: auto;
385
  }
386
- .panel img.fit-cover { object-fit: cover; }
387
-
388
- .panel img.pannable { cursor: grab; }
389
- .panel img.panning { cursor: grabbing; }
390
 
391
- /* SPEECH BUBBLES */
392
  .speech-bubble {
393
- position: absolute; display: flex; justify-content: center; align-items: center;
394
- width: 150px; height: 80px; min-width: 50px; min-height: 30px; box-sizing: border-box;
395
- z-index: 10; cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold;
396
- font-size: 13px; text-align: center;
397
- overflow: visible;
398
- line-height: 1.2;
399
- --tail-pos: 50%;
400
- left: 50%; top: 50%; transform: translate(-50%, -50%);
401
- }
402
-
403
- .bubble-text {
404
- padding: 0.5em; word-wrap: break-word; white-space: pre-wrap; position: relative;
405
- z-index: 5; pointer-events: none; user-select: none; width: 100%; height: 100%;
406
- overflow: hidden; display: flex; align-items: center; justify-content: center; border-radius: inherit;
407
- }
408
-
409
- .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
410
- .speech-bubble textarea { position: absolute; top:0; left:0; width:100%; height:100%; box-sizing:border-box; border:1px solid #4CAF50; background:rgba(255,255,255,0.95); text-align:center; padding:8px; z-index:102; resize:none; font: inherit; white-space: pre-wrap; }
411
-
412
- .speech-bubble.speech {
413
- --b: 3em; --h: 1.8em; --t: 0.6; --p: var(--tail-pos, 50%); --r: 1.2em;
414
- background: var(--bubble-fill-color, #4ECDC4); color: var(--bubble-text-color, #fff); padding: 0;
415
- border-radius: var(--r) var(--r) min(var(--r), calc(100% - var(--p) - (1 - var(--t)) * var(--b) / 2)) min(var(--r), calc(var(--p) - (1 - var(--t)) * var(--b) / 2)) / var(--r);
416
- }
417
- .speech-bubble.speech:before {
418
- content: ""; position: absolute; width: var(--b); height: var(--h);
419
- background: inherit; border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
420
- -webkit-mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%); mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
421
  }
422
-
423
- .speech-bubble.speech.tail-bottom:before { top: 100%; left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); }
424
- .speech-bubble.speech.tail-top { border-radius: min(var(--r), calc(var(--p) - (1 - var(--t)) * var(--b) / 2)) min(var(--r), calc(100% - var(--p) - (1 - var(--t)) * var(--b) / 2)) var(--r) var(--r) / var(--r); }
425
- .speech-bubble.speech.tail-left { border-radius: var(--r); }
426
- .speech-bubble.speech.tail-left:before { right: 100%; top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); transform: rotate(90deg); transform-origin: top right; }
427
- .speech-bubble.speech.tail-right { border-radius: var(--r); }
428
- .speech-bubble.speech.tail-right:before { left: 100%; top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); transform: rotate(-90deg); transform-origin: top left; }
429
-
430
- .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
431
- .speech-bubble.thought::before { display:none; }
432
- .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
433
- .thought-dot-1 { width: 20px; height: 20px; }
434
- .thought-dot-2 { width: 12px; height: 12px; }
435
- .speech-bubble.thought.pos-bl .thought-dot-1 { left: 20px; bottom: -20px; }
436
- .speech-bubble.thought.pos-bl .thought-dot-2 { left: 10px; bottom: -32px; }
437
- .speech-bubble.reaction { background: #FFD700; border: 3px solid #E53935; color: #D32F2F; font-weight: 900; text-transform: uppercase; clip-path: polygon(0% 25%, 17% 21%, 17% 0%, 31% 16%, 50% 4%, 69% 16%, 83% 0%, 83% 21%, 100% 25%, 85% 45%, 95% 62%, 82% 79%, 100% 97%, 79% 89%, 60% 98%, 46% 82%, 27% 95%, 15% 78%, 5% 62%, 15% 45%); }
438
- .speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; }
439
-
440
- .resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; z-index: 11; }
441
- .speech-bubble.selected .resize-handle { display: block; }
442
- .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
443
- .resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
444
- .resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
445
- .resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
446
-
447
- /* CONTROLS */
448
- .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; }
449
- .edit-controls h4 { margin: 0 0 10px 0; color: #4ECDC4; text-align: center; }
450
- .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
451
- .control-group label { font-size: 11px; font-weight: bold; display: block; margin-bottom: 3px; }
452
- 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; }
453
- .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
454
- .color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
455
- .slider-container { display: flex; align-items: center; gap: 5px; margin-top: 5px; }
456
- .slider-container label { min-width: 40px; font-size: 11px; }
457
- .action-btn { background: #4CAF50; color: white; }
458
- .reset-btn { background: #e74c3c; color: white; }
459
- .secondary-btn { background: #f39c12; color: white; }
460
- .export-btn { background: #2196F3; color: white; }
461
- .save-btn { background: #9b59b6; color: white; }
462
- .undo-btn { background: #7f8c8d; color: white; margin-bottom: 5px; }
463
-
464
- /* MODAL */
465
- .modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); display: none; justify-content: center; align-items: center; z-index: 9999; }
466
- .modal-content { background: white; padding: 30px; border-radius: 12px; max-width: 400px; width: 90%; text-align: center; }
467
- .modal-content .code { font-size: 32px; font-weight: bold; letter-spacing: 4px; background: #f0f0f0; padding: 15px 25px; border-radius: 8px; display: inline-block; margin: 15px 0; font-family: monospace; user-select: all; }
468
- .modal-content button { background: #3498db; color: white; border: none; padding: 12px 30px; border-radius: 8px; cursor: pointer; font-weight: bold; margin: 5px; }
469
- </style>
470
- </head> <body> <div id="upload-container"> <div class="upload-box"> <h1>🎬 Enhanced Comic Generator</h1> <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name"> <label for="file-upload" class="file-label">📁 Choose Video File</label> <span id="fn" style="margin-bottom:10px; display:block; color:#666;">No file selected</span>
471
- <div class="button-grid">
472
- <div class="page-input-group" style="margin:5px 0;">
473
- <label>📚 Panels / Page:</label>
474
- <input type="number" id="panels-per-page" value="2" min="1" max="4">
475
- </div>
476
- <div class="page-input-group" style="margin:5px 0;">
477
- <label>📄 Total Pages:</label>
478
- <input type="number" id="page-count" value="4" min="1" max="15">
479
- </div>
480
- </div>
481
-
482
- <button class="submit-btn" onclick="upload()">🚀 Generate Comic</button>
483
- <button id="restore-draft-btn" class="restore-btn" style="display:none; margin-top:15px;" onclick="restoreDraft()">���� Restore Unsaved Draft</button>
484
-
485
- <div class="load-section">
486
- <h3>📥 Load Saved Comic</h3>
487
- <div class="load-input-group">
488
- <input type="text" id="load-code-input" placeholder="SAVE CODE" maxlength="8" style="text-transform:uppercase;">
489
- <button onclick="loadSavedComic()">Load</button>
490
- </div>
491
- </div>
492
- <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
493
- <div class="loader" style="margin:0 auto;"></div>
494
- <p id="status-text" style="margin-top:10px;">Starting...</p>
495
  </div>
496
  </div>
497
  </div>
 
498
  <div id="editor-container">
499
- <div class="comic-wrapper" id="comic-container"></div>
500
- <input type="file" id="image-uploader" style="display: none;" accept="image/*">
501
  <div class="edit-controls">
502
- <h4>✏️ Interactive Editor</h4>
503
-
504
- <button onclick="undoLastAction()" class="undo-btn">↩️ Undo</button>
505
-
506
- <div class="control-group">
507
- <label>💾 Save & Load:</label>
508
- <button onclick="saveComic()" class="save-btn">💾 Save Comic</button>
509
- <div id="current-save-code" style="display:none; margin-top:5px; text-align:center;">
510
- <span id="display-save-code" style="font-weight:bold; background:#eee; padding:2px 5px; border-radius:3px;"></span>
511
- <button onclick="copyCode()" style="padding:2px; width:auto; font-size:10px;">Copy</button>
512
- </div>
513
- </div>
514
-
515
- <div class="control-group">
516
- <label>💬 Bubble Styling:</label>
517
- <select id="bubble-type-select" onchange="changeBubbleType(this.value)" disabled>
518
- <option value="speech">Speech</option>
519
- <option value="thought">Thought</option>
520
- <option value="reaction">Reaction (Shout)</option>
521
- <option value="narration">Narration (Box)</option>
522
- </select>
523
- <select id="font-select" onchange="changeFont(this.value)" disabled>
524
- <option value="'Comic Neue', cursive">Comic Neue</option>
525
- <option value="'Bangers', cursive">Bangers</option>
526
- <option value="'Gloria Hallelujah', cursive">Gloria</option>
527
- <option value="'Lato', sans-serif">Lato</option>
528
- </select>
529
- <div class="color-grid">
530
- <div><label>Text</label><input type="color" id="bubble-text-color" value="#ffffff" disabled></div>
531
- <div><label>Fill</label><input type="color" id="bubble-fill-color" value="#4ECDC4" disabled></div>
532
- </div>
533
- <div class="button-grid">
534
- <button onclick="addBubble()" class="action-btn">Add</button>
535
- <button onclick="deleteBubble()" class="reset-btn">Delete</button>
536
- </div>
537
- </div>
538
-
539
- <div class="control-group" id="tail-controls" style="display:none;">
540
- <label>📐 Tail Adjustment:</label>
541
- <button onclick="rotateTail()" class="secondary-btn">🔄 Rotate Side</button>
542
- <div class="slider-container">
543
- <label>Pos:</label>
544
- <input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)">
545
- </div>
546
- </div>
547
-
548
- <div class="control-group">
549
- <label>🖼️ Panel Tools:</label>
550
- <button onclick="replacePanelImage()" class="action-btn">🖼️ Replace Image</button>
551
- <div class="button-grid">
552
- <button onclick="adjustFrame('backward')" class="secondary-btn" id="prev-btn">⬅️ Prev</button>
553
- <button onclick="adjustFrame('forward')" class="action-btn" id="next-btn">Next ➡️</button>
554
- </div>
555
- <div class="timestamp-controls">
556
- <input type="text" id="timestamp-input" placeholder="mm:ss or secs">
557
- <button onclick="gotoTimestamp()" class="action-btn" id="go-btn">Go</button>
558
- </div>
559
- </div>
560
-
561
- <div class="control-group">
562
- <label>🔍 Zoom & Pan:</label>
563
- <div class="button-grid">
564
- <button onclick="toggleFitMode()" class="secondary-btn" id="fit-btn">Fit: Contain</button>
565
- <button onclick="resetPanelTransform()" class="secondary-btn">Reset</button>
566
- </div>
567
- <input type="range" id="zoom-slider" min="50" max="500" value="100" step="5" disabled oninput="handleZoom(this)">
568
- <small style="display:block;margin-top:5px;color:#aaa;">💡 Scroll on image to Zoom, Drag to Pan.</small>
569
- </div>
570
-
571
- <div class="control-group">
572
- <button onclick="exportComic()" class="export-btn">📥 Export as PNG</button>
573
- <button onclick="goBackToUpload()" class="reset-btn" style="margin-top:10px;">🏠 Home</button>
574
- </div>
575
- </div>
576
- </div>
577
- <div class="modal-overlay" id="save-modal">
578
- <div class="modal-content">
579
- <h2>✅ Comic Saved!</h2>
580
- <div class="code" id="modal-save-code">XXXXXXXX</div>
581
- <button onclick="copyModalCode()">📋 Copy Code</button>
582
- <button class="close-btn" onclick="closeModal()">Close</button>
583
  </div>
584
  </div>
 
585
  <script>
586
- 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);}); }
587
- let sid = localStorage.getItem('comic_sid') || genUUID();
588
- localStorage.setItem('comic_sid', sid);
589
-
590
- let currentSaveCode = null;
591
- let isProcessing = false;
592
- let interval, selectedBubble = null, selectedPanel = null;
593
- let isDragging = false, isResizing = false, isPanning = false, isDraggingSplit = false;
594
- let startX, startY, initX, initY, panStartX, panStartY, panStartTx, panStartTy;
595
- let resizeHandle, originalWidth, originalHeight, originalMouseX, originalMouseY;
596
- let currentlyEditing = null;
597
- let selectedSplitHandle = null;
598
-
599
- // UNDO SYSTEM
600
- let historyStack = [];
601
- let historyIndex = -1;
602
- function addToHistory() {
603
- if (historyIndex < historyStack.length - 1) {
604
- historyStack = historyStack.slice(0, historyIndex + 1);
605
- }
606
- const state = JSON.stringify(getCurrentState());
607
- if (historyStack.length > 0 && historyStack[historyStack.length - 1] === state) return;
608
-
609
- historyStack.push(state);
610
- historyIndex++;
611
-
612
- if (historyStack.length > 30) {
613
- historyStack.shift();
614
- historyIndex--;
615
- }
616
- }
617
- function undoLastAction() {
618
- if (historyIndex > 0) {
619
- historyIndex--;
620
- const previousState = JSON.parse(historyStack[historyIndex]);
621
- renderFromState(previousState);
622
- saveDraft(false);
623
- }
624
- }
625
-
626
- if(localStorage.getItem('comic_draft_'+sid)) document.getElementById('restore-draft-btn').style.display = 'block';
627
-
628
- function showSaveModal(code) { document.getElementById('modal-save-code').textContent = code; document.getElementById('save-modal').style.display = 'flex'; }
629
- function closeModal() { document.getElementById('save-modal').style.display = 'none'; }
630
- function copyModalCode() { navigator.clipboard.writeText(document.getElementById('modal-save-code').textContent).then(() => alert('Code copied!')); }
631
- function copyCode() { if(currentSaveCode) navigator.clipboard.writeText(currentSaveCode).then(() => alert('Code copied!')); }
632
-
633
- function setProcessing(busy) {
634
- isProcessing = busy;
635
- const btns = ['prev-btn', 'next-btn', 'go-btn'];
636
- btns.forEach(id => {
637
- const el = document.getElementById(id);
638
- if(el) { el.disabled = busy; el.style.opacity = busy ? '0.5' : '1'; el.innerText = busy ? '⏳' : el.getAttribute('data-txt') || el.innerText; }
639
- });
640
- }
641
- async function saveComic() {
642
- const state = getCurrentState();
643
- if(!state || state.length === 0) { alert('No comic to save!'); return; }
644
- try {
645
- const r = await fetch(`/save_comic?sid=${sid}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ pages: state, savedAt: new Date().toISOString() }) });
646
- const d = await r.json();
647
- if(d.success) { currentSaveCode = d.code; document.getElementById('display-save-code').textContent = d.code; document.getElementById('current-save-code').style.display = 'block'; showSaveModal(d.code); saveDraft(false); }
648
- else { alert('Failed to save: ' + d.message); }
649
- } catch(e) { console.error(e); alert('Error saving comic'); }
650
- }
651
-
652
- async function loadSavedComic() {
653
- const code = document.getElementById('load-code-input').value.trim().toUpperCase();
654
- if(!code || code.length < 4) { alert('Invalid code'); return; }
655
- try {
656
- const r = await fetch(`/load_comic/${code}`);
657
  const d = await r.json();
658
- if(d.success) { currentSaveCode = code; sid = d.originalSid || sid; localStorage.setItem('comic_sid', sid); renderFromState(d.pages); document.getElementById('upload-container').style.display = 'none'; document.getElementById('editor-container').style.display = 'block'; document.getElementById('display-save-code').textContent = code; document.getElementById('current-save-code').style.display = 'block'; saveDraft(true); }
659
- else { alert('Load failed: ' + d.message); }
660
- } catch(e) { console.error(e); alert('Error loading comic.'); }
661
- }
662
-
663
- function restoreDraft() {
664
- try {
665
- const state = JSON.parse(localStorage.getItem('comic_draft_'+sid));
666
- if(state.saveCode) { currentSaveCode = state.saveCode; document.getElementById('display-save-code').textContent = state.saveCode; document.getElementById('current-save-code').style.display = 'block'; }
667
- renderFromState(state.pages || state);
668
- document.getElementById('upload-container').style.display = 'none';
669
- document.getElementById('editor-container').style.display = 'block';
670
- addToHistory();
671
- } catch(e) { console.error(e); alert("Failed to restore."); }
672
  }
673
-
674
- function getCurrentState() {
675
- const pages = [];
676
- document.querySelectorAll('.comic-page').forEach(p => {
677
- const grid = p.querySelector('.comic-grid');
678
-
679
- // Layout Split Pos
680
- const splitL = grid.style.getPropertyValue('--split-l') || '45%';
681
- const splitR = grid.style.getPropertyValue('--split-r') || '55%';
682
-
683
- const panels = [];
684
- grid.querySelectorAll('.panel').forEach(pan => {
685
- const img = pan.querySelector('img');
686
- const bubbles = [];
687
- pan.querySelectorAll('.speech-bubble').forEach(b => {
688
- const textEl = b.querySelector('.bubble-text');
689
- bubbles.push({
690
- text: textEl ? textEl.textContent : '',
691
- left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height,
692
- classes: b.className.replace(' selected', ''),
693
- type: b.dataset.type, font: b.style.fontFamily,
694
- tailPos: b.style.getPropertyValue('--tail-pos'),
695
- colors: { fill: b.style.getPropertyValue('--bubble-fill-color'), text: b.style.getPropertyValue('--bubble-text-color') },
696
- transform: b.style.transform
697
- });
698
- });
699
- panels.push({
700
- src: img.src,
701
- zoom: img.dataset.zoom, tx: img.dataset.translateX, ty: img.dataset.translateY,
702
- fit: img.classList.contains('fit-cover') ? 'cover' : 'contain',
703
- bubbles: bubbles
704
- });
705
- });
706
- pages.push({ layout: 'layout-custom-slant', splitL: splitL, splitR: splitR, panels: panels });
707
- });
708
- return pages;
709
- }
710
-
711
- function saveDraft(recordHistory = true) {
712
- if(recordHistory) addToHistory();
713
- localStorage.setItem('comic_draft_'+sid, JSON.stringify({ pages: getCurrentState(), saveCode: currentSaveCode, savedAt: new Date().toISOString() }));
714
- }
715
-
716
- function renderFromState(pagesData) {
717
- const con = document.getElementById('comic-container'); con.innerHTML = '';
718
- pagesData.forEach((page, pageIdx) => {
719
- const pageWrapper = document.createElement('div'); pageWrapper.className = 'page-wrapper';
720
- const pageTitle = document.createElement('h2'); pageTitle.className = 'page-title'; pageTitle.textContent = `Page ${pageIdx + 1}`;
721
- pageWrapper.appendChild(pageTitle);
722
-
723
- const div = document.createElement('div'); div.className = 'comic-page';
724
- const grid = document.createElement('div');
725
-
726
  grid.className = 'comic-grid layout-custom-slant';
727
- grid.style.setProperty('--split-l', page.splitL || '45%');
728
- grid.style.setProperty('--split-r', page.splitR || '55%');
 
 
 
 
 
 
729
 
730
- // Add handles
731
- const hL = document.createElement('div'); hL.className = 'split-handle left';
732
- hL.onmousedown = (e) => startSplitDrag(e, hL, grid, 'left');
733
- const hR = document.createElement('div'); hR.className = 'split-handle right';
734
- hR.onmousedown = (e) => startSplitDrag(e, hR, grid, 'right');
735
- grid.appendChild(hL); grid.appendChild(hR);
736
-
737
- page.panels.forEach((pan) => {
738
- const pDiv = document.createElement('div'); pDiv.className = 'panel';
739
  pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
740
- const img = document.createElement('img');
741
- img.src = pan.src.includes('?') ? pan.src : pan.src + `?sid=${sid}`;
742
- img.dataset.zoom = pan.zoom || 100; img.dataset.translateX = pan.tx || 0; img.dataset.translateY = pan.ty || 0;
743
-
744
- if(pan.fit === 'cover') img.classList.add('fit-cover');
745
-
746
- updateImageTransform(img);
747
- img.onmousedown = (e) => { selectPanel(pDiv); startPan(e, img); };
748
- img.onwheel = (e) => handleWheelZoom(e, img);
749
-
750
- pDiv.appendChild(img);
751
- (pan.bubbles || []).forEach(bData => { pDiv.appendChild(createBubbleHTML(bData)); });
752
  grid.appendChild(pDiv);
753
  });
754
- div.appendChild(grid); pageWrapper.appendChild(div); con.appendChild(pageWrapper);
 
755
  });
756
- selectedBubble = null; selectedPanel = null;
 
757
  }
758
-
759
- // === SPLIT DRAGGING LOGIC ===
760
- function startSplitDrag(e, handle, grid, side) {
761
- e.preventDefault(); e.stopPropagation();
762
- isDraggingSplit = true;
763
- selectedSplitHandle = { handle, grid, side };
764
  }
765
-
766
- document.addEventListener('mousemove', (e) => {
767
- if(isDragging && selectedBubble) { selectedBubble.style.left = (initX + e.clientX - startX) + 'px'; selectedBubble.style.top = (initY + e.clientY - startY) + 'px'; }
768
- if(isResizing && selectedBubble) { resizeBubble(e); }
769
- if(isPanning && selectedPanel) { panImage(e); }
770
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
771
  if(isDraggingSplit && selectedSplitHandle) {
772
  const rect = selectedSplitHandle.grid.getBoundingClientRect();
773
- let y = e.clientY - rect.top;
774
- let percent = (y / rect.height) * 100;
775
- if(percent < 10) percent = 10;
776
- if(percent > 90) percent = 90;
777
-
778
- if(selectedSplitHandle.side === 'left') {
779
- selectedSplitHandle.grid.style.setProperty('--split-l', percent + '%');
780
- } else {
781
- selectedSplitHandle.grid.style.setProperty('--split-r', percent + '%');
782
- }
783
  }
784
  });
785
-
786
- document.addEventListener('mouseup', () => {
787
- if(isDragging || isResizing || isPanning || isDraggingSplit) {
788
- saveDraft(true);
789
- }
790
- isDragging = false; isResizing = false; isPanning = false; isDraggingSplit = false; selectedSplitHandle = null;
791
- });
792
 
793
- // ... (Existing Functions for Bubbles, Upload, etc. same as before) ...
794
-
795
- function createBubbleHTML(data) {
796
- const b = document.createElement('div');
797
- const type = data.type || 'speech';
798
- b.className = data.classes || `speech-bubble ${type} tail-bottom`;
799
- if (type === 'thought' && !b.className.includes('pos-')) b.className += ' pos-bl';
800
- b.dataset.type = type;
801
- b.style.left = data.left; b.style.top = data.top;
802
- if(data.transform) b.style.transform = data.transform;
803
- if(data.width) b.style.width = data.width; if(data.height) b.style.height = data.height;
804
- if(data.font) b.style.fontFamily = data.font;
805
- if(data.colors) { b.style.setProperty('--bubble-fill-color', data.colors.fill || '#4ECDC4'); b.style.setProperty('--bubble-text-color', data.colors.text || '#ffffff'); }
806
- if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
807
- const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || ''; b.appendChild(textSpan);
808
- if(type === 'thought') { for(let i=1; i<=2; i++){ const d = document.createElement('div'); d.className = `thought-dot thought-dot-${i}`; b.appendChild(d); } }
809
- ['nw', 'ne', 'sw', 'se'].forEach(dir => { const handle = document.createElement('div'); handle.className = `resize-handle ${dir}`; handle.onmousedown = (e) => startResize(e, dir); b.appendChild(handle); });
810
- b.onmousedown = (e) => {
811
- if(e.target.classList.contains('resize-handle')) return;
812
- e.stopPropagation(); selectBubble(b);
813
- isDragging = true; startX = e.clientX; startY = e.clientY; initX = b.offsetLeft; initY = b.offsetTop;
814
- if(b.style.transform.includes('translate')) { b.style.transform = 'none'; b.style.left = (b.offsetLeft - b.offsetWidth/2) + 'px'; b.style.top = (b.offsetTop - b.offsetHeight/2) + 'px'; initX = parseInt(b.style.left); initY = parseInt(b.style.top); }
815
- };
816
- b.onclick = (e) => { e.stopPropagation(); };
817
- b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
818
- return b;
819
- }
820
- function editBubbleText(bubble) {
821
- if (currentlyEditing) return; currentlyEditing = bubble;
822
- const textSpan = bubble.querySelector('.bubble-text'); const textarea = document.createElement('textarea');
823
- textarea.value = textSpan.textContent; bubble.appendChild(textarea); textSpan.style.display = 'none'; textarea.focus();
824
- const finishEditing = () => { textSpan.textContent = textarea.value; textarea.remove(); textSpan.style.display = ''; currentlyEditing = null; saveDraft(true); };
825
- textarea.addEventListener('blur', finishEditing, { once: true });
826
- textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); } });
827
- }
828
- function startResize(e, dir) { e.preventDefault(); e.stopPropagation(); isResizing = true; resizeHandle = dir; const rect = selectedBubble.getBoundingClientRect(); originalWidth = rect.width; originalHeight = rect.height; originalMouseX = e.clientX; originalMouseY = e.clientY; }
829
- function resizeBubble(e) { if (!isResizing || !selectedBubble) return; const dx = e.clientX - originalMouseX; const dy = e.clientY - originalMouseY; if(resizeHandle.includes('e')) selectedBubble.style.width = (originalWidth + dx)+'px'; if(resizeHandle.includes('s')) selectedBubble.style.height = (originalHeight + dy)+'px'; }
830
- function selectBubble(el) {
831
- if(selectedBubble) selectedBubble.classList.remove('selected'); if(selectedPanel) { selectedPanel.classList.remove('selected'); selectedPanel = null; }
832
- selectedBubble = el; el.classList.add('selected');
833
- document.getElementById('bubble-type-select').disabled = false; document.getElementById('font-select').disabled = false; document.getElementById('bubble-text-color').disabled = false; document.getElementById('bubble-fill-color').disabled = false; document.getElementById('tail-controls').style.display = 'block'; document.getElementById('bubble-type-select').value = el.dataset.type || 'speech';
834
- }
835
- function selectPanel(el) {
836
- if(selectedPanel) selectedPanel.classList.remove('selected'); if(selectedBubble) { selectedBubble.classList.remove('selected'); selectedBubble = null; }
837
- selectedPanel = el; el.classList.add('selected');
838
- document.getElementById('zoom-slider').disabled = false; const img = el.querySelector('img'); document.getElementById('zoom-slider').value = img.dataset.zoom || 100; document.getElementById('bubble-type-select').disabled = true; document.getElementById('font-select').disabled = true; document.getElementById('tail-controls').style.display = 'none';
839
- document.getElementById('fit-btn').innerText = img.classList.contains('fit-cover') ? "Fit: Cover" : "Fit: Contain";
840
- }
841
- function addBubble() { if(!selectedPanel) return alert("Select a panel first"); const b = createBubbleHTML({ text: "Text", left: "50%", top: "50%", type: 'speech', classes: "speech-bubble speech tail-bottom", transform: "translate(-50%, -50%)" }); selectedPanel.appendChild(b); selectBubble(b); saveDraft(true); }
842
- function deleteBubble() { if(!selectedBubble) return alert("Select a bubble"); selectedBubble.remove(); selectedBubble=null; saveDraft(true); }
843
- function changeBubbleType(type) { if(!selectedBubble) return; selectedBubble.dataset.type = type; selectedBubble.className = 'speech-bubble ' + type + ' selected'; if(type === 'thought') selectedBubble.classList.add('pos-bl'); else selectedBubble.classList.add('tail-bottom'); selectedBubble.querySelectorAll('.thought-dot').forEach(d=>d.remove()); if(type === 'thought') { for(let i=1; i<=2; i++){ const d = document.createElement('div'); d.className = `thought-dot thought-dot-${i}`; selectedBubble.appendChild(d); } } saveDraft(true); }
844
- function changeFont(font) { if(!selectedBubble) return; selectedBubble.style.fontFamily = font; saveDraft(true); }
845
- function rotateTail() { if(!selectedBubble) return; const type = selectedBubble.dataset.type; if(type === 'speech') { const positions = ['tail-bottom', 'tail-right', 'tail-top', 'tail-left']; let current = 0; positions.forEach((pos, i) => { if(selectedBubble.classList.contains(pos)) current = i; }); selectedBubble.classList.remove(positions[current]); selectedBubble.classList.add(positions[(current + 1) % 4]); } else if (type === 'thought') { const positions = ['pos-bl', 'pos-br', 'pos-tr', 'pos-tl']; let current = 0; positions.forEach((pos, i) => { if(selectedBubble.classList.contains(pos)) current = i; }); selectedBubble.classList.remove(positions[current]); selectedBubble.classList.add(positions[(current + 1) % 4]); } saveDraft(true); }
846
- function slideTail(v) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', v+'%'); saveDraft(true); } }
847
- document.getElementById('bubble-text-color').addEventListener('change', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-text-color', e.target.value); saveDraft(true); } });
848
- document.getElementById('bubble-fill-color').addEventListener('change', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-fill-color', e.target.value); saveDraft(true); } });
849
- function handleZoom(el) { if(!selectedPanel) return; const img = selectedPanel.querySelector('img'); img.dataset.zoom = el.value; updateImageTransform(img); }
850
- document.getElementById('zoom-slider').addEventListener('change', () => saveDraft(true));
851
- function handleWheelZoom(e, img) { e.preventDefault(); e.stopPropagation(); if(!selectedPanel) selectPanel(img.closest('.panel')); let scale = parseFloat(img.dataset.zoom) || 100; const delta = -Math.sign(e.deltaY) * 10; scale += delta; if(scale < 10) scale = 10; if(scale > 500) scale = 500; img.dataset.zoom = scale; document.getElementById('zoom-slider').value = scale; updateImageTransform(img); }
852
- function startPan(e, img) { e.preventDefault(); isPanning = true; selectedPanel = img.closest('.panel'); panStartX = e.clientX; panStartY = e.clientY; panStartTx = parseFloat(img.dataset.translateX) || 0; panStartTy = parseFloat(img.dataset.translateY) || 0; img.classList.add('panning'); }
853
- function panImage(e) { if(!isPanning || !selectedPanel) return; const img = selectedPanel.querySelector('img'); const dx = e.clientX - panStartX; const dy = e.clientY - panStartY; img.dataset.translateX = panStartTx + dx; img.dataset.translateY = panStartTy + dy; updateImageTransform(img); }
854
- function updateImageTransform(img) { const z = (img.dataset.zoom || 100) / 100; const x = img.dataset.translateX || 0; const y = img.dataset.translateY || 0; img.style.transform = `translateX(${x}px) translateY(${y}px) scale(${z})`; img.classList.toggle('pannable', true); }
855
- function resetPanelTransform() { if(!selectedPanel) return alert("Select a panel"); const img = selectedPanel.querySelector('img'); img.dataset.zoom = 100; img.dataset.translateX = 0; img.dataset.translateY = 0; document.getElementById('zoom-slider').value = 100; updateImageTransform(img); saveDraft(true); }
856
- function toggleFitMode() { if(!selectedPanel) return alert("Select a panel"); const img = selectedPanel.querySelector('img'); if(img.classList.contains('fit-cover')) { img.classList.remove('fit-cover'); document.getElementById('fit-btn').innerText = "Fit: Contain"; } else { img.classList.add('fit-cover'); document.getElementById('fit-btn').innerText = "Fit: Cover"; } saveDraft(true); }
857
- function replacePanelImage() { if(!selectedPanel) return alert("Select a panel"); const inp = document.getElementById('image-uploader'); inp.onchange = async (e) => { const fd = new FormData(); fd.append('image', e.target.files[0]); const img = selectedPanel.querySelector('img'); const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd}); const d = await r.json(); if(d.success) { img.src = `/frames/${d.new_filename}?sid=${sid}`; saveDraft(true); } inp.value = ''; }; inp.click(); }
858
- async function adjustFrame(dir) { if(isProcessing) return; if(!selectedPanel) return alert("Select a panel"); const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0]; setProcessing(true); img.style.opacity = '0.5'; try { const r = await fetch(`/regenerate_frame?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, direction:dir}) }); const d = await r.json(); if(d.success) { img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`; } else { alert('Error: ' + d.message); } } catch(e) { console.error(e); } img.style.opacity = '1'; setProcessing(false); saveDraft(true); }
859
- async function gotoTimestamp() { if(isProcessing) return; if(!selectedPanel) return alert("Select a panel"); let v = document.getElementById('timestamp-input').value.trim(); if(!v) return; if(v.includes(':')) { let p = v.split(':'); v = parseInt(p[0]) * 60 + parseFloat(p[1]); } else { v = parseFloat(v); } if(isNaN(v)) return alert("Invalid time"); const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0]; setProcessing(true); img.style.opacity = '0.5'; try { const r = await fetch(`/goto_timestamp?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, timestamp:v}) }); const d = await r.json(); if(d.success) { img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`; document.getElementById('timestamp-input').value = ''; } else { alert('Error: ' + d.message); } } catch(e) { console.error(e); } img.style.opacity = '1'; setProcessing(false); saveDraft(true); }
860
-
861
- async function upload() {
862
- const f = document.getElementById('file-upload').files[0];
863
- const pCount = document.getElementById('page-count').value;
864
- const panelCount = document.getElementById('panels-per-page').value;
865
- if(!f) return alert("Select a video");
866
- sid = genUUID(); localStorage.setItem('comic_sid', sid); currentSaveCode = null;
867
- document.querySelector('.upload-box').style.display='none';
868
- document.getElementById('loading-view').style.display='flex';
869
- const fd = new FormData(); fd.append('file', f); fd.append('target_pages', pCount); fd.append('panels_per_page', panelCount);
870
- const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
871
- if(r.ok) interval = setInterval(checkStatus, 2000);
872
- else { alert("Upload failed"); location.reload(); }
873
  }
874
- async function checkStatus() { try { const r = await fetch(`/status?sid=${sid}`); const d = await r.json(); document.getElementById('status-text').innerText = d.message; if(d.progress >= 100) { clearInterval(interval); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='block'; loadNewComic(); } else if (d.progress < 0) { clearInterval(interval); document.getElementById('status-text').textContent = "Error: " + d.message; document.querySelector('.loader').style.display = 'none'; } } catch(e) {} }
875
- function loadNewComic() { fetch(`/output/pages.json?sid=${sid}`).then(r=>r.json()).then(data => { const cleanData = data.map((p, pi) => ({ layout: 'layout-rows', panels: p.panels.map((pan, j) => ({ src: `/frames/${pan.image}?sid=${sid}`, fit: 'contain', bubbles: (p.bubbles && p.bubbles[j] && p.bubbles[j].dialog) ? [{ text: p.bubbles[j].dialog, left: (p.bubbles[j].bubble_offset_x > 0 ? p.bubbles[j].bubble_offset_x + 'px' : '50%'), top: (p.bubbles[j].bubble_offset_y > 0 ? p.bubbles[j].bubble_offset_y + 'px' : '50%'), type: (p.bubbles[j].type || 'speech'), classes: `speech-bubble ${p.bubbles[j].type || 'speech'} tail-bottom`, transform: (p.bubbles[j].bubble_offset_x > 0 ? 'none' : 'translate(-50%, -50%)') }] : [] })) })); renderFromState(cleanData); saveDraft(true); }); }
876
- async function exportComic() { const pgs = document.querySelectorAll('.comic-page'); if(pgs.length === 0) return alert("No pages found"); if(selectedBubble) selectedBubble.classList.remove('selected'); if(selectedPanel) selectedPanel.classList.remove('selected'); document.querySelectorAll('.split-handle').forEach(h => h.style.display = 'none'); alert(`Exporting ${pgs.length} page(s)...`); const bubbles = document.querySelectorAll('.speech-bubble'); bubbles.forEach(b => { const rect = b.getBoundingClientRect(); b.style.width = (rect.width + 1) + 'px'; b.style.height = rect.height + 'px'; b.style.display = 'flex'; b.style.alignItems = 'center'; b.style.justifyContent = 'center'; }); for(let i = 0; i < pgs.length; i++) { try { const u = await htmlToImage.toPng(pgs[i], { pixelRatio: 2, style: { transform: 'none' } }); const a = document.createElement('a'); a.href = u; a.download = `Comic-Page-${i+1}.png`; a.click(); } catch(err) { console.error(err); alert(`Failed to export page ${i+1}`); } } document.querySelectorAll('.split-handle').forEach(h => h.style.display = ''); }
877
- function goBackToUpload() { if(confirm('Go home? Unsaved changes will be lost.')) { document.getElementById('editor-container').style.display = 'none'; document.getElementById('upload-container').style.display = 'flex'; document.getElementById('loading-view').style.display = 'none'; } }
878
  </script>
879
- </body> </html> '''
 
880
 
881
  @app.route('/')
882
- def index():
883
- return INDEX_HTML
884
 
885
  @app.route('/uploader', methods=['POST'])
886
  def upload():
887
  sid = request.args.get('sid')
888
- if not sid: return jsonify({'success': False, 'message': 'Missing session ID'}), 400
889
- if 'file' not in request.files or not request.files['file'].filename:
890
- return jsonify({'success': False, 'message': 'No file selected'}), 400
891
-
892
- # GET PAGE COUNT FROM FORM
893
- target_pages = request.form.get('target_pages', 4)
894
- # GET PANEL COUNT FROM FORM (Default to 2)
895
- panels_per_page = request.form.get('panels_per_page', 2)
896
-
897
  f = request.files['file']
898
  gen = EnhancedComicGenerator(sid)
899
- gen.cleanup()
900
  f.save(gen.video_path)
901
- gen.write_status("Starting...", 5)
902
-
903
- # Run in thread
904
- threading.Thread(target=gen.run, args=(target_pages, panels_per_page)).start()
905
- return jsonify({'success': True, 'message': 'Generation started.'})
906
 
907
  @app.route('/status')
908
  def get_status():
909
  sid = request.args.get('sid')
910
- path = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
911
- if os.path.exists(path): return send_file(path)
912
- return jsonify({'progress': 0, 'message': "Waiting..."})
913
 
914
  @app.route('/output/<path:filename>')
915
  def get_output(filename):
@@ -921,84 +419,5 @@ def get_frame(filename):
921
  sid = request.args.get('sid')
922
  return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
923
 
924
- @app.route('/regenerate_frame', methods=['POST'])
925
- def regen():
926
- sid = request.args.get('sid')
927
- d = request.get_json()
928
- gen = EnhancedComicGenerator(sid)
929
- return jsonify(regen_frame_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], d['direction']))
930
-
931
- @app.route('/goto_timestamp', methods=['POST'])
932
- def go_time():
933
- sid = request.args.get('sid')
934
- d = request.get_json()
935
- gen = EnhancedComicGenerator(sid)
936
- return jsonify(get_frame_at_ts_gpu(gen.video_path, gen.frames_dir, gen.metadata_path, d['filename'], float(d['timestamp'])))
937
-
938
- @app.route('/replace_panel', methods=['POST'])
939
- def rep_panel():
940
- sid = request.args.get('sid')
941
- if 'image' not in request.files: return jsonify({'success': False, 'error': 'No image'})
942
- f = request.files['image']
943
- frames_dir = os.path.join(BASE_USER_DIR, sid, 'frames')
944
- os.makedirs(frames_dir, exist_ok=True)
945
- fname = f"replaced_{int(time.time() * 1000)}.png"
946
- f.save(os.path.join(frames_dir, fname))
947
- return jsonify({'success': True, 'new_filename': fname})
948
-
949
- @app.route('/save_comic', methods=['POST'])
950
- def save_comic():
951
- sid = request.args.get('sid')
952
- try:
953
- data = request.get_json()
954
- save_code = generate_save_code()
955
- save_dir = os.path.join(SAVED_COMICS_DIR, save_code)
956
- os.makedirs(save_dir, exist_ok=True)
957
-
958
- user_frames_dir = os.path.join(BASE_USER_DIR, sid, 'frames')
959
- saved_frames_dir = os.path.join(save_dir, 'frames')
960
-
961
- if os.path.exists(user_frames_dir):
962
- if os.path.exists(saved_frames_dir): shutil.rmtree(saved_frames_dir)
963
- shutil.copytree(user_frames_dir, saved_frames_dir)
964
-
965
- save_data = {
966
- 'code': save_code,
967
- 'originalSid': sid,
968
- 'pages': data.get('pages', []),
969
- 'savedAt': data.get('savedAt', time.strftime('%Y-%m-%d %H:%M:%S'))
970
- }
971
- with open(os.path.join(save_dir, 'comic_state.json'), 'w') as f: json.dump(save_data, f, indent=2)
972
- return jsonify({'success': True, 'code': save_code})
973
- except Exception as e:
974
- traceback.print_exc()
975
- return jsonify({'success': False, 'message': str(e)})
976
-
977
- @app.route('/load_comic/<code>')
978
- def load_comic(code):
979
- code = code.upper()
980
- save_dir = os.path.join(SAVED_COMICS_DIR, code)
981
- state_file = os.path.join(save_dir, 'comic_state.json')
982
-
983
- if not os.path.exists(state_file): return jsonify({'success': False, 'message': 'Save code not found'})
984
-
985
- try:
986
- with open(state_file, 'r') as f: save_data = json.load(f)
987
- original_sid = save_data.get('originalSid')
988
- saved_frames_dir = os.path.join(save_dir, 'frames')
989
- if original_sid and os.path.exists(saved_frames_dir):
990
- user_frames_dir = os.path.join(BASE_USER_DIR, original_sid, 'frames')
991
- os.makedirs(user_frames_dir, exist_ok=True)
992
- for fname in os.listdir(saved_frames_dir):
993
- src = os.path.join(saved_frames_dir, fname)
994
- dst = os.path.join(user_frames_dir, fname)
995
- if not os.path.exists(dst): shutil.copy2(src, dst)
996
- return jsonify({ 'success': True, 'pages': save_data.get('pages', []), 'originalSid': original_sid, 'savedAt': save_data.get('savedAt') })
997
- except Exception as e:
998
- traceback.print_exc()
999
- return jsonify({'success': False, 'message': str(e)})
1000
-
1001
  if __name__ == '__main__':
1002
- try: gpu_warmup()
1003
- except: pass
1004
  app.run(host='0.0.0.0', port=7860)
 
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")
 
81
  # ======================================================
82
  @spaces.GPU(duration=300)
83
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages, panels_per_page_req):
 
 
84
  import cv2
85
  import srt
86
  import numpy as np
87
+
 
 
 
 
 
 
88
  cap = cv2.VideoCapture(video_path)
89
  if not cap.isOpened(): raise Exception("Cannot open video")
90
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
 
93
  cap.release()
94
 
95
  user_srt = os.path.join(user_dir, 'subs.srt')
96
+ # Simple SRT parsing logic
97
+ with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
 
 
 
 
98
 
99
  with open(user_srt, 'r', encoding='utf-8') as f:
100
  try: all_subs = list(srt.parse(f.read()))
 
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
+ target_pages = int(target_pages)
107
  panels_per_page = int(panels_per_page_req)
 
 
108
  total_panels_needed = target_pages * panels_per_page
109
 
110
  selected_moments = []
111
  if not raw_moments:
112
+ times = np.linspace(1, max(1, duration-1), total_panels_needed)
113
  for t in times: selected_moments.append({'text': '', 'start': t, 'end': t+1})
 
 
114
  else:
115
  indices = np.linspace(0, len(raw_moments) - 1, total_panels_needed, dtype=int)
116
  selected_moments = [raw_moments[i] for i in indices]
 
122
 
123
  for i, moment in enumerate(selected_moments):
124
  mid = (moment['start'] + moment['end']) / 2
 
125
  cap.set(cv2.CAP_PROP_POS_FRAMES, int(mid * fps))
126
  ret, frame = cap.read()
127
  if ret:
128
  fname = f"frame_{count:04d}.png"
129
  p = os.path.join(frames_dir, fname)
130
  cv2.imwrite(p, frame)
 
131
  frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
132
  frame_files_ordered.append(fname)
133
  count += 1
 
135
 
136
  with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  bubbles_list = []
139
  for f in frame_files_ordered:
 
140
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
141
+ bubbles_list.append(bubble(dialog=dialogue, type='speech'))
 
 
 
 
 
 
142
 
143
  pages = []
144
  for i in range(target_pages):
 
148
  p_bubbles = bubbles_list[start_idx:end_idx]
149
  if p_frames:
150
  pg_panels = [panel(image=f) for f in p_frames]
151
+ pages.append({'panels': pg_panels, 'bubbles': p_bubbles})
 
 
 
 
 
 
152
 
153
+ return pages
154
 
155
  @spaces.GPU
156
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
157
  import cv2
158
  import json
 
 
 
159
  with open(metadata_path, 'r') as f: meta = json.load(f)
160
+ t = meta[fname]['time']
 
 
161
  cap = cv2.VideoCapture(video_path)
162
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
163
  offset = (1.0/fps) * (1 if direction == 'forward' else -1)
164
  new_t = max(0, t + offset)
 
165
  cap.set(cv2.CAP_PROP_POS_MSEC, new_t * 1000)
166
  ret, frame = cap.read()
167
  cap.release()
 
168
  if ret:
169
+ cv2.imwrite(os.path.join(frames_dir, fname), frame)
170
+ meta[fname]['time'] = new_t
 
 
 
 
 
 
171
  with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
172
+ return {"success": True}
173
+ return {"success": False}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
  # ======================================================
176
  # 💻 BACKEND CLASS
 
186
  os.makedirs(self.output_dir, exist_ok=True)
187
  self.metadata_path = os.path.join(self.frames_dir, 'frame_metadata.json')
188
 
 
 
 
 
 
 
189
  def run(self, target_pages, panels_per_page):
190
  try:
191
+ data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, target_pages, panels_per_page)
192
+ with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f: json.dump(data, f, indent=2)
 
 
193
  self.write_status("Complete!", 100)
194
  except Exception as e:
 
195
  self.write_status(f"Error: {str(e)}", -1)
196
 
197
  def write_status(self, msg, prog):
 
202
  # 🌐 ROUTES & FULL UI
203
  # ======================================================
204
  INDEX_HTML = '''
205
+ <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>🎬 Vertical Tilt Comic Gen</title> <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script> <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Lato&display=swap" rel="stylesheet"> <style>
206
+ * { box-sizing: border-box; }
207
+ body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; margin: 0; }
208
 
209
+ #upload-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px; }
210
  .upload-box { max-width: 500px; width: 100%; padding: 40px; background: white; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.12); text-align: center; }
211
 
212
+ #editor-container { display: none; padding: 20px; padding-bottom: 100px; }
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  .comic-page {
215
+ background: white; width: 864px; height: 1080px; margin: 20px auto;
216
+ box-shadow: 0 4px 10px rgba(0,0,0,0.1); position: relative; overflow: hidden; border: 4px solid #000; padding: 10px;
 
 
 
 
 
 
217
  }
218
 
219
+ /* === THE VERTICAL TILT GRID === */
220
+ .comic-grid { width: 100%; height: 100%; position: relative; background: #000; }
221
+ .panel { position: absolute; top:0; left:0; width: 100%; height: 100%; overflow: hidden; cursor: pointer; }
 
 
222
 
223
+ /* Left Panel (1) */
224
+ .layout-custom-slant .panel:nth-child(1) {
225
+ z-index: 2;
226
+ clip-path: polygon(0 0, var(--split-t, 45%) 0, var(--split-b, 55%) 100%, 0 100%);
 
 
227
  }
228
 
229
+ /* Right Panel (2) */
230
+ .layout-custom-slant .panel:nth-child(2) {
231
+ z-index: 1;
232
+ clip-path: polygon(var(--split-t, 45%) 0, 100% 0, 100% 100%, var(--split-b, 55%) 100%);
 
233
  }
234
 
235
+ .panel.selected { outline: 4px solid #2196F3; z-index: 10; }
236
 
237
+ /* VERTICAL SPLIT HANDLES (Top and Bottom) */
238
  .split-handle {
239
+ position: absolute; width: 28px; height: 28px;
240
+ background: #FF5722; border: 3px solid white; border-radius: 50%;
241
+ cursor: ew-resize; z-index: 1000; box-shadow: 0 2px 8px rgba(0,0,0,0.4);
 
 
 
 
 
 
 
 
 
 
 
 
242
  }
243
+ .split-handle.top { top: -14px; left: var(--split-t, 45%); transform: translateX(-50%); }
244
+ .split-handle.bottom { bottom: -14px; left: var(--split-b, 55%); transform: translateX(-50%); }
245
+
246
+ .panel img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.1s; pointer-events: none; }
247
 
248
+ /* BUBBLES */
249
  .speech-bubble {
250
+ position: absolute; width: 160px; height: 90px; background: #4ECDC4; color: white;
251
+ border-radius: 1.2em; display: flex; align-items: center; justify-content: center;
252
+ padding: 10px; font-family: 'Comic Neue', cursive; font-weight: bold; font-size: 14px;
253
+ text-align: center; cursor: move; z-index: 50; transform: translate(-50%, -50%);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  }
255
+ .speech-bubble.selected { outline: 3px dashed #FFEB3B; }
256
+
257
+ .edit-controls { position: fixed; bottom: 20px; right: 20px; width: 240px; background: #2c3e50; color: white; padding: 15px; border-radius: 10px; z-index: 999; }
258
+ .edit-controls button { width: 100%; margin: 5px 0; padding: 8px; font-weight: bold; cursor: pointer; border-radius: 4px; border: none; }
259
+ .action-btn { background: #27ae60; color: white; }
260
+ .delete-btn { background: #c0392b; color: white; }
261
+ </style> </head> <body>
262
+
263
+ <div id="upload-container">
264
+ <div class="upload-box">
265
+ <h1>🎬 Vertical Comic Gen</h1>
266
+ <input type="file" id="file-upload" style="display:none;" onchange="document.getElementById('fn').innerText=this.files[0].name">
267
+ <button onclick="document.getElementById('file-upload').click()" class="action-btn">📁 Choose Video</button>
268
+ <p id="fn">No file selected</p>
269
+ <button class="action-btn" onclick="upload()" style="background:#e67e22">🚀 Generate</button>
270
+ <div id="loading" style="display:none;">
271
+ <p id="status-text">Processing...</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  </div>
273
  </div>
274
  </div>
275
+
276
  <div id="editor-container">
277
+ <div id="comic-container"></div>
 
278
  <div class="edit-controls">
279
+ <h4>Panel Editor</h4>
280
+ <button onclick="addBubble()" class="action-btn">💬 Add Bubble</button>
281
+ <button onclick="deleteBubble()" class="delete-btn">🗑️ Delete Bubble</button>
282
+ <button onclick="exportComic()" style="background:#3498db; color:white;">📥 Export PNG</button>
283
+ <button onclick="location.reload()" class="delete-btn">🏠 Home</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  </div>
285
  </div>
286
+
287
  <script>
288
+ let sid = 'S' + Math.floor(Math.random()*1000000);
289
+ let selectedPanel = null, selectedBubble = null, selectedSplitHandle = null;
290
+ let isDraggingBubble = false, isDraggingSplit = false;
291
+
292
+ async function upload() {
293
+ const f = document.getElementById('file-upload').files[0];
294
+ if(!f) return alert("Select a video");
295
+ document.getElementById('loading').style.display='block';
296
+ const fd = new FormData(); fd.append('file', f); fd.append('target_pages', 2); fd.append('panels_per_page', 2);
297
+ await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
298
+ const timer = setInterval(async () => {
299
+ const r = await fetch(`/status?sid=${sid}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  const d = await r.json();
301
+ document.getElementById('status-text').innerText = d.message;
302
+ if(d.progress >= 100) { clearInterval(timer); loadComic(); }
303
+ }, 2000);
 
 
 
 
 
 
 
 
 
 
 
304
  }
305
+
306
+ async function loadComic() {
307
+ const r = await fetch(`/output/pages.json?sid=${sid}`);
308
+ const pages = await r.json();
309
+ const container = document.getElementById('comic-container');
310
+ container.innerHTML = '';
311
+
312
+ pages.forEach(pData => {
313
+ const pageDiv = document.createElement('div');
314
+ pageDiv.className = 'comic-page';
315
+ const grid = document.createElement('div');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  grid.className = 'comic-grid layout-custom-slant';
317
+ grid.style.setProperty('--split-t', '45%');
318
+ grid.style.setProperty('--split-b', '55%');
319
+
320
+ // VERTICAL SPLIT HANDLES
321
+ const hT = document.createElement('div'); hT.className = 'split-handle top';
322
+ hT.onmousedown = (e) => { isDraggingSplit = true; selectedSplitHandle = { grid, side: 'top' }; };
323
+ const hB = document.createElement('div'); hB.className = 'split-handle bottom';
324
+ hB.onmousedown = (e) => { isDraggingSplit = true; selectedSplitHandle = { grid, side: 'bottom' }; };
325
 
326
+ grid.appendChild(hT); grid.appendChild(hB);
327
+
328
+ pData.panels.forEach(pan => {
329
+ const pDiv = document.createElement('div');
330
+ pDiv.className = 'panel';
331
+ pDiv.innerHTML = `<img src="/frames/${pan.image}?sid=${sid}">`;
 
 
 
332
  pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
 
 
 
 
 
 
 
 
 
 
 
 
333
  grid.appendChild(pDiv);
334
  });
335
+ pageDiv.appendChild(grid);
336
+ container.appendChild(pageDiv);
337
  });
338
+ document.getElementById('upload-container').style.display='none';
339
+ document.getElementById('editor-container').style.display='block';
340
  }
341
+
342
+ function selectPanel(el) {
343
+ if(selectedPanel) selectedPanel.classList.remove('selected');
344
+ selectedPanel = el;
345
+ selectedPanel.classList.add('selected');
 
346
  }
347
+
348
+ function addBubble() {
349
+ if(!selectedPanel) return alert("Select a panel");
350
+ const b = document.createElement('div');
351
+ b.className = 'speech-bubble';
352
+ b.innerText = "New Dialogue";
353
+ b.style.left = '50%'; b.style.top = '50%';
354
+ b.onmousedown = (e) => { e.stopPropagation(); selectedBubble = b; isDraggingBubble = true; selectBubble(b); };
355
+ selectedPanel.appendChild(b);
356
+ }
357
+
358
+ function selectBubble(el) {
359
+ document.querySelectorAll('.speech-bubble').forEach(x => x.classList.remove('selected'));
360
+ el.classList.add('selected');
361
+ selectedBubble = el;
362
+ }
363
+
364
+ function deleteBubble() { if(selectedBubble) selectedBubble.remove(); }
365
+
366
+ document.addEventListener('mousemove', (e) => {
367
+ if(isDraggingBubble && selectedBubble) {
368
+ const rect = selectedBubble.parentElement.getBoundingClientRect();
369
+ selectedBubble.style.left = ((e.clientX - rect.left) / rect.width * 100) + '%';
370
+ selectedBubble.style.top = ((e.clientY - rect.top) / rect.height * 100) + '%';
371
+ }
372
  if(isDraggingSplit && selectedSplitHandle) {
373
  const rect = selectedSplitHandle.grid.getBoundingClientRect();
374
+ let percent = ((e.clientX - rect.left) / rect.width * 100);
375
+ percent = Math.max(10, Math.min(90, percent));
376
+ if(selectedSplitHandle.side === 'top') selectedSplitHandle.grid.style.setProperty('--split-t', percent + '%');
377
+ else selectedSplitHandle.grid.style.setProperty('--split-b', percent + '%');
 
 
 
 
 
 
378
  }
379
  });
 
 
 
 
 
 
 
380
 
381
+ document.addEventListener('mouseup', () => { isDraggingBubble = false; isDraggingSplit = false; });
382
+
383
+ async function exportComic() {
384
+ const pages = document.querySelectorAll('.comic-page');
385
+ for(let i=0; i<pages.length; i++) {
386
+ const imgData = await htmlToImage.toPng(pages[i]);
387
+ const link = document.createElement('a');
388
+ link.download = `page-${i+1}.png`; link.href = imgData; link.click();
389
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  }
 
 
 
 
391
  </script>
392
+ </body> </html>
393
+ '''
394
 
395
  @app.route('/')
396
+ def index(): return INDEX_HTML
 
397
 
398
  @app.route('/uploader', methods=['POST'])
399
  def upload():
400
  sid = request.args.get('sid')
 
 
 
 
 
 
 
 
 
401
  f = request.files['file']
402
  gen = EnhancedComicGenerator(sid)
 
403
  f.save(gen.video_path)
404
+ threading.Thread(target=gen.run, args=(request.form.get('target_pages', 2), request.form.get('panels_per_page', 2))).start()
405
+ return jsonify({'success': True})
 
 
 
406
 
407
  @app.route('/status')
408
  def get_status():
409
  sid = request.args.get('sid')
410
+ return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), 'status.json')
 
 
411
 
412
  @app.route('/output/<path:filename>')
413
  def get_output(filename):
 
419
  sid = request.args.get('sid')
420
  return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
421
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  if __name__ == '__main__':
 
 
423
  app.run(host='0.0.0.0', port=7860)