tester343 commited on
Commit
321a3e9
·
verified ·
1 Parent(s): cf1baae

Update app_enhanced.py

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