tester343 commited on
Commit
109e7d7
·
verified ·
1 Parent(s): 5b5836d

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +306 -367
app_enhanced.py CHANGED
@@ -1,123 +1,144 @@
1
- import spaces # <--- CRITICAL: MUST BE THE FIRST IMPORT
2
  import os
3
- import time
4
  import threading
5
  import json
6
  import traceback
7
- import logging
8
- 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
16
 
17
  # ======================================================
18
- # 🚀 ZEROGPU CONFIGURATION
19
  # ======================================================
20
- @spaces.GPU
21
- def gpu_warmup():
22
- import torch
23
- print(f"✅ ZeroGPU Warmup: CUDA Available: {torch.cuda.is_available()}")
24
- return True
25
 
26
- # ======================================================
27
- # 💾 PERSISTENT STORAGE CONFIGURATION
28
- # ======================================================
29
  if os.path.exists('/data'):
30
  BASE_STORAGE_PATH = '/data'
31
- print("✅ Using Persistent Storage at /data")
32
  else:
33
  BASE_STORAGE_PATH = '.'
34
- print("⚠️ Using Ephemeral Storage")
35
 
36
  BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
37
  os.makedirs(BASE_USER_DIR, exist_ok=True)
38
 
39
  # ======================================================
40
- # 🔧 APP CONFIG
41
  # ======================================================
42
- app = Flask(__name__)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
- # ======================================================
45
- # 🧠 CORE LOGIC (SIMPLIFIED FOR ROBUSTNESS)
46
- # ======================================================
47
- @spaces.GPU(duration=300)
48
- def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages, panels_per_page_req):
49
- import cv2
50
- import numpy as np
51
 
52
- # 1. Setup
53
  if os.path.exists(frames_dir): shutil.rmtree(frames_dir)
54
  os.makedirs(frames_dir, exist_ok=True)
55
 
56
- # 2. Extract Frames
57
  cap = cv2.VideoCapture(video_path)
58
- fps = cap.get(cv2.CAP_PROP_FPS) or 25
59
- total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
60
- duration = total_frames / fps
61
 
62
- # Default to 2 panels per page (Split layout)
63
- panels_per_page = 2
64
- total_panels = target_pages * panels_per_page
65
 
66
- # Pick timestamps
67
- times = np.linspace(1, duration - 1, total_panels)
 
 
 
 
 
 
 
 
68
 
69
  frame_files_ordered = []
70
- frame_metadata = {}
71
 
72
- for i, t in enumerate(times):
73
- cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000)
74
- ret, frame = cap.read()
75
- if ret:
76
- fname = f"frame_{i:04d}.png"
77
- # Resize for vertical quality
78
- h, w = frame.shape[:2]
79
- if w > h: # If landscape, crop center
80
- center = w // 2
81
- start = center - (h // 2)
82
- frame = frame[:, start:start+h]
83
 
84
- p = os.path.join(frames_dir, fname)
85
- cv2.imwrite(p, frame)
86
 
87
- frame_metadata[fname] = {'dialogue': "Edit Text", 'time': t}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  frame_files_ordered.append(fname)
89
-
90
- cap.release()
91
-
92
- # 3. Build Page Structure
93
- pages = []
94
  for i in range(target_pages):
95
- start_idx = i * panels_per_page
96
- current_frames = frame_files_ordered[start_idx : start_idx + panels_per_page]
 
97
 
98
- # Create Page Object
99
- pg_panels = [{'image': f} for f in current_frames]
100
- # Add a default bubble to the first panel of each page
101
- pg_bubbles = []
102
- if len(pg_panels) > 0:
103
- pg_bubbles.append({
104
- 'dialog': "Your text here...",
105
- 'type': 'speech',
106
- 'layout_idx': 0 # Belongs to first panel
107
- })
108
 
109
- pages.append({
 
 
 
 
 
 
 
 
 
110
  'panels': pg_panels,
111
  'bubbles': pg_bubbles,
112
- 'splitT': '45%', # Default top split position
113
- 'splitB': '55%' # Default bottom split position
114
  })
115
 
116
- return pages
117
 
118
- # ======================================================
119
- # 💻 BACKEND CLASS
120
- # ======================================================
121
  class EnhancedComicGenerator:
122
  def __init__(self, sid):
123
  self.sid = sid
@@ -125,17 +146,18 @@ class EnhancedComicGenerator:
125
  self.video_path = os.path.join(self.user_dir, 'uploaded.mp4')
126
  self.frames_dir = os.path.join(self.user_dir, 'frames')
127
  self.output_dir = os.path.join(self.user_dir, 'output')
 
128
  os.makedirs(self.frames_dir, exist_ok=True)
129
  os.makedirs(self.output_dir, exist_ok=True)
130
- self.metadata_path = os.path.join(self.frames_dir, 'frame_metadata.json')
131
 
132
- def run(self, target_pages, panels_per_page):
133
  try:
134
- self.write_status("Generating Frames...", 10)
135
- data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, int(target_pages), int(panels_per_page))
136
 
137
  with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
