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

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +166 -497
app_enhanced.py CHANGED
@@ -9,7 +9,6 @@ import string
9
  import random
10
  import shutil
11
  import cv2
12
- import math
13
  import numpy as np
14
  import srt
15
  from flask import Flask, jsonify, request, send_from_directory, send_file
@@ -23,592 +22,264 @@ def gpu_warmup():
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">
429
- </div>
430
- <div class="page-input-group" style="margin:5px 0;">
431
- <label>📄 Total Pages:</label>
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):
@@ -621,6 +292,4 @@ def get_frame(filename):
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)
 
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
 
22
  print(f"✅ ZeroGPU Warmup: CUDA Available: {torch.cuda.is_available()}")
23
  return True
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  # ======================================================
26
  # 🧠 GLOBAL GPU FUNCTIONS
27
  # ======================================================
28
  @spaces.GPU(duration=300)
29
+ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
 
 
30
  import cv2
 
31
  import numpy as np
32
+
 
 
 
 
 
 
33
  cap = cv2.VideoCapture(video_path)
34
  if not cap.isOpened(): raise Exception("Cannot open video")
35
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
36
+ duration = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) / fps
 
37
  cap.release()
38
 
39
+ # We force 2 panels per page for the vertical slant layout
40
+ panels_per_page = 2
41
+ total_panels_needed = int(target_pages) * panels_per_page
42
+ times = np.linspace(1, max(1, duration - 1), total_panels_needed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  frame_metadata = {}
 
 
45
  frame_files_ordered = []
46
+ cap = cv2.VideoCapture(video_path)
47
+ for i, t in enumerate(times):
48
+ cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000)
 
 
49
  ret, frame = cap.read()
50
  if ret:
51
+ fname = f"frame_{i:04d}.png"
52
  p = os.path.join(frames_dir, fname)
53
  cv2.imwrite(p, frame)
54
+ frame_metadata[fname] = {'dialogue': f"Moment {i+1}", 'time': t}
 
55
  frame_files_ordered.append(fname)
 
56
  cap.release()
57
 
58
+ with open(metadata_path, 'w') as f: json.dump(frame_metadata, f)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
  pages = []
61
+ for i in range(int(target_pages)):
62
+ start_idx = i * 2
63
+ p_frames = frame_files_ordered[start_idx:start_idx+2]
64
+ if len(p_frames) == 2:
65
+ pg_panels = [{'image': f} for f in p_frames]
66
+ pg_bubbles = [{'dialog': frame_metadata[f]['dialogue']} for f in p_frames]
67
+ pages.append({'panels': pg_panels, 'bubbles': pg_bubbles})
68
+ return pages
 
 
 
 
 
 
 
 
69
 
70
  @spaces.GPU
71
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
72
  import cv2
73
  import json
 
 
 
74
  with open(metadata_path, 'r') as f: meta = json.load(f)
75
+ t = meta[fname]['time']
 
 
76
  cap = cv2.VideoCapture(video_path)
77
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
78
+ new_t = max(0, t + (1.0/fps * (1 if direction == 'forward' else -1)))
 
 
79
  cap.set(cv2.CAP_PROP_POS_MSEC, new_t * 1000)
80
  ret, frame = cap.read()
81
  cap.release()
 
82
  if ret:
83
+ cv2.imwrite(os.path.join(frames_dir, fname), frame)
84
+ meta[fname]['time'] = new_t
85
+ with open(metadata_path, 'w') as f: json.dump(meta, f)
86
+ return {"success": True}
87
+ return {"success": False}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
  # ======================================================
90
+ # 💾 STORAGE SETUP
91
  # ======================================================
92
+ BASE_STORAGE_PATH = '/data' if os.path.exists('/data') else '.'
93
+ BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
94
+ os.makedirs(BASE_USER_DIR, exist_ok=True)
95
+
96
+ app = Flask(__name__)
97
+
98
+ class ComicBackend:
99
  def __init__(self, sid):
100
  self.sid = sid
101
+ self.dir = os.path.join(BASE_USER_DIR, sid)
102
+ self.v_path = os.path.join(self.dir, 'uploaded.mp4')
103
+ self.f_dir = os.path.join(self.dir, 'frames')
104
+ self.o_dir = os.path.join(self.dir, 'output')
105
+ for d in [self.f_dir, self.o_dir]: os.makedirs(d, exist_ok=True)
106
+ self.meta_p = os.path.join(self.f_dir, 'metadata.json')
 
107
 
108
+ def write_status(self, msg, prog):
109
+ with open(os.path.join(self.o_dir, 'status.json'), 'w') as f:
110
+ json.dump({'message': msg, 'progress': prog}, f)
 
 
111
 
112
+ def run(self, target_pages):
113
  try:
114
+ self.write_status("Running ZeroGPU Extraction...", 20)
115
+ data = generate_comic_gpu(self.v_path, self.dir, self.f_dir, self.meta_p, target_pages)
116
+ with open(os.path.join(self.o_dir, 'pages.json'), 'w') as f: json.dump(data, f)
 
117
  self.write_status("Complete!", 100)
118
  except Exception as e:
 
119
  self.write_status(f"Error: {str(e)}", -1)
120
 
 
 
 
 
121
  # ======================================================
122
+ # 🌐 UI & TEMPLATE (Vertical Tilt Feature)
123
  # ======================================================
124
  INDEX_HTML = '''
125
+ <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <title>🎬 Slanted Divider Comic Gen</title> <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script> <link href="https://fonts.googleapis.com/css2?family=Comic+Neue:wght@700&family=Lato&display=swap" rel="stylesheet"> <style>
126
+ body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; margin: 0; padding: 20px; }
127
+ #upload-container { display: flex; justify-content: center; align-items: center; min-height: 80vh; }
128
+ .upload-box { background: white; padding: 40px; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.1); text-align: center; width: 400px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
+ /* COMIC PAGE - VERTICAL SLANT */
131
  .comic-page {
132
+ background: white; width: 864px; height: 1080px; margin: 20px auto;
133
+ position: relative; overflow: hidden; border: 8px solid #000; box-shadow: 0 10px 30px rgba(0,0,0,0.2);
 
 
 
 
 
 
134
  }
135
+ .comic-grid { width: 100%; height: 100%; position: relative; background: #000; }
136
+ .panel { position: absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; }
137
 
138
+ /* THE TILT LOGIC */
139
+ /* Panel 1 is clipped to show the left side */
140
+ .panel:nth-child(1) {
141
+ z-index: 2;
 
 
 
 
 
 
142
  clip-path: polygon(0 0, var(--split-t, 45%) 0, var(--split-b, 55%) 100%, 0 100%);
143
  }
144
+ /* Panel 2 is behind and clipped to show the right side */
145
+ .panel:nth-child(2) {
146
+ z-index: 1;
 
 
147
  clip-path: polygon(var(--split-t, 45%) 0, 100% 0, 100% 100%, var(--split-b, 55%) 100%);
148
  }
149
 
150
+ .panel img { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
151
 
152
+ /* DRAGGABLE HANDLES ON THE EDGES */
153
  .split-handle {
154
+ position: absolute; width: 34px; height: 34px;
155
+ background: #FF9800; border: 4px solid white; border-radius: 50%;
156
+ cursor: ew-resize; z-index: 100; box-shadow: 0 4px 10px rgba(0,0,0,0.4);
 
 
 
 
 
 
 
 
 
 
 
157
  }
158
+ /* Top edge handle */
159
+ .split-handle.top { top: -17px; left: var(--split-t, 45%); transform: translateX(-50%); }
160
+ /* Bottom edge handle */
161
+ .split-handle.bottom { bottom: -17px; left: var(--split-b, 55%); transform: translateX(-50%); }
162
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  /* CONTROLS */
164
+ .edit-controls { position: fixed; right: 20px; top: 20px; width: 220px; background: #2c3e50; color: white; padding: 15px; border-radius: 10px; z-index: 999; }
165
+ button { width: 100%; padding: 10px; margin: 5px 0; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
166
+ .btn-gen { background: #27ae60; color: white; }
167
+ .btn-exp { background: #2980b9; color: white; }
168
+ #status { color: #f1c40f; font-family: monospace; font-size: 12px; margin-top: 10px; }
169
+ </style> </head> <body>
170
+
171
+ <div id="upload-container">
172
+ <div class="upload-box">
173
+ <h1>🎬 Vertical Slant Gen</h1>
174
+ <input type="file" id="vid-file" style="display:none" onchange="document.getElementById('fn').innerText=this.files[0].name">
175
+ <button onclick="document.getElementById('vid-file').click()" class="btn-gen">📁 Choose Video</button>
176
+ <p id="fn">No file selected</p>
177
+ <button class="btn-gen" onclick="upload()" style="background:#e67e22">🚀 Generate Comic</button>
178
+ <div id="status">Ready.</div>
 
 
 
 
 
 
 
 
 
 
 
 
179
  </div>
180
  </div>
181
+
182
+ <div id="editor-container" style="display:none">
183
+ <div id="comic-list"></div>
184
  <div class="edit-controls">
185
+ <h4>Panel Editor</h4>
186
+ <p style="font-size: 11px; opacity: 0.8;">Drag the <b style="color:#FF9800">orange circles</b> at the top and bottom to tilt the middle line.</p>
187
+ <button class="btn-exp" onclick="exportPNG()">📥 Export PNG</button>
188
+ <button onclick="location.reload()" style="background:#c0392b; color:white;">Reset</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  </div>
190
  </div>
191
 
192
  <script>
193
  let sid = 'S' + Math.floor(Math.random()*1000000);
194
+ let isDragging = false, currentHandle = null;
195
+
 
 
 
196
  async function upload() {
197
+ const file = document.getElementById('vid-file').files[0];
198
+ if(!file) return alert("Select a video");
199
+ const fd = new FormData(); fd.append('file', file);
200
+ document.getElementById('status').innerText = "Uploading to ZeroGPU...";
201
+ await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
202
+
203
+ const timer = setInterval(async () => {
204
+ const r = await fetch(`/status?sid=${sid}`);
205
+ const d = await r.json();
206
+ document.getElementById('status').innerText = d.message;
207
+ if(d.progress >= 100) { clearInterval(timer); loadEditor(); }
208
+ }, 2000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  }
210
+
211
+ async function loadEditor() {
212
+ const r = await fetch(`/output/pages.json?sid=${sid}`);
213
+ const pages = await r.json();
214
+ const list = document.getElementById('comic-list');
215
+ pages.forEach(p => {
216
+ const pgDiv = document.createElement('div');
217
+ pgDiv.className = 'comic-page';
218
+ const grid = document.createElement('div');
219
+ grid.className = 'comic-grid';
220
+ grid.style.setProperty('--split-t', '45%');
221
+ grid.style.setProperty('--split-b', '55%');
222
+
 
223
  const hTop = document.createElement('div'); hTop.className = 'split-handle top';
 
224
  const hBot = document.createElement('div'); hBot.className = 'split-handle bottom';
 
 
 
225
 
226
+ hTop.onmousedown = (e) => { isDragging = true; currentHandle = { grid, key: '--split-t' }; };
227
+ hBot.onmousedown = (e) => { isDragging = true; currentHandle = { grid, key: '--split-b' }; };
228
+
229
+ p.panels.forEach(pan => {
230
+ const div = document.createElement('div');
231
+ div.className = 'panel';
232
+ div.innerHTML = `<img src="/frames/${pan.image}?sid=${sid}">`;
233
+ grid.appendChild(div);
234
  });
235
+
236
+ grid.appendChild(hTop); grid.appendChild(hBot);
237
+ pgDiv.appendChild(grid);
238
+ list.appendChild(pgDiv);
239
  });
240
+ document.getElementById('upload-container').style.display='none';
241
+ document.getElementById('editor-container').style.display='block';
242
  }
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
+ window.onmousemove = (e) => {
245
+ if(!isDragging || !currentHandle) return;
246
+ const rect = currentHandle.grid.getBoundingClientRect();
247
+ let x = ((e.clientX - rect.left) / rect.width) * 100;
248
+ x = Math.max(5, Math.min(95, x)); // Constraints
249
+ currentHandle.grid.style.setProperty(currentHandle.key, x + '%');
250
+ };
251
+ window.onmouseup = () => isDragging = false;
252
+
253
+ async function exportPNG() {
254
+ const pgs = document.querySelectorAll('.comic-page');
255
+ for(let i=0; i<pgs.length; i++){
256
+ const url = await htmlToImage.toPng(pgs[i]);
257
+ const a = document.createElement('a');
258
+ a.download = `comic-page-${i+1}.png`; a.href = url; a.click();
259
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  }
261
+ </script> </body> </html>
262
+ '''
 
 
263
 
264
  @app.route('/')
265
+ def index(): return INDEX_HTML
 
266
 
267
  @app.route('/uploader', methods=['POST'])
268
+ def uploader():
269
  sid = request.args.get('sid')
 
 
270
  f = request.files['file']
271
+ gen = ComicBackend(sid)
272
+ f.save(gen.v_path)
273
+ threading.Thread(target=gen.run, args=(2,)).start()
274
+ return jsonify({'ok': True})
275
 
276
  @app.route('/status')
277
  def get_status():
278
  sid = request.args.get('sid')
279
+ try:
280
+ with open(os.path.join(BASE_USER_DIR, sid, 'output', 'status.json'), 'r') as f:
281
+ return f.read()
282
+ except: return jsonify({'message': 'Starting...', 'progress': 0})
283
 
284
  @app.route('/output/<path:filename>')
285
  def get_output(filename):
 
292
  return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
293
 
294
  if __name__ == '__main__':
 
 
295
  app.run(host='0.0.0.0', port=7860)