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

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +119 -262
app_enhanced.py CHANGED
@@ -1,4 +1,5 @@
1
- import spaces # <--- CRITICAL: MUST BE THE FIRST IMPORT
 
2
  import os
3
  import time
4
  import threading
@@ -9,153 +10,66 @@ 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
- else:
32
- BASE_STORAGE_PATH = '.'
33
-
34
- BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
35
- SAVED_COMICS_DIR = os.path.join(BASE_STORAGE_PATH, "saved_comics")
36
-
37
- os.makedirs(BASE_USER_DIR, exist_ok=True)
38
- os.makedirs(SAVED_COMICS_DIR, exist_ok=True)
39
-
40
- # ======================================================
41
- # 🧱 DATA CLASSES
42
- # ======================================================
43
- def bubble(dialog="", bubble_offset_x=-1, bubble_offset_y=-1, lip_x=-1, lip_y=-1, emotion='normal', type='speech'):
44
- return {
45
- 'dialog': dialog,
46
- 'bubble_offset_x': int(bubble_offset_x),
47
- 'bubble_offset_y': int(bubble_offset_y),
48
- 'lip_x': int(lip_x),
49
- 'lip_y': int(lip_y),
50
- 'emotion': emotion,
51
- 'type': type,
52
- 'tail_pos': '50%',
53
- 'classes': f'speech-bubble {type} tail-bottom'
54
- }
55
-
56
- def panel(image=""):
57
- return {'image': image}
58
-
59
- class Page:
60
- def __init__(self, panels, bubbles):
61
- self.panels = panels
62
- self.bubbles = bubbles
63
-
64
- # ======================================================
65
- # 🔧 APP CONFIG
66
- # ======================================================
67
- logging.basicConfig(level=logging.INFO)
68
- logger = logging.getLogger(__name__)
69
 
70
- app = Flask(__name__)
71
-
72
- def generate_save_code(length=8):
73
- chars = string.ascii_uppercase + string.digits
74
- while True:
75
- code = ''.join(random.choices(chars, k=length))
76
- if not os.path.exists(os.path.join(SAVED_COMICS_DIR, code)):
77
- return code
78
-
79
- # ======================================================
80
- # 🧠 GLOBAL GPU FUNCTIONS
81
- # ======================================================
82
- @spaces.GPU(duration=300)
83
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages, panels_per_page_req):
84
- import cv2
85
- import srt
86
- import numpy as np
87
-
88
  cap = cv2.VideoCapture(video_path)
89
  if not cap.isOpened(): raise Exception("Cannot open video")
 
90
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
91
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
92
  duration = total_frames / fps
93
  cap.release()
94
 
95
- user_srt = os.path.join(user_dir, 'subs.srt')
96
- # Simple SRT parsing logic
97
- with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
98
-
99
- with open(user_srt, 'r', encoding='utf-8') as f:
100
- try: all_subs = list(srt.parse(f.read()))
101
- except: all_subs = []
102
-
103
- valid_subs = [s for s in all_subs if s.content.strip()]
104
- raw_moments = [{'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
105
-
106
  target_pages = int(target_pages)
107
  panels_per_page = int(panels_per_page_req)
108
  total_panels_needed = target_pages * panels_per_page
109
 
110
- selected_moments = []
111
- if not raw_moments:
112
- times = np.linspace(1, max(1, duration-1), total_panels_needed)
113
- for t in times: selected_moments.append({'text': '', 'start': t, 'end': t+1})
114
- else:
115
- indices = np.linspace(0, len(raw_moments) - 1, total_panels_needed, dtype=int)
116
- selected_moments = [raw_moments[i] for i in indices]
117
-
118
  frame_metadata = {}
119
  cap = cv2.VideoCapture(video_path)
120
- count = 0
121
  frame_files_ordered = []
122
 
123
- for i, moment in enumerate(selected_moments):
124
- mid = (moment['start'] + moment['end']) / 2
125
- cap.set(cv2.CAP_PROP_POS_FRAMES, int(mid * fps))
126
  ret, frame = cap.read()
127
  if ret:
128
- fname = f"frame_{count:04d}.png"
129
  p = os.path.join(frames_dir, fname)
130
  cv2.imwrite(p, frame)
131
- frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
132
  frame_files_ordered.append(fname)
133
- count += 1
134
  cap.release()
135
 
136
- with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
 
137
 
138
- bubbles_list = []
139
- for f in frame_files_ordered:
140
- dialogue = frame_metadata.get(f, {}).get('dialogue', '')
141
- bubbles_list.append(bubble(dialog=dialogue, type='speech'))
142
-
143
  pages = []
144
  for i in range(target_pages):
145
  start_idx = i * panels_per_page
146
  end_idx = start_idx + panels_per_page
147
  p_frames = frame_files_ordered[start_idx:end_idx]
148
- p_bubbles = bubbles_list[start_idx:end_idx]
149
  if p_frames:
150
- pg_panels = [panel(image=f) for f in p_frames]
151
- pages.append({'panels': pg_panels, 'bubbles': p_bubbles})
152
-
 
153
  return pages
154
 
155
  @spaces.GPU
156
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
157
- import cv2
158
- import json
159
  with open(metadata_path, 'r') as f: meta = json.load(f)
160
  t = meta[fname]['time']
161
  cap = cv2.VideoCapture(video_path)
@@ -168,13 +82,19 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
168
  if ret:
169
  cv2.imwrite(os.path.join(frames_dir, fname), frame)
170
  meta[fname]['time'] = new_t
171
- with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
172
  return {"success": True}
173
  return {"success": False}
174
 
175
  # ======================================================
176
- # 💻 BACKEND CLASS
177
  # ======================================================
 
 
 
 
 
 
178
  class EnhancedComicGenerator:
179
  def __init__(self, sid):
180
  self.sid = sid
@@ -188,8 +108,10 @@ class EnhancedComicGenerator:
188
 
189
  def run(self, target_pages, panels_per_page):
190
  try:
 
191
  data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, target_pages, panels_per_page)
192
- with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f: json.dump(data, f, indent=2)
 
193
  self.write_status("Complete!", 100)
194
  except Exception as e:
195
  self.write_status(f"Error: {str(e)}", -1)
@@ -199,215 +121,149 @@ class EnhancedComicGenerator:
199
  json.dump({'message': msg, 'progress': prog}, f)
200
 
201
  # ======================================================
202
- # 🌐 ROUTES & FULL UI
203
  # ======================================================
204
  INDEX_HTML = '''
205
- <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>🎬 Vertical Tilt Comic Gen</title> <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script> <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Lato&display=swap" rel="stylesheet"> <style>
206
- * { box-sizing: border-box; }
207
- body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; margin: 0; }
208
-
209
- #upload-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px; }
210
- .upload-box { max-width: 500px; width: 100%; padding: 40px; background: white; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.12); text-align: center; }
211
-
212
- #editor-container { display: none; padding: 20px; padding-bottom: 100px; }
213
-
214
  .comic-page {
215
- background: white; width: 864px; height: 1080px; margin: 20px auto;
216
- box-shadow: 0 4px 10px rgba(0,0,0,0.1); position: relative; overflow: hidden; border: 4px solid #000; padding: 10px;
217
  }
218
-
219
- /* === THE VERTICAL TILT GRID === */
220
  .comic-grid { width: 100%; height: 100%; position: relative; background: #000; }
221
- .panel { position: absolute; top:0; left:0; width: 100%; height: 100%; overflow: hidden; cursor: pointer; }
222
 
223
- /* Left Panel (1) */
224
- .layout-custom-slant .panel:nth-child(1) {
225
- z-index: 2;
226
- clip-path: polygon(0 0, var(--split-t, 45%) 0, var(--split-b, 55%) 100%, 0 100%);
227
  }
228
-
229
- /* Right Panel (2) */
230
- .layout-custom-slant .panel:nth-child(2) {
231
- z-index: 1;
232
- clip-path: polygon(var(--split-t, 45%) 0, 100% 0, 100% 100%, var(--split-b, 55%) 100%);
233
  }
234
 
235
- .panel.selected { outline: 4px solid #2196F3; z-index: 10; }
236
 
237
- /* VERTICAL SPLIT HANDLES (Top and Bottom) */
238
- .split-handle {
239
- position: absolute; width: 28px; height: 28px;
240
- background: #FF5722; border: 3px solid white; border-radius: 50%;
241
- cursor: ew-resize; z-index: 1000; box-shadow: 0 2px 8px rgba(0,0,0,0.4);
242
  }
243
- .split-handle.top { top: -14px; left: var(--split-t, 45%); transform: translateX(-50%); }
244
- .split-handle.bottom { bottom: -14px; left: var(--split-b, 55%); transform: translateX(-50%); }
245
 
246
- .panel img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.1s; pointer-events: none; }
247
-
248
- /* BUBBLES */
249
- .speech-bubble {
250
- position: absolute; width: 160px; height: 90px; background: #4ECDC4; color: white;
251
- border-radius: 1.2em; display: flex; align-items: center; justify-content: center;
252
- padding: 10px; font-family: 'Comic Neue', cursive; font-weight: bold; font-size: 14px;
253
- text-align: center; cursor: move; z-index: 50; transform: translate(-50%, -50%);
254
- }
255
- .speech-bubble.selected { outline: 3px dashed #FFEB3B; }
256
 
257
- .edit-controls { position: fixed; bottom: 20px; right: 20px; width: 240px; background: #2c3e50; color: white; padding: 15px; border-radius: 10px; z-index: 999; }
258
- .edit-controls button { width: 100%; margin: 5px 0; padding: 8px; font-weight: bold; cursor: pointer; border-radius: 4px; border: none; }
259
- .action-btn { background: #27ae60; color: white; }
260
- .delete-btn { background: #c0392b; color: white; }
261
- </style> </head> <body>
262
-
263
- <div id="upload-container">
264
- <div class="upload-box">
265
- <h1>🎬 Vertical Comic Gen</h1>
266
- <input type="file" id="file-upload" style="display:none;" onchange="document.getElementById('fn').innerText=this.files[0].name">
267
- <button onclick="document.getElementById('file-upload').click()" class="action-btn">📁 Choose Video</button>
268
- <p id="fn">No file selected</p>
269
- <button class="action-btn" onclick="upload()" style="background:#e67e22">🚀 Generate</button>
270
- <div id="loading" style="display:none;">
271
- <p id="status-text">Processing...</p>
272
- </div>
273
- </div>
274
  </div>
275
 
276
- <div id="editor-container">
277
- <div id="comic-container"></div>
278
- <div class="edit-controls">
279
- <h4>Panel Editor</h4>
280
- <button onclick="addBubble()" class="action-btn">💬 Add Bubble</button>
281
- <button onclick="deleteBubble()" class="delete-btn">🗑️ Delete Bubble</button>
282
- <button onclick="exportComic()" style="background:#3498db; color:white;">📥 Export PNG</button>
283
- <button onclick="location.reload()" class="delete-btn">🏠 Home</button>
284
  </div>
285
  </div>
286
 
287
  <script>
288
- let sid = 'S' + Math.floor(Math.random()*1000000);
289
- let selectedPanel = null, selectedBubble = null, selectedSplitHandle = null;
290
- let isDraggingBubble = false, isDraggingSplit = false;
291
-
292
- async function upload() {
293
- const f = document.getElementById('file-upload').files[0];
294
- if(!f) return alert("Select a video");
295
- document.getElementById('loading').style.display='block';
296
- const fd = new FormData(); fd.append('file', f); fd.append('target_pages', 2); fd.append('panels_per_page', 2);
297
  await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
298
- const timer = setInterval(async () => {
 
 
299
  const r = await fetch(`/status?sid=${sid}`);
300
  const d = await r.json();
301
- document.getElementById('status-text').innerText = d.message;
302
- if(d.progress >= 100) { clearInterval(timer); loadComic(); }
303
  }, 2000);
304
  }
305
 
306
- async function loadComic() {
307
  const r = await fetch(`/output/pages.json?sid=${sid}`);
308
- const pages = await r.json();
309
- const container = document.getElementById('comic-container');
310
- container.innerHTML = '';
311
-
312
- pages.forEach(pData => {
313
- const pageDiv = document.createElement('div');
314
- pageDiv.className = 'comic-page';
315
  const grid = document.createElement('div');
316
- grid.className = 'comic-grid layout-custom-slant';
317
  grid.style.setProperty('--split-t', '45%');
318
  grid.style.setProperty('--split-b', '55%');
319
 
320
- // VERTICAL SPLIT HANDLES
321
- const hT = document.createElement('div'); hT.className = 'split-handle top';
322
- hT.onmousedown = (e) => { isDraggingSplit = true; selectedSplitHandle = { grid, side: 'top' }; };
323
- const hB = document.createElement('div'); hB.className = 'split-handle bottom';
324
- hB.onmousedown = (e) => { isDraggingSplit = true; selectedSplitHandle = { grid, side: 'bottom' }; };
325
-
326
- grid.appendChild(hT); grid.appendChild(hB);
327
 
328
- pData.panels.forEach(pan => {
329
- const pDiv = document.createElement('div');
330
- pDiv.className = 'panel';
331
  pDiv.innerHTML = `<img src="/frames/${pan.image}?sid=${sid}">`;
332
- pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
333
  grid.appendChild(pDiv);
334
  });
335
- pageDiv.appendChild(grid);
336
- container.appendChild(pageDiv);
 
 
337
  });
338
- document.getElementById('upload-container').style.display='none';
339
- document.getElementById('editor-container').style.display='block';
340
- }
341
-
342
- function selectPanel(el) {
343
- if(selectedPanel) selectedPanel.classList.remove('selected');
344
- selectedPanel = el;
345
- selectedPanel.classList.add('selected');
346
- }
347
-
348
- function addBubble() {
349
- if(!selectedPanel) return alert("Select a panel");
350
- const b = document.createElement('div');
351
- b.className = 'speech-bubble';
352
- b.innerText = "New Dialogue";
353
- b.style.left = '50%'; b.style.top = '50%';
354
- b.onmousedown = (e) => { e.stopPropagation(); selectedBubble = b; isDraggingBubble = true; selectBubble(b); };
355
- selectedPanel.appendChild(b);
356
- }
357
-
358
- function selectBubble(el) {
359
- document.querySelectorAll('.speech-bubble').forEach(x => x.classList.remove('selected'));
360
- el.classList.add('selected');
361
- selectedBubble = el;
362
  }
363
 
364
- function deleteBubble() { if(selectedBubble) selectedBubble.remove(); }
365
-
366
- document.addEventListener('mousemove', (e) => {
367
- if(isDraggingBubble && selectedBubble) {
368
- const rect = selectedBubble.parentElement.getBoundingClientRect();
369
- selectedBubble.style.left = ((e.clientX - rect.left) / rect.width * 100) + '%';
370
- selectedBubble.style.top = ((e.clientY - rect.top) / rect.height * 100) + '%';
371
- }
372
- if(isDraggingSplit && selectedSplitHandle) {
373
- const rect = selectedSplitHandle.grid.getBoundingClientRect();
374
- let percent = ((e.clientX - rect.left) / rect.width * 100);
375
- percent = Math.max(10, Math.min(90, percent));
376
- if(selectedSplitHandle.side === 'top') selectedSplitHandle.grid.style.setProperty('--split-t', percent + '%');
377
- else selectedSplitHandle.grid.style.setProperty('--split-b', percent + '%');
378
- }
379
- });
380
-
381
- document.addEventListener('mouseup', () => { isDraggingBubble = false; isDraggingSplit = false; });
382
 
383
- async function exportComic() {
384
  const pages = document.querySelectorAll('.comic-page');
385
  for(let i=0; i<pages.length; i++) {
386
- const imgData = await htmlToImage.toPng(pages[i]);
387
  const link = document.createElement('a');
388
- link.download = `page-${i+1}.png`; link.href = imgData; link.click();
389
  }
390
  }
391
- </script>
392
- </body> </html>
393
  '''
394
 
 
 
 
 
395
  @app.route('/')
396
  def index(): return INDEX_HTML
397
 
398
  @app.route('/uploader', methods=['POST'])
399
- def upload():
400
  sid = request.args.get('sid')
401
  f = request.files['file']
402
  gen = EnhancedComicGenerator(sid)
403
  f.save(gen.video_path)
404
- threading.Thread(target=gen.run, args=(request.form.get('target_pages', 2), request.form.get('panels_per_page', 2))).start()
405
- return jsonify({'success': True})
406
 
407
  @app.route('/status')
408
  def get_status():
409
  sid = request.args.get('sid')
410
- return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), 'status.json')
 
 
411
 
412
  @app.route('/output/<path:filename>')
413
  def get_output(filename):
@@ -420,4 +276,5 @@ def get_frame(filename):
420
  return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
421
 
422
  if __name__ == '__main__':
 
423
  app.run(host='0.0.0.0', port=7860)
 
1
+ import spaces # <--- MUST BE LINE 1
2
+ import torch
3
  import os
4
  import time
5
  import threading
 
10
  import random
11
  import shutil
12
  import cv2
 
13
  import numpy as np
14
  import srt
15
  from flask import Flask, jsonify, request, send_from_directory, send_file
16
 
17
  # ======================================================
18
+ # 🧠 ZERO GPU FUNCTIONS (Must be top-level)
19
  # ======================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
+ @spaces.GPU(duration=120)
 
 
 
 
 
 
 
 
 
 
 
 
22
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages, panels_per_page_req):
23
+ """ZeroGPU task to extract frames and process video"""
 
 
 
24
  cap = cv2.VideoCapture(video_path)
25
  if not cap.isOpened(): raise Exception("Cannot open video")
26
+
27
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
28
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
29
  duration = total_frames / fps
30
  cap.release()
31
 
32
+ # Create dummy subtitle logic for this standalone version
 
 
 
 
 
 
 
 
 
 
33
  target_pages = int(target_pages)
34
  panels_per_page = int(panels_per_page_req)
35
  total_panels_needed = target_pages * panels_per_page
36
 
37
+ # Uniformly pick timestamps
38
+ times = np.linspace(1, max(1, duration-1), total_panels_needed)
39
+
 
 
 
 
 
40
  frame_metadata = {}
41
  cap = cv2.VideoCapture(video_path)
 
42
  frame_files_ordered = []
43
 
44
+ for i, t in enumerate(times):
45
+ cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000)
 
46
  ret, frame = cap.read()
47
  if ret:
48
+ fname = f"frame_{i:04d}.png"
49
  p = os.path.join(frames_dir, fname)
50
  cv2.imwrite(p, frame)
51
+ frame_metadata[fname] = {'dialogue': f"Dialogue {i+1}", 'time': t}
52
  frame_files_ordered.append(fname)
 
53
  cap.release()
54
 
55
+ with open(metadata_path, 'w') as f:
56
+ json.dump(frame_metadata, f)
57
 
 
 
 
 
 
58
  pages = []
59
  for i in range(target_pages):
60
  start_idx = i * panels_per_page
61
  end_idx = start_idx + panels_per_page
62
  p_frames = frame_files_ordered[start_idx:end_idx]
 
63
  if p_frames:
64
+ pg_panels = [{'image': f} for f in p_frames]
65
+ # Create a simple bubble for each panel
66
+ pg_bubbles = [{'dialog': frame_metadata[f]['dialogue'], 'type': 'speech'} 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
+ """ZeroGPU task to shift frame time slightly"""
 
73
  with open(metadata_path, 'r') as f: meta = json.load(f)
74
  t = meta[fname]['time']
75
  cap = cv2.VideoCapture(video_path)
 
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
+ # 💾 APP CONFIG & STORAGE
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 EnhancedComicGenerator:
99
  def __init__(self, sid):
100
  self.sid = sid
 
108
 
109
  def run(self, target_pages, panels_per_page):
110
  try:
111
+ self.write_status("Running on GPU...", 20)
112
  data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, target_pages, panels_per_page)
113
+ with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
114
+ json.dump(data, f)
115
  self.write_status("Complete!", 100)
116
  except Exception as e:
117
  self.write_status(f"Error: {str(e)}", -1)
 
121
  json.dump({'message': msg, 'progress': prog}, f)
122
 
123
  # ======================================================
124
+ # 🌐 UI & TEMPLATE (With Vertical Tilt)
125
  # ======================================================
126
  INDEX_HTML = '''
127
+ <!DOCTYPE html><html><head><title>Vertical Tilt Comic</title>
128
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
129
+ <style>
130
+ body { background: #f0f0f0; font-family: sans-serif; margin: 0; padding: 20px; }
 
 
 
 
 
131
  .comic-page {
132
+ background: white; width: 800px; height: 1000px; margin: 20px auto;
133
+ position: relative; overflow: hidden; border: 5px solid black;
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
+ /* VERTICAL TILT CLIPPING */
139
+ .layout-slant .panel:nth-child(1) {
140
+ z-index: 2; clip-path: polygon(0 0, var(--split-t, 45%) 0, var(--split-b, 55%) 100%, 0 100%);
 
141
  }
142
+ .layout-slant .panel:nth-child(2) {
143
+ z-index: 1; clip-path: polygon(var(--split-t, 45%) 0, 100% 0, 100% 100%, var(--split-b, 55%) 100%);
 
 
 
144
  }
145
 
146
+ .panel img { width: 100%; height: 100%; object-fit: cover; }
147
 
148
+ /* DRAG HANDLES */
149
+ .handle {
150
+ position: absolute; width: 30px; height: 30px; background: red;
151
+ border-radius: 50%; z-index: 100; cursor: ew-resize; border: 3px solid white;
 
152
  }
153
+ .handle.top { top: -15px; left: var(--split-t); transform: translateX(-50%); }
154
+ .handle.bottom { bottom: -15px; left: var(--split-b); transform: translateX(-50%); }
155
 
156
+ .controls { position: fixed; right: 20px; top: 20px; background: white; padding: 20px; border-radius: 10px; box-shadow: 0 0 10px rgba(0,0,0,0.2); }
157
+ .speech-bubble { position: absolute; padding: 10px; background: white; border: 2px solid black; border-radius: 50%; cursor: move; z-index: 50; transform: translate(-50%, -50%); }
158
+ </style></head><body>
 
 
 
 
 
 
 
159
 
160
+ <div id="setup">
161
+ <input type="file" id="vid">
162
+ <button onclick="start()">Generate Comic</button>
163
+ <p id="status"></p>
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  </div>
165
 
166
+ <div id="editor" style="display:none">
167
+ <div id="pages"></div>
168
+ <div class="controls">
169
+ <button onclick="download()">Download PNG</button>
170
+ <p>Drag the red circles to tilt the panels vertically.</p>
 
 
 
171
  </div>
172
  </div>
173
 
174
  <script>
175
+ let sid = "S" + Date.now();
176
+ let isDragging = false;
177
+ let currentHandle = null;
178
+
179
+ async function start() {
180
+ const file = document.getElementById('vid').files[0];
181
+ if(!file) return alert("Select video");
182
+ const fd = new FormData(); fd.append('file', file); fd.append('target_pages', 2); fd.append('panels_per_page', 2);
 
183
  await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
184
+ document.getElementById('status').innerText = "Processing on ZeroGPU...";
185
+
186
+ const check = setInterval(async () => {
187
  const r = await fetch(`/status?sid=${sid}`);
188
  const d = await r.json();
189
+ document.getElementById('status').innerText = d.message;
190
+ if(d.progress >= 100) { clearInterval(check); loadEditor(); }
191
  }, 2000);
192
  }
193
 
194
+ async function loadEditor() {
195
  const r = await fetch(`/output/pages.json?sid=${sid}`);
196
+ const data = await r.json();
197
+ const container = document.getElementById('pages');
198
+ data.forEach(p => {
199
+ const page = document.createElement('div');
200
+ page.className = 'comic-page';
 
 
201
  const grid = document.createElement('div');
202
+ grid.className = 'comic-grid layout-slant';
203
  grid.style.setProperty('--split-t', '45%');
204
  grid.style.setProperty('--split-b', '55%');
205
 
206
+ const hT = document.createElement('div'); hT.className = 'handle top';
207
+ hT.onmousedown = () => { isDragging = true; currentHandle = {grid, key: '--split-t'}; };
208
+ const hB = document.createElement('div'); hB.className = 'handle bottom';
209
+ hB.onmousedown = () => { isDragging = true; currentHandle = {grid, key: '--split-b'}; };
 
 
 
210
 
211
+ p.panels.forEach(pan => {
212
+ const pDiv = document.createElement('div'); pDiv.className = 'panel';
 
213
  pDiv.innerHTML = `<img src="/frames/${pan.image}?sid=${sid}">`;
 
214
  grid.appendChild(pDiv);
215
  });
216
+
217
+ grid.appendChild(hT); grid.appendChild(hB);
218
+ page.appendChild(grid);
219
+ container.appendChild(page);
220
  });
221
+ document.getElementById('setup').style.display = 'none';
222
+ document.getElementById('editor').style.display = 'block';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  }
224
 
225
+ window.onmousemove = (e) => {
226
+ if(!isDragging) return;
227
+ const rect = currentHandle.grid.getBoundingClientRect();
228
+ let x = ((e.clientX - rect.left) / rect.width) * 100;
229
+ x = Math.max(10, Math.min(90, x));
230
+ currentHandle.grid.style.setProperty(currentHandle.key, x + '%');
231
+ };
232
+ window.onmouseup = () => isDragging = false;
 
 
 
 
 
 
 
 
 
 
233
 
234
+ async function download() {
235
  const pages = document.querySelectorAll('.comic-page');
236
  for(let i=0; i<pages.length; i++) {
237
+ const dataUrl = await htmlToImage.toPng(pages[i]);
238
  const link = document.createElement('a');
239
+ link.download = `page-${i+1}.png`; link.href = dataUrl; link.click();
240
  }
241
  }
242
+ </script></body></html>
 
243
  '''
244
 
245
+ # ======================================================
246
+ # 🚀 FLASK ROUTES
247
+ # ======================================================
248
+
249
  @app.route('/')
250
  def index(): return INDEX_HTML
251
 
252
  @app.route('/uploader', methods=['POST'])
253
+ def uploader():
254
  sid = request.args.get('sid')
255
  f = request.files['file']
256
  gen = EnhancedComicGenerator(sid)
257
  f.save(gen.video_path)
258
+ threading.Thread(target=gen.run, args=(2, 2)).start()
259
+ return jsonify({'status': 'ok'})
260
 
261
  @app.route('/status')
262
  def get_status():
263
  sid = request.args.get('sid')
264
+ path = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
265
+ if os.path.exists(path): return send_file(path)
266
+ return jsonify({'message': 'Initializing...', 'progress': 0})
267
 
268
  @app.route('/output/<path:filename>')
269
  def get_output(filename):
 
276
  return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
277
 
278
  if __name__ == '__main__':
279
+ # On Hugging Face, the port must be 7860
280
  app.run(host='0.0.0.0', port=7860)