tester343 commited on
Commit
78d0f0f
·
verified ·
1 Parent(s): fee3c0a

Update app_enhanced.py

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