138
  json.dump(data, f, indent=2)
 
139
  self.write_status("Complete!", 100)
140
  except Exception as e:
141
  traceback.print_exc()
@@ -146,7 +168,7 @@ class EnhancedComicGenerator:
146
  json.dump({'message': msg, 'progress': prog}, f)
147
 
148
  # ======================================================
149
- # 🌐 HTML FRONTEND
150
  # ======================================================
151
  INDEX_HTML = '''
152
  <!DOCTYPE html>
@@ -154,402 +176,319 @@ INDEX_HTML = '''
154
  <head>
155
  <meta charset="UTF-8">
156
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
157
- <title>Vertical Split Comic Generator</title>
158
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
159
- <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&display=swap" rel="stylesheet">
160
  <style>
161
- body { background: #222; font-family: 'Comic Neue', cursive; margin: 0; color: white; }
 
 
 
162
 
163
- /* UPLOAD SCREEN */
164
- #upload-container { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; }
165
- .box { background: #333; padding: 40px; border-radius: 10px; text-align: center; }
166
- .btn { padding: 10px 20px; background: #e67e22; border: none; color: white; cursor: pointer; font-size: 18px; border-radius: 5px; margin-top: 10px; }
167
- .btn:hover { background: #d35400; }
168
- input[type=file] { margin-bottom: 20px; }
169
-
170
- /* EDITOR SCREEN */
171
- #editor-container { display: none; padding: 20px; text-align: center; }
172
 
173
- .comic-wrapper {
174
- display: flex;
175
- flex-direction: row;
176
- flex-wrap: wrap;
177
- justify-content: center;
178
- gap: 40px;
179
- padding-bottom: 100px;
180
  }
181
 
182
- /* PAGE CONTAINER */
183
  .comic-page {
184
- width: 400px; /* Reduced width for preview */
185
- height: 600px;
186
- background: black;
187
  position: relative;
188
- border: 4px solid white;
189
- box-shadow: 0 0 20px rgba(0,0,0,0.5);
190
- overflow: visible; /* Allow handles to stick out */
191
  user-select: none;
192
  }
193
 
194
- /* THE GRID HOLDER */
195
  .comic-grid {
196
- width: 100%;
197
- height: 100%;
198
- position: relative;
199
- /* Default split variables */
200
  --split-t: 45%;
201
  --split-b: 55%;
202
  }
203
 
204
- /* PANELS - CLIPPED */
205
  .panel {
206
- position: absolute;
207
- top: 0; left: 0;
208
- width: 100%; height: 100%;
209
- overflow: hidden;
210
- pointer-events: none; /* Let clicks pass through to bubbles/handles if needed */
211
  }
212
 
213
  .panel img {
214
- width: 100%; height: 100%;
215
- object-fit: cover;
216
- pointer-events: auto; /* Re-enable pointer events for dragging image if needed */
217
  }
218
 
219
- /* Left/Top Panel */
 
220
  .panel:nth-child(1) {
221
- z-index: 2;
222
  clip-path: polygon(0 0, var(--split-t) 0, var(--split-b) 100%, 0 100%);
223
- border-right: 2px solid black; /* Fake border */
224
  }
225
-
226
- /* Right/Bottom Panel */
227
  .panel:nth-child(2) {
228
- z-index: 1;
229
  clip-path: polygon(var(--split-t) 0, 100% 0, 100% 100%, var(--split-b) 100%);
230
  }
231
 
232
- /* DRAG HANDLES FOR SPLIT LINE */
233
  .split-handle {
234
- position: absolute;
235
- width: 24px; height: 24px;
236
- background: #2196F3;
237
- border: 2px solid white;
238
- border-radius: 50%;
239
- transform: translate(-50%, -50%);
240
- cursor: col-resize;
241
- z-index: 999;
242
  box-shadow: 0 2px 5px rgba(0,0,0,0.5);
243
  pointer-events: auto;
244
  }
245
-
246
- /* Top Handle */
247
- .split-handle.top { top: 0%; left: var(--split-t); margin-top: -12px; }
248
- /* Bottom Handle */
249
- .split-handle.bottom { top: 100%; left: var(--split-b); margin-top: 12px; }
250
-
251
- /* LINE VISUALIZATION */
252
- .split-line-visual {
253
- position: absolute; top:0; left:0; width:100%; height:100%;
254
- pointer-events: none; z-index: 10;
255
- background: transparent;
256
- }
257
 
258
  /* BUBBLES */
259
- .speech-bubble {
260
- position: absolute;
261
- background: white; color: black;
262
- padding: 10px; border-radius: 10px;
263
- min-width: 80px; text-align: center;
264
- font-family: 'Comic Neue'; font-weight: bold;
265
- cursor: move; z-index: 100;
266
- border: 2px solid black;
267
- transform: translate(-50%, -50%);
268
- }
269
-
270
- .speech-bubble:after {
271
- content: ''; position: absolute; bottom: -10px; left: 50%;
272
- border-width: 10px 10px 0; border-style: solid;
273
- border-color: black transparent; display: block; width: 0;
274
- }
275
-
276
- .speech-bubble:before {
277
- content: ''; position: absolute; bottom: -6px; left: 50%;
278
- border-width: 10px 10px 0; border-style: solid;
279
- border-color: white transparent; display: block; width: 0; z-index: 1;
280
- }
281
-
282
- /* CONTROLS */
283
- .controls {
284
- position: fixed; bottom: 0; left: 0; width: 100%;
285
- background: #333; padding: 15px; display: flex;
286
- justify-content: center; gap: 20px; z-index: 1000;
287
  }
288
  </style>
289
  </head>
290
  <body>
291
 
292
- <!-- UPLOAD -->
293
- <div id="upload-container">
294
- <div class="box">
295
- <h1>🎬 Vertical Split Comic</h1>
296
- <input type="file" id="file-upload" accept="video/*"><br>
297
- <label>Pages:</label> <input type="number" id="page-count" value="2" min="1" max="5" style="width:50px;">
298
- <button class="btn" onclick="startUpload()">Generate Comic</button>
299
- <p id="status" style="margin-top:10px; color:#aaa;"></p>
 
 
 
300
  </div>
301
- </div>
302
 
303
- <!-- EDITOR -->
304
- <div id="editor-container">
305
- <h2>Adjust the Split Line by Dragging the Blue Dots</h2>
306
- <div class="comic-wrapper" id="comic-container"></div>
307
-
308
- <div class="controls">
309
- <button class="btn" onclick="addBubble()">+ Text Bubble</button>
310
- <button class="btn" onclick="downloadAll()">Download Images</button>
311
- <button class="btn" style="background:#c0392b" onclick="location.reload()">Reset</button>
312
  </div>
313
  </div>
314
 
315
  <script>
316
- let sid = 'S' + Math.floor(Math.random()*100000);
317
- let isDraggingHandle = false;
318
- let activeHandle = null; // { element: domNode, type: 'top'|'bottom', grid: domNode }
319
 
320
- let isDraggingBubble = false;
321
- let activeBubble = null;
322
- let offset = {x:0, y:0};
323
-
324
- // 1. UPLOAD LOGIC
325
- async function startUpload() {
326
- const file = document.getElementById('file-upload').files[0];
327
- if(!file) return alert("Please select a file");
328
 
329
- const pages = document.getElementById('page-count').value;
330
- const fd = new FormData();
331
- fd.append('file', file);
332
- fd.append('target_pages', pages);
333
- fd.append('panels_per_page', 2); // FORCE 2 PANELS
334
 
335
- document.querySelector('.box').style.display = 'none';
336
- document.getElementById('status').innerText = "Uploading...";
337
 
338
- const res = await fetch(`/uploader?sid=${sid}`, { method: 'POST', body: fd });
339
- if(res.ok) {
340
- checkStatus();
341
- }
342
  }
343
 
344
- async function checkStatus() {
345
- const interval = setInterval(async () => {
346
- const res = await fetch(`/status?sid=${sid}`);
347
- const data = await res.json();
348
- document.getElementById('status').innerText = data.message;
349
 
350
- if(data.progress >= 100) {
351
- clearInterval(interval);
352
- loadComic();
353
  }
354
  }, 1000);
355
  }
356
 
357
- // 2. RENDER COMIC
358
- async function loadComic() {
359
- document.getElementById('upload-container').style.display = 'none';
360
- document.getElementById('editor-container').style.display = 'block';
361
 
362
- const res = await fetch(`/output/pages.json?sid=${sid}`);
363
- const pages = await res.json();
364
 
365
- const container = document.getElementById('comic-container');
366
- container.innerHTML = '';
367
 
368
- pages.forEach((pageData, idx) => {
369
- renderPage(pageData, idx, container);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  });
371
  }
372
 
373
- function renderPage(data, idx, container) {
374
- const pageDiv = document.createElement('div');
375
- pageDiv.className = 'comic-page';
376
-
377
- // Grid Container
378
- const grid = document.createElement('div');
379
- grid.className = 'comic-grid';
380
- grid.style.setProperty('--split-t', data.splitT || '45%');
381
- grid.style.setProperty('--split-b', data.splitB || '55%');
382
-
383
- // Panels (Assuming 2 panels always)
384
- if(data.panels.length >= 2) {
385
- // Panel 1 (Left)
386
- const p1 = document.createElement('div');
387
- p1.className = 'panel';
388
- p1.innerHTML = `<img src="/frames/${data.panels[0].image}?sid=${sid}">`;
389
- grid.appendChild(p1);
390
-
391
- // Panel 2 (Right)
392
- const p2 = document.createElement('div');
393
- p2.className = 'panel';
394
- p2.innerHTML = `<img src="/frames/${data.panels[1].image}?sid=${sid}">`;
395
- grid.appendChild(p2);
396
- }
397
-
398
- // HANDLES
399
- const hTop = document.createElement('div');
400
- hTop.className = 'split-handle top';
401
- hTop.onmousedown = (e) => startDragHandle(e, hTop, 'top', grid);
402
-
403
- const hBot = document.createElement('div');
404
- hBot.className = 'split-handle bottom';
405
- hBot.onmousedown = (e) => startDragHandle(e, hBot, 'bottom', grid);
406
-
407
- grid.appendChild(hTop);
408
- grid.appendChild(hBot);
409
-
410
- // BUBBLES
411
- if(data.bubbles) {
412
- data.bubbles.forEach(b => createBubble(b.dialog, grid));
413
- }
414
-
415
- pageDiv.appendChild(grid);
416
- container.appendChild(pageDiv);
417
- }
418
-
419
- // 3. INTERACTION LOGIC (DRAG HANDLES)
420
- function startDragHandle(e, el, type, grid) {
421
- e.preventDefault();
422
- e.stopPropagation();
423
- isDraggingHandle = true;
424
- activeHandle = { el, type, grid };
425
  }
426
 
427
- // 4. INTERACTION LOGIC (BUBBLES)
428
  function createBubble(text, parent) {
429
- const b = document.createElement('div');
430
- b.className = 'speech-bubble';
431
  b.contentEditable = true;
432
- b.innerText = text;
433
- b.style.left = '50%';
434
- b.style.top = '50%';
435
 
436
  b.onmousedown = (e) => {
437
- if(e.target !== b) return; // Allow text selection
438
- e.preventDefault();
439
- isDraggingBubble = true;
440
- activeBubble = b;
441
- offset.x = e.clientX - b.getBoundingClientRect().left - (b.offsetWidth/2);
442
- offset.y = e.clientY - b.getBoundingClientRect().top - (b.offsetHeight/2);
443
  };
444
 
445
- parent.appendChild(b);
 
 
 
 
 
446
  }
447
 
448
- function addBubble() {
449
- const grids = document.querySelectorAll('.comic-grid');
450
- if(grids.length > 0) createBubble("New Text", grids[0]);
451
- }
452
 
453
- // GLOBAL MOUSE EVENTS
454
  document.addEventListener('mousemove', (e) => {
455
- // Handle Dragging
456
- if(isDraggingHandle && activeHandle) {
457
- const rect = activeHandle.grid.getBoundingClientRect();
458
- let relativeX = e.clientX - rect.left;
459
-
460
- // Constrain
461
- if(relativeX < 0) relativeX = 0;
462
- if(relativeX > rect.width) relativeX = rect.width;
463
-
464
- const pct = (relativeX / rect.width) * 100;
465
 
466
- if(activeHandle.type === 'top') {
467
- activeHandle.grid.style.setProperty('--split-t', pct + '%');
468
- } else {
469
- activeHandle.grid.style.setProperty('--split-b', pct + '%');
470
- }
471
  }
472
-
473
- // Bubble Dragging
474
- if(isDraggingBubble && activeBubble) {
475
- const rect = activeBubble.parentElement.getBoundingClientRect();
476
- let x = e.clientX - rect.left; // - offset.x;
477
- let y = e.clientY - rect.top; // - offset.y;
478
-
479
- activeBubble.style.left = x + 'px';
480
- activeBubble.style.top = y + 'px';
481
  }
482
  });
483
 
484
  document.addEventListener('mouseup', () => {
485
- isDraggingHandle = false;
486
- activeHandle = null;
487
- isDraggingBubble = false;
488
- activeBubble = null;
489
  });
490
 
491
- // 5. EXPORT
492
- async function downloadAll() {
493
- const pages = document.querySelectorAll('.comic-page');
494
- for(let i=0; i<pages.length; i++) {
495
- // Hide handles for screenshot
496
- const handles = pages[i].querySelectorAll('.split-handle');
497
- handles.forEach(h => h.style.display = 'none');
498
 
499
- const dataUrl = await htmlToImage.toPng(pages[i]);
500
- const link = document.createElement('a');
501
- link.download = `comic_page_${i+1}.png`;
502
- link.href = dataUrl;
503
- link.click();
504
 
505
- // Show handles again
506
- handles.forEach(h => h.style.display = 'block');
507
  }
508
- }
509
  </script>
510
  </body>
511
  </html>
512
  '''
513
 
514
  # ======================================================
515
- # 🚀 FLASK ROUTES
516
  # ======================================================
517
  @app.route('/')
518
  def index():
519
  return INDEX_HTML
520
 
521
  @app.route('/uploader', methods=['POST'])
522
- def upload():
523
  sid = request.args.get('sid')
524
- target_pages = request.form.get('target_pages', 2)
525
- panels_per_page = 2 # Fixed for this specific layout req
526
-
527
  f = request.files['file']
 
 
528
  gen = EnhancedComicGenerator(sid)
529
  f.save(gen.video_path)
530
 
531
- # Run in background
532
- threading.Thread(target=gen.run, args=(target_pages, panels_per_page)).start()
533
- return jsonify({'success': True})
534
 
535
  @app.route('/status')
536
- def get_status():
537
  sid = request.args.get('sid')
538
- path = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
539
- if os.path.exists(path): return send_file(path)
540
- return jsonify({'progress': 0, 'message': "Waiting..."})
541
 
542
- @app.route('/output/path:filename')
543
- def get_output(filename):
544
  sid = request.args.get('sid')
545
- return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
 
 
 
546
 
547
- @app.route('/frames/path:filename')
548
- def get_frame(filename):
549
  sid = request.args.get('sid')
550
- return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
551
 
552
  if __name__ == '__main__':
553
- try: gpu_warmup()
554
- except: pass
555
  app.run(host='0.0.0.0', port=7860)
 
1
+ import spaces
2
  import os
 
3
  import threading
4
  import json
5
  import traceback
 
 
 
6
  import shutil
7
  import cv2
 
8
  import numpy as np
 
9
  from flask import Flask, jsonify, request, send_from_directory, send_file
10
 
11
  # ======================================================
12
+ # 🔧 CONFIGURATION
13
  # ======================================================
14
+ app = Flask(__name__)
 
 
 
 
15
 
16
+ # Persistent storage check (for Hugging Face Spaces)
 
 
17
  if os.path.exists('/data'):
18
  BASE_STORAGE_PATH = '/data'
 
19
  else:
20
  BASE_STORAGE_PATH = '.'
 
21
 
22
  BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
23
  os.makedirs(BASE_USER_DIR, exist_ok=True)
24
 
25
  # ======================================================
26
+ # 🧠 BACKEND LOGIC
27
  # ======================================================
28
+ def create_placeholder_image(text, filename, output_dir):
29
+ """Creates a dummy image if video extraction fails"""
30
+ # Create a dark blue image
31
+ img = np.zeros((600, 400, 3), dtype=np.uint8)
32
+ img[:] = (50, 50, 50) # Dark gray background
33
+
34
+ # Add text
35
+ font = cv2.FONT_HERSHEY_SIMPLEX
36
+ cv2.putText(img, text, (50, 300), font, 1, (255, 255, 255), 2, cv2.LINE_AA)
37
+
38
+ # Draw an X to show it's a placeholder
39
+ cv2.line(img, (0,0), (400,600), (100,100,100), 2)
40
+ cv2.line(img, (400,0), (0,600), (100,100,100), 2)
41
+
42
+ path = os.path.join(output_dir, filename)
43
+ cv2.imwrite(path, img)
44
+ return filename
45
 
46
+ @spaces.GPU(duration=120)
47
+ def generate_comic_gpu(video_path, frames_dir, target_pages):
48
+ """Extracts frames from video. Uses placeholders if extraction fails."""
 
 
 
 
49
 
50
+ # 1. Clean/Create Directory
51
  if os.path.exists(frames_dir): shutil.rmtree(frames_dir)
52
  os.makedirs(frames_dir, exist_ok=True)
53
 
54
+ # 2. Try to Open Video
55
  cap = cv2.VideoCapture(video_path)
 
 
 
56
 
57
+ total_frames = 0
58
+ duration = 0
59
+ fps = 25
60
 
61
+ if cap.isOpened():
62
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
63
+ fps = cap.get(cv2.CAP_PROP_FPS) or 25
64
+ duration = total_frames / fps
65
+ else:
66
+ print("❌ Error: Could not open video. Using placeholders.")
67
+
68
+ # 3. Determine Layout (2 panels per page)
69
+ panels_per_page = 2
70
+ total_panels_needed = target_pages * panels_per_page
71
 
72
  frame_files_ordered = []
 
73
 
74
+ # 4. Extract Frames
75
+ if duration > 0 and total_frames > 0:
76
+ # Generate timestamps
77
+ times = np.linspace(1, max(1, duration - 1), total_panels_needed)
78
+
79
+ for i, t in enumerate(times):
80
+ cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000)
81
+ ret, frame = cap.read()
 
 
 
82
 
83
+ fname = f"frame_{i:04d}.png"
 
84
 
85
+ if ret and frame is not None:
86
+ # Crop logic: Keep center vertical strip if landscape
87
+ h, w = frame.shape[:2]
88
+ if w > h:
89
+ center_x = w // 2
90
+ start_x = max(0, center_x - (h // 2))
91
+ frame = frame[:, start_x : start_x + h] # Crop to square/vertical
92
+
93
+ # Resize to standard height for consistency
94
+ # frame = cv2.resize(frame, (500, 750))
95
+
96
+ p = os.path.join(frames_dir, fname)
97
+ cv2.imwrite(p, frame)
98
+ frame_files_ordered.append(fname)
99
+ else:
100
+ # Fallback if specific frame read fails
101
+ create_placeholder_image(f"Error {t:.1f}s", fname, frames_dir)
102
+ frame_files_ordered.append(fname)
103
+ cap.release()
104
+ else:
105
+ # Fallback if video totally failed
106
+ for i in range(total_panels_needed):
107
+ fname = f"placeholder_{i}.png"
108
+ create_placeholder_image(f"Panel {i+1}", fname, frames_dir)
109
  frame_files_ordered.append(fname)
110
+
111
+ # 5. Build Page JSON
112
+ pages_data = []
 
 
113
  for i in range(target_pages):
114
+ start = i * panels_per_page
115
+ end = start + panels_per_page
116
+ p_frames = frame_files_ordered[start:end]
117
 
118
+ # Ensure we have enough frames for the page
119
+ while len(p_frames) < 2:
120
+ fname = f"extra_{i}.png"
121
+ create_placeholder_image("Extra", fname, frames_dir)
122
+ p_frames.append(fname)
 
 
 
 
 
123
 
124
+ pg_panels = [
125
+ {'image': p_frames[0]},
126
+ {'image': p_frames[1]}
127
+ ]
128
+
129
+ pg_bubbles = []
130
+ if i == 0: # Add intro bubble
131
+ pg_bubbles.append({'dialog': "Drag the Blue Dots\nto tilt the split!", 'type': 'speech'})
132
+
133
+ pages_data.append({
134
  'panels': pg_panels,
135
  'bubbles': pg_bubbles,
136
+ 'splitT': '45%',
137
+ 'splitB': '55%'
138
  })
139
 
140
+ return pages_data
141
 
 
 
 
142
  class EnhancedComicGenerator:
143
  def __init__(self, sid):
144
  self.sid = sid
 
146
  self.video_path = os.path.join(self.user_dir, 'uploaded.mp4')
147
  self.frames_dir = os.path.join(self.user_dir, 'frames')
148
  self.output_dir = os.path.join(self.user_dir, 'output')
149
+ os.makedirs(self.user_dir, exist_ok=True)
150
  os.makedirs(self.frames_dir, exist_ok=True)
151
  os.makedirs(self.output_dir, exist_ok=True)
 
152
 
153
+ def run(self, target_pages):
154
  try:
155
+ self.write_status("Processing...", 20)
156
+ data = generate_comic_gpu(self.video_path, self.frames_dir, int(target_pages))
157
 
158
  with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
159
  json.dump(data, f, indent=2)
160
+
161
  self.write_status("Complete!", 100)
162
  except Exception as e:
163
  traceback.print_exc()
 
168
  json.dump({'message': msg, 'progress': prog}, f)
169
 
170
  # ======================================================
171
+ # 🌐 FRONTEND HTML
172
  # ======================================================
173
  INDEX_HTML = '''
174
  <!DOCTYPE html>
 
176
  <head>
177
  <meta charset="UTF-8">
178
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
179
+ <title>Vertical Comic Splitter</title>
180
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
181
+ <link href="https://fonts.googleapis.com/css2?family=Comic+Neue:wght@700&display=swap" rel="stylesheet">
182
  <style>
183
+ body { background: #1a1a1a; font-family: 'Comic Neue', sans-serif; margin: 0; color: #eee; }
184
+
185
+ /* LAYOUT */
186
+ #app { max-width: 1200px; margin: 0 auto; padding: 20px; text-align: center; }
187
 
188
+ /* UPLOAD */
189
+ .upload-box { background: #333; padding: 40px; border-radius: 12px; display: inline-block; margin-top: 50px; }
190
+ input, button { padding: 10px; border-radius: 5px; border: none; font-size: 16px; }
191
+ button { background: #e67e22; color: white; cursor: pointer; font-weight: bold; }
192
+ button:hover { background: #d35400; }
 
 
 
 
193
 
194
+ /* COMIC AREA */
195
+ #editor-area { display: none; }
196
+
197
+ .pages-container {
198
+ display: flex; flex-wrap: wrap; justify-content: center; gap: 30px; margin-top: 30px;
 
 
199
  }
200
 
201
+ /* PAGE - The container for one comic page */
202
  .comic-page {
203
+ width: 400px; height: 600px;
204
+ background: #2a2a2a; /* Grey background so you can see if image is missing */
 
205
  position: relative;
206
+ border: 4px solid #fff;
207
+ box-shadow: 0 10px 30px rgba(0,0,0,0.5);
 
208
  user-select: none;
209
  }
210
 
211
+ /* GRID - Holds panels and handles CSS vars */
212
  .comic-grid {
213
+ width: 100%; height: 100%; position: relative;
 
 
 
214
  --split-t: 45%;
215
  --split-b: 55%;
216
  }
217
 
218
+ /* PANELS */
219
  .panel {
220
+ position: absolute; top: 0; left: 0; width: 100%; height: 100%;
221
+ overflow: hidden; pointer-events: none;
 
 
 
222
  }
223
 
224
  .panel img {
225
+ width: 100%; height: 100%; object-fit: cover;
226
+ display: block; /* Removes tiny gaps */
227
+ pointer-events: auto; /* Allow dragging checks later if needed */
228
  }
229
 
230
+ /* CLIP PATHS */
231
+ /* Panel 1 (Left) */
232
  .panel:nth-child(1) {
233
+ z-index: 5;
234
  clip-path: polygon(0 0, var(--split-t) 0, var(--split-b) 100%, 0 100%);
235
+ border-right: 1px solid black;
236
  }
237
+ /* Panel 2 (Right) */
 
238
  .panel:nth-child(2) {
239
+ z-index: 4;
240
  clip-path: polygon(var(--split-t) 0, 100% 0, 100% 100%, var(--split-b) 100%);
241
  }
242
 
243
+ /* HANDLES (Blue Dots) */
244
  .split-handle {
245
+ position: absolute; width: 20px; height: 20px;
246
+ background: #3498db; border: 3px solid white; border-radius: 50%;
247
+ z-index: 999; cursor: ew-resize; transform: translate(-50%, -50%);
 
 
 
 
 
248
  box-shadow: 0 2px 5px rgba(0,0,0,0.5);
249
  pointer-events: auto;
250
  }
251
+ .split-handle.top { top: 0%; left: var(--split-t); margin-top: -10px; }
252
+ .split-handle.bottom { top: 100%; left: var(--split-b); margin-top: 10px; }
 
 
 
 
 
 
 
 
 
 
253
 
254
  /* BUBBLES */
255
+ .bubble {
256
+ position: absolute; background: white; color: black; padding: 10px;
257
+ border-radius: 12px; min-width: 80px; text-align: center;
258
+ font-size: 14px; font-weight: bold; cursor: move; z-index: 100;
259
+ border: 2px solid #000; box-shadow: 3px 3px 0 rgba(0,0,0,0.2);
260
+ top: 50%; left: 50%; transform: translate(-50%, -50%);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  }
262
  </style>
263
  </head>
264
  <body>
265
 
266
+ <div id="app">
267
+ <div id="upload-screen">
268
+ <div class="upload-box">
269
+ <h1>🎬 Vertical Video to Comic</h1>
270
+ <p>Select a video file to generate panels.</p>
271
+ <input type="file" id="fileIn" accept="video/*"><br><br>
272
+ <label>Pages:</label> <input type="number" id="pgCount" value="2" min="1" max="5" style="width:50px;">
273
+ <br><br>
274
+ <button onclick="startProcess()">Generate Comic</button>
275
+ <p id="statusMsg" style="color:#aaa; margin-top:10px;"></p>
276
+ </div>
277
  </div>
 
278
 
279
+ <div id="editor-area">
280
+ <h2>Drag the Blue Dots to tilt the line!</h2>
281
+ <div class="controls">
282
+ <button onclick="addBubble()">+ Bubble</button>
283
+ <button onclick="downloadAll()">Download Pages</button>
284
+ <button style="background:#444" onclick="location.reload()">Start Over</button>
285
+ </div>
286
+ <div class="pages-container" id="comic-container"></div>
 
287
  </div>
288
  </div>
289
 
290
  <script>
291
+ let sid = 'S' + Date.now();
 
 
292
 
293
+ // DRAG STATE
294
+ let dragTarget = null; // 'handle' or 'bubble'
295
+ let activeItem = null;
296
+ let dragData = {};
297
+
298
+ async function startProcess() {
299
+ let f = document.getElementById('fileIn').files[0];
300
+ if(!f) return alert("Select a video!");
301
 
302
+ let fd = new FormData();
303
+ fd.append('file', f);
304
+ fd.append('target_pages', document.getElementById('pgCount').value);
 
 
305
 
306
+ document.getElementById('statusMsg').innerText = "Uploading & Processing...";
 
307
 
308
+ // 1. Upload
309
+ let r = await fetch(`/uploader?sid=${sid}`, { method:'POST', body:fd });
310
+ if(r.ok) trackStatus();
311
+ else alert("Upload failed");
312
  }
313
 
314
+ function trackStatus() {
315
+ let tmr = setInterval(async () => {
316
+ let r = await fetch(`/status?sid=${sid}`);
317
+ let d = await r.json();
318
+ document.getElementById('statusMsg').innerText = d.message;
319
 
320
+ if(d.progress >= 100 || d.progress == -1) {
321
+ clearInterval(tmr);
322
+ if(d.progress == 100) loadEditor();
323
  }
324
  }, 1000);
325
  }
326
 
327
+ async function loadEditor() {
328
+ document.getElementById('upload-screen').style.display='none';
329
+ document.getElementById('editor-area').style.display='block';
 
330
 
331
+ let r = await fetch(`/output/pages.json?sid=${sid}`);
332
+ let pages = await r.json();
333
 
334
+ let con = document.getElementById('comic-container');
335
+ con.innerHTML = '';
336
 
337
+ pages.forEach((pg, i) => {
338
+ // Container
339
+ let div = document.createElement('div');
340
+ div.className = 'comic-page';
341
+
342
+ // Grid
343
+ let grid = document.createElement('div');
344
+ grid.className = 'comic-grid';
345
+ grid.style.setProperty('--split-t', pg.splitT);
346
+ grid.style.setProperty('--split-b', pg.splitB);
347
+
348
+ // Panels
349
+ pg.panels.forEach(pan => {
350
+ let pDiv = document.createElement('div');
351
+ pDiv.className = 'panel';
352
+ // Add timestamp to force image refresh
353
+ let imgPath = `/frames/${pan.image}?sid=${sid}&t=${Date.now()}`;
354
+ pDiv.innerHTML = `<img src="${imgPath}" onerror="this.style.display='none'">`;
355
+ grid.appendChild(pDiv);
356
+ });
357
+
358
+ // Handles
359
+ let h1 = createHandle('top', grid);
360
+ let h2 = createHandle('bottom', grid);
361
+ grid.appendChild(h1);
362
+ grid.appendChild(h2);
363
+
364
+ // Bubbles
365
+ if(pg.bubbles) pg.bubbles.forEach(b => createBubble(b.dialog, grid));
366
+
367
+ div.appendChild(grid);
368
+ con.appendChild(div);
369
  });
370
  }
371
 
372
+ function createHandle(pos, grid) {
373
+ let h = document.createElement('div');
374
+ h.className = `split-handle ${pos}`;
375
+ h.onmousedown = (e) => {
376
+ e.stopPropagation();
377
+ dragTarget = 'handle';
378
+ activeItem = { el: h, grid: grid, pos: pos };
379
+ };
380
+ return h;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  }
382
 
 
383
  function createBubble(text, parent) {
384
+ let b = document.createElement('div');
385
+ b.className = 'bubble';
386
  b.contentEditable = true;
387
+ b.innerText = text || "New Text";
 
 
388
 
389
  b.onmousedown = (e) => {
390
+ if(e.target !== b) return;
391
+ e.stopPropagation();
392
+ dragTarget = 'bubble';
393
+ activeItem = b;
 
 
394
  };
395
 
396
+ if(!parent) {
397
+ let firstGrid = document.querySelector('.comic-grid');
398
+ if(firstGrid) firstGrid.appendChild(b);
399
+ } else {
400
+ parent.appendChild(b);
401
+ }
402
  }
403
 
404
+ window.addBubble = () => createBubble("Text Here");
 
 
 
405
 
406
+ // GLOBAL DRAG HANDLER
407
  document.addEventListener('mousemove', (e) => {
408
+ if(!dragTarget) return;
409
+
410
+ if(dragTarget === 'handle') {
411
+ let rect = activeItem.grid.getBoundingClientRect();
412
+ let rx = e.clientX - rect.left;
413
+ let pct = (rx / rect.width) * 100;
414
+ pct = Math.max(0, Math.min(100, pct));
 
 
 
415
 
416
+ if(activeItem.pos === 'top') activeItem.grid.style.setProperty('--split-t', pct+'%');
417
+ else activeItem.grid.style.setProperty('--split-b', pct+'%');
 
 
 
418
  }
419
+ else if(dragTarget === 'bubble') {
420
+ let rect = activeItem.parentElement.getBoundingClientRect();
421
+ let x = e.clientX - rect.left;
422
+ let y = e.clientY - rect.top;
423
+ activeItem.style.left = x + 'px';
424
+ activeItem.style.top = y + 'px';
 
 
 
425
  }
426
  });
427
 
428
  document.addEventListener('mouseup', () => {
429
+ dragTarget = null;
430
+ activeItem = null;
 
 
431
  });
432
 
433
+ window.downloadAll = async () => {
434
+ let pgs = document.querySelectorAll('.comic-page');
435
+ for(let i=0; i<pgs.length; i++) {
436
+ // Hide handles
437
+ let handles = pgs[i].querySelectorAll('.split-handle');
438
+ handles.forEach(h => h.style.display='none');
 
439
 
440
+ let url = await htmlToImage.toPng(pgs[i]);
441
+ let a = document.createElement('a');
442
+ a.download = `page_${i+1}.png`;
443
+ a.href = url;
444
+ a.click();
445
 
446
+ handles.forEach(h => h.style.display='block');
 
447
  }
448
+ };
449
  </script>
450
  </body>
451
  </html>
452
  '''
453
 
454
  # ======================================================
455
+ # 🔌 FLASK ROUTES
456
  # ======================================================
457
  @app.route('/')
458
  def index():
459
  return INDEX_HTML
460
 
461
  @app.route('/uploader', methods=['POST'])
462
+ def uploader():
463
  sid = request.args.get('sid')
 
 
 
464
  f = request.files['file']
465
+ pages = request.form.get('target_pages', 2)
466
+
467
  gen = EnhancedComicGenerator(sid)
468
  f.save(gen.video_path)
469
 
470
+ threading.Thread(target=gen.run, args=(pages,)).start()
471
+ return jsonify({'success':True})
 
472
 
473
  @app.route('/status')
474
+ def status():
475
  sid = request.args.get('sid')
476
+ p = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
477
+ if os.path.exists(p): return send_file(p)
478
+ return jsonify({'progress':0, 'message':'Waiting...'})
479
 
480
+ @app.route('/frames/<path:filename>')
481
+ def frames(filename):
482
  sid = request.args.get('sid')
483
+ # Force no-cache headers to prevent black screen on reload
484
+ resp = send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
485
+ resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
486
+ return resp
487
 
488
+ @app.route('/output/<path:filename>')
489
+ def output(filename):
490
  sid = request.args.get('sid')
491
+ return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
492
 
493
  if __name__ == '__main__':
 
 
494
  app.run(host='0.0.0.0', port=7860)