tester343 commited on
Commit
a3c4dc0
·
verified ·
1 Parent(s): 4642400

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +306 -161
app_enhanced.py CHANGED
@@ -9,21 +9,11 @@ import numpy as np
9
  from flask import Flask, jsonify, request, send_from_directory, send_file
10
 
11
  # ======================================================
12
- # 🚀 ZEROGPU WARMUP (CRITICAL FIX)
13
- # ======================================================
14
- @spaces.GPU
15
- def gpu_warmup():
16
- """A dummy function to wake up the GPU on startup."""
17
- import torch
18
- print(f"✅ ZeroGPU Warmup Successful: CUDA Available: {torch.cuda.is_available()}")
19
- return True
20
-
21
- # ======================================================
22
- # 🔧 CONFIGURATION
23
  # ======================================================
24
  app = Flask(__name__)
25
 
26
- # Persistent storage check (for Hugging Face Spaces)
27
  if os.path.exists('/data'):
28
  BASE_STORAGE_PATH = '/data'
29
  else:
@@ -33,113 +23,146 @@ BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
33
  os.makedirs(BASE_USER_DIR, exist_ok=True)
34
 
35
  # ======================================================
36
- # 🧠 BACKEND LOGIC
37
  # ======================================================
 
38
  def create_placeholder_image(text, filename, output_dir):
39
- """Creates a dummy image if video extraction fails"""
40
- img = np.zeros((600, 400, 3), dtype=np.uint8)
41
- img[:] = (50, 50, 50) # Dark gray background
 
 
42
  font = cv2.FONT_HERSHEY_SIMPLEX
43
- cv2.putText(img, text, (50, 300), font, 1, (255, 255, 255), 2, cv2.LINE_AA)
44
- cv2.line(img, (0,0), (400,600), (100,100,100), 2)
45
- cv2.line(img, (400,0), (0,600), (100,100,100), 2)
 
 
46
  path = os.path.join(output_dir, filename)
47
  cv2.imwrite(path, img)
48
  return filename
49
 
50
- @spaces.GPU(duration=120) # <-- CRITICAL FIX: The decorator is restored here
51
  def generate_comic_gpu(video_path, frames_dir, target_pages):
52
- """Extracts frames from video. Uses placeholders if extraction fails."""
 
 
 
 
 
53
  if os.path.exists(frames_dir): shutil.rmtree(frames_dir)
54
  os.makedirs(frames_dir, exist_ok=True)
55
 
56
  cap = cv2.VideoCapture(video_path)
 
57
  total_frames = 0
58
  duration = 0
 
59
  if cap.isOpened():
60
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
61
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
62
  duration = total_frames / fps
63
  else:
64
- print("❌ Error: Could not open video. Using placeholders.")
65
 
66
- panels_per_page = 2
 
67
  total_panels_needed = target_pages * panels_per_page
 
68
  frame_files_ordered = []
69
 
 
70
  if duration > 0 and total_frames > 0:
71
  times = np.linspace(1, max(1, duration - 1), total_panels_needed)
 
72
  for i, t in enumerate(times):
73
  cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000)
74
  ret, frame = cap.read()
75
  fname = f"frame_{i:04d}.png"
 
76
  if ret and frame is not None:
 
77
  h, w = frame.shape[:2]
78
- if w > h:
79
- center_x = w // 2
80
- start_x = max(0, center_x - (h // 2))
81
- frame = frame[:, start_x : start_x + h]
82
- p = os.path.join(frames_dir, fname)
83
- cv2.imwrite(p, frame)
 
 
 
84
  frame_files_ordered.append(fname)
85
  else:
86
  create_placeholder_image(f"Error {t:.1f}s", fname, frames_dir)
87
  frame_files_ordered.append(fname)
88
  cap.release()
89
  else:
 
90
  for i in range(total_panels_needed):
91
  fname = f"placeholder_{i}.png"
92
  create_placeholder_image(f"Panel {i+1}", fname, frames_dir)
93
  frame_files_ordered.append(fname)
94
 
 
95
  pages_data = []
96
  for i in range(target_pages):
97
  start = i * panels_per_page
98
  end = start + panels_per_page
99
  p_frames = frame_files_ordered[start:end]
100
 
101
- while len(p_frames) < 2:
102
- fname = f"extra_{i}.png"
103
- create_placeholder_image("Extra", fname, frames_dir)
 
104
  p_frames.append(fname)
105
 
106
- pg_panels = [{'image': p_frames[0]}, {'image': p_frames[1]}]
 
107
  pg_bubbles = []
108
  if i == 0:
109
- pg_bubbles.append({'dialog': "Drag the Blue Dots!", 'type': 'speech'})
110
 
111
- pages_data.append({'panels': pg_panels, 'bubbles': pg_bubbles, 'splitT': '45%', 'splitB': '55%'})
 
 
 
 
 
112
 
113
  return pages_data
114
 
115
- class EnhancedComicGenerator:
116
  def __init__(self, sid):
117
  self.sid = sid
118
  self.user_dir = os.path.join(BASE_USER_DIR, sid)
119
- self.video_path = os.path.join(self.user_dir, 'uploaded.mp4')
120
  self.frames_dir = os.path.join(self.user_dir, 'frames')
121
  self.output_dir = os.path.join(self.user_dir, 'output')
 
122
  os.makedirs(self.user_dir, exist_ok=True)
123
  os.makedirs(self.frames_dir, exist_ok=True)
124
  os.makedirs(self.output_dir, exist_ok=True)
125
 
126
- def run(self, target_pages):
127
  try:
128
- self.write_status("Processing...", 20)
129
- data = generate_comic_gpu(self.video_path, self.frames_dir, int(target_pages))
130
- with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
131
- json.dump(data, f, indent=2)
132
- self.write_status("Complete!", 100)
 
 
133
  except Exception as e:
134
  traceback.print_exc()
135
- self.write_status(f"Error: {str(e)}", -1)
136
 
137
  def write_status(self, msg, prog):
138
  with open(os.path.join(self.output_dir, 'status.json'), 'w') as f:
139
  json.dump({'message': msg, 'progress': prog}, f)
140
 
141
  # ======================================================
142
- # 🌐 FRONTEND HTML (No changes needed here)
143
  # ======================================================
144
  INDEX_HTML = '''
145
  <!DOCTYPE html>
@@ -147,150 +170,275 @@ INDEX_HTML = '''
147
  <head>
148
  <meta charset="UTF-8">
149
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
150
- <title>Vertical Comic Splitter</title>
151
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
152
- <link href="https://fonts.googleapis.com/css2?family=Comic+Neue:wght@700&display=swap" rel="stylesheet">
153
  <style>
154
- body { background: #1a1a1a; font-family: 'Comic Neue', sans-serif; margin: 0; color: #eee; }
155
- #app { max-width: 1200px; margin: 0 auto; padding: 20px; text-align: center; }
156
- .upload-box { background: #333; padding: 40px; border-radius: 12px; display: inline-block; margin-top: 50px; }
157
- input, button { padding: 10px; border-radius: 5px; border: none; font-size: 16px; }
158
- button { background: #e67e22; color: white; cursor: pointer; font-weight: bold; }
159
- button:hover { background: #d35400; }
160
- #editor-area { display: none; }
161
- .pages-container { display: flex; flex-wrap: wrap; justify-content: center; gap: 30px; margin-top: 30px; }
 
 
 
 
 
 
162
  .comic-page {
163
- width: 400px; height: 600px; background: #2a2a2a; position: relative;
164
- border: 4px solid #fff; box-shadow: 0 10px 30px rgba(0,0,0,0.5); user-select: none;
 
 
 
 
 
165
  }
 
 
166
  .comic-grid {
167
- width: 100%; height: 100%; position: relative;
168
- --split-t: 45%; --split-b: 55%;
 
 
 
169
  }
 
170
  .panel {
171
- position: absolute; top: 0; left: 0; width: 100%; height: 100%;
172
- overflow: hidden; pointer-events: none;
 
 
 
173
  }
174
- .panel img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: auto; }
175
- .panel:nth-child(1) { z-index: 5; clip-path: polygon(0 0, var(--split-t) 0, var(--split-b) 100%, 0 100%); border-right: 1px solid black; }
176
- .panel:nth-child(2) { z-index: 4; clip-path: polygon(var(--split-t) 0, 100% 0, 100% 100%, var(--split-b) 100%); }
177
- .split-handle {
178
- position: absolute; width: 20px; height: 20px; background: #3498db; border: 3px solid white; border-radius: 50%;
179
- z-index: 999; cursor: ew-resize; transform: translate(-50%, -50%); box-shadow: 0 2px 5px rgba(0,0,0,0.5); pointer-events: auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  }
181
- .split-handle.top { top: 0%; left: var(--split-t); margin-top: -10px; }
182
- .split-handle.bottom { top: 100%; left: var(--split-b); margin-top: 10px; }
 
183
  .bubble {
184
- position: absolute; background: white; color: black; padding: 10px;
185
- border-radius: 12px; min-width: 80px; text-align: center;
186
- font-size: 14px; font-weight: bold; cursor: move; z-index: 100;
187
- border: 2px solid #000; box-shadow: 3px 3px 0 rgba(0,0,0,0.2);
188
- top: 50%; left: 50%; transform: translate(-50%, -50%);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  }
190
  </style>
191
  </head>
192
  <body>
193
- <div id="app">
194
- <div id="upload-screen">
195
- <div class="upload-box">
196
- <h1>🎬 Vertical Video to Comic</h1>
197
- <p>Select a video file to generate panels.</p>
198
- <input type="file" id="fileIn" accept="video/*"><br><br>
199
- <label>Pages:</label> <input type="number" id="pgCount" value="2" min="1" max="5" style="width:50px;"><br><br>
200
- <button onclick="startProcess()">Generate Comic</button>
201
- <p id="statusMsg" style="color:#aaa; margin-top:10px;"></p>
202
- </div>
203
  </div>
204
- <div id="editor-area">
205
- <h2>Drag the Blue Dots to tilt the line!</h2>
206
- <div class="controls" style="margin-bottom: 20px; display: flex; gap: 10px; justify-content: center;">
207
- <button onclick="addBubble()">+ Bubble</button>
208
- <button onclick="downloadAll()">Download Pages</button>
209
- <button style="background:#444" onclick="location.reload()">Start Over</button>
210
- </div>
211
- <div class="pages-container" id="comic-container"></div>
 
 
212
  </div>
213
  </div>
 
214
  <script>
215
  let sid = 'S' + Date.now();
216
- let dragTarget = null, activeItem = null;
 
217
 
218
- async function startProcess() {
219
  let f = document.getElementById('fileIn').files[0];
220
- if(!f) return alert("Select a video!");
 
221
  let fd = new FormData();
222
  fd.append('file', f);
223
- fd.append('target_pages', document.getElementById('pgCount').value);
224
- document.getElementById('statusMsg').innerText = "Uploading & Processing...";
225
- let r = await fetch(`/uploader?sid=${sid}`, { method:'POST', body:fd });
226
- if(r.ok) trackStatus(); else alert("Upload failed");
 
 
227
  }
228
- function trackStatus() {
229
- let tmr = setInterval(async () => {
 
230
  let r = await fetch(`/status?sid=${sid}`);
231
  let d = await r.json();
232
- document.getElementById('statusMsg').innerText = d.message;
233
- if(d.progress >= 100 || d.progress == -1) {
234
- clearInterval(tmr);
235
- if(d.progress == 100) loadEditor();
236
  }
237
- }, 1500);
238
  }
 
239
  async function loadEditor() {
240
- document.getElementById('upload-screen').style.display='none';
241
- document.getElementById('editor-area').style.display='block';
242
- let r = await fetch(`/output/pages.json?sid=${sid}`);
243
- let pages = await r.json();
244
- let con = document.getElementById('comic-container');
 
 
245
  con.innerHTML = '';
246
- pages.forEach((pg, i) => {
247
- let div = document.createElement('div'); div.className = 'comic-page';
248
- let grid = document.createElement('div'); grid.className = 'comic-grid';
249
- grid.style.setProperty('--split-t', pg.splitT); grid.style.setProperty('--split-b', pg.splitB);
 
 
 
 
 
 
 
 
250
  pg.panels.forEach(pan => {
251
- let pDiv = document.createElement('div'); pDiv.className = 'panel';
252
- let imgPath = `/frames/${pan.image}?sid=${sid}&t=${Date.now()}`;
253
- pDiv.innerHTML = `<img src="${imgPath}" onerror="this.style.display='none'">`;
254
- grid.appendChild(pDiv);
 
255
  });
256
- grid.appendChild(createHandle('top', grid)); grid.appendChild(createHandle('bottom', grid));
 
 
 
 
 
 
 
 
 
 
 
257
  if(pg.bubbles) pg.bubbles.forEach(b => createBubble(b.dialog, grid));
258
- div.appendChild(grid); con.appendChild(div);
 
 
259
  });
260
  }
261
- function createHandle(pos, grid) {
262
- let h = document.createElement('div'); h.className = `split-handle ${pos}`;
263
- h.onmousedown = (e) => { e.stopPropagation(); dragTarget = 'handle'; activeItem = { el: h, grid: grid, pos: pos }; };
264
- return h;
265
- }
266
- function createBubble(text, parent) {
267
- let b = document.createElement('div'); b.className = 'bubble'; b.contentEditable = true; b.innerText = text || "New Text";
268
- b.onmousedown = (e) => { if(e.target !== b) return; e.stopPropagation(); dragTarget = 'bubble'; activeItem = b; };
269
- let targetParent = parent || document.querySelector('.comic-grid');
270
- if(targetParent) targetParent.appendChild(b);
 
 
 
 
 
 
 
271
  }
272
- window.addBubble = () => createBubble("Text Here");
 
 
 
273
  document.addEventListener('mousemove', (e) => {
274
- if(!dragTarget) return;
275
- if(dragTarget === 'handle') {
276
- let rect = activeItem.grid.getBoundingClientRect(); let rx = e.clientX - rect.left;
277
- let pct = (rx / rect.width) * 100; pct = Math.max(0, Math.min(100, pct));
278
- if(activeItem.pos === 'top') activeItem.grid.style.setProperty('--split-t', pct+'%');
279
- else activeItem.grid.style.setProperty('--split-b', pct+'%');
280
- } else if(dragTarget === 'bubble') {
281
- let rect = activeItem.parentElement.getBoundingClientRect();
282
- let x = e.clientX - rect.left; let y = e.clientY - rect.top;
283
- activeItem.style.left = x + 'px'; activeItem.style.top = y + 'px';
 
 
 
 
 
 
 
 
284
  }
285
  });
286
- document.addEventListener('mouseup', () => { dragTarget = null; activeItem = null; });
 
 
 
 
 
287
  window.downloadAll = async () => {
288
  let pgs = document.querySelectorAll('.comic-page');
289
  for(let i=0; i<pgs.length; i++) {
290
- let handles = pgs[i].querySelectorAll('.split-handle'); handles.forEach(h => h.style.display='none');
 
 
 
291
  let url = await htmlToImage.toPng(pgs[i]);
292
- let a = document.createElement('a'); a.download = `page_${i+1}.png`; a.href = url; a.click();
293
- handles.forEach(h => h.style.display='block');
 
 
 
 
294
  }
295
  };
296
  </script>
@@ -305,28 +453,30 @@ INDEX_HTML = '''
305
  def index():
306
  return INDEX_HTML
307
 
308
- @app.route('/uploader', methods=['POST'])
309
- def uploader():
310
  sid = request.args.get('sid')
311
  f = request.files['file']
312
- pages = request.form.get('target_pages', 2)
313
- gen = EnhancedComicGenerator(sid)
314
- f.save(gen.video_path)
315
- threading.Thread(target=gen.run, args=(pages,)).start()
316
- return jsonify({'success':True})
 
 
317
 
318
  @app.route('/status')
319
  def status():
320
  sid = request.args.get('sid')
321
  p = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
322
  if os.path.exists(p): return send_file(p)
323
- return jsonify({'progress':0, 'message':'Waiting...'})
324
 
325
  @app.route('/frames/<path:filename>')
326
  def frames(filename):
327
  sid = request.args.get('sid')
328
  resp = send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
329
- resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
330
  return resp
331
 
332
  @app.route('/output/<path:filename>')
@@ -335,10 +485,5 @@ def output(filename):
335
  return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
336
 
337
  if __name__ == '__main__':
338
- # CRITICAL: Call the warmup function to initialize the GPU environment
339
- try:
340
- gpu_warmup()
341
- except Exception as e:
342
- print(f"GPU warmup failed: {e}")
343
-
344
  app.run(host='0.0.0.0', port=7860)
 
9
  from flask import Flask, jsonify, request, send_from_directory, send_file
10
 
11
  # ======================================================
12
+ # 🔧 CONFIGURATION & SETUP
 
 
 
 
 
 
 
 
 
 
13
  # ======================================================
14
  app = Flask(__name__)
15
 
16
+ # Check for persistent storage (Hugging Face Spaces specific)
17
  if os.path.exists('/data'):
18
  BASE_STORAGE_PATH = '/data'
19
  else:
 
23
  os.makedirs(BASE_USER_DIR, exist_ok=True)
24
 
25
  # ======================================================
26
+ # 🧠 GPU / BACKEND LOGIC
27
  # ======================================================
28
+
29
  def create_placeholder_image(text, filename, output_dir):
30
+ """Creates a backup image if video fails to read."""
31
+ img = np.zeros((600, 600, 3), dtype=np.uint8)
32
+ img[:] = (40, 40, 40) # Dark gray
33
+
34
+ # Text
35
  font = cv2.FONT_HERSHEY_SIMPLEX
36
+ cv2.putText(img, text, (50, 300), font, 1.5, (200, 200, 200), 2, cv2.LINE_AA)
37
+
38
+ # Border
39
+ cv2.rectangle(img, (0,0), (600,600), (100,100,100), 10)
40
+
41
  path = os.path.join(output_dir, filename)
42
  cv2.imwrite(path, img)
43
  return filename
44
 
45
+ @spaces.GPU(duration=120)
46
  def generate_comic_gpu(video_path, frames_dir, target_pages):
47
+ """
48
+ Extracts frames for a 4-panel layout (2x2 grid).
49
+ Target: 4 frames per page.
50
+ """
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
  cap = cv2.VideoCapture(video_path)
57
+ fps = 25
58
  total_frames = 0
59
  duration = 0
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("❌ Video load failed. Using placeholders.")
67
 
68
+ # 2. Calculate frames needed (4 per page)
69
+ panels_per_page = 4
70
  total_panels_needed = target_pages * panels_per_page
71
+
72
  frame_files_ordered = []
73
 
74
+ # 3. Extract Frames
75
  if duration > 0 and total_frames > 0:
76
  times = np.linspace(1, max(1, duration - 1), total_panels_needed)
77
+
78
  for i, t in enumerate(times):
79
  cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000)
80
  ret, frame = cap.read()
81
  fname = f"frame_{i:04d}.png"
82
+
83
  if ret and frame is not None:
84
+ # Crop to square-ish for 2x2 grid
85
  h, w = frame.shape[:2]
86
+ min_dim = min(h, w)
87
+ start_x = (w - min_dim) // 2
88
+ start_y = (h - min_dim) // 2
89
+ frame = frame[start_y:start_y+min_dim, start_x:start_x+min_dim]
90
+
91
+ # Resize for consistency
92
+ frame = cv2.resize(frame, (800, 800))
93
+
94
+ cv2.imwrite(os.path.join(frames_dir, fname), frame)
95
  frame_files_ordered.append(fname)
96
  else:
97
  create_placeholder_image(f"Error {t:.1f}s", fname, frames_dir)
98
  frame_files_ordered.append(fname)
99
  cap.release()
100
  else:
101
+ # Fallback loop
102
  for i in range(total_panels_needed):
103
  fname = f"placeholder_{i}.png"
104
  create_placeholder_image(f"Panel {i+1}", fname, frames_dir)
105
  frame_files_ordered.append(fname)
106
 
107
+ # 4. Build Page Data
108
  pages_data = []
109
  for i in range(target_pages):
110
  start = i * panels_per_page
111
  end = start + panels_per_page
112
  p_frames = frame_files_ordered[start:end]
113
 
114
+ # Fill missing if any
115
+ while len(p_frames) < 4:
116
+ fname = f"extra_{len(p_frames)}.png"
117
+ create_placeholder_image("Empty", fname, frames_dir)
118
  p_frames.append(fname)
119
 
120
+ pg_panels = [{'image': f} for f in p_frames]
121
+
122
  pg_bubbles = []
123
  if i == 0:
124
+ pg_bubbles.append({'dialog': "Drag the CENTER DOT\nto adjust grid!", 'x': 50, 'y': 50})
125
 
126
+ pages_data.append({
127
+ 'panels': pg_panels,
128
+ 'bubbles': pg_bubbles,
129
+ 'splitX': '50%', # Center X
130
+ 'splitY': '50%' # Center Y
131
+ })
132
 
133
  return pages_data
134
 
135
+ class ComicGenHost:
136
  def __init__(self, sid):
137
  self.sid = sid
138
  self.user_dir = os.path.join(BASE_USER_DIR, sid)
139
+ self.video_path = os.path.join(self.user_dir, 'video.mp4')
140
  self.frames_dir = os.path.join(self.user_dir, 'frames')
141
  self.output_dir = os.path.join(self.user_dir, 'output')
142
+
143
  os.makedirs(self.user_dir, exist_ok=True)
144
  os.makedirs(self.frames_dir, exist_ok=True)
145
  os.makedirs(self.output_dir, exist_ok=True)
146
 
147
+ def run(self, pages):
148
  try:
149
+ self.write_status("Generating...", 30)
150
+ data = generate_comic_gpu(self.video_path, self.frames_dir, int(pages))
151
+
152
+ with open(os.path.join(self.output_dir, 'data.json'), 'w') as f:
153
+ json.dump(data, f)
154
+
155
+ self.write_status("Ready", 100)
156
  except Exception as e:
157
  traceback.print_exc()
158
+ self.write_status(f"Error: {e}", -1)
159
 
160
  def write_status(self, msg, prog):
161
  with open(os.path.join(self.output_dir, 'status.json'), 'w') as f:
162
  json.dump({'message': msg, 'progress': prog}, f)
163
 
164
  # ======================================================
165
+ # 🌐 FRONTEND
166
  # ======================================================
167
  INDEX_HTML = '''
168
  <!DOCTYPE html>
 
170
  <head>
171
  <meta charset="UTF-8">
172
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
173
+ <title>4-Panel Comic Generator</title>
174
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
175
+ <link href="https://fonts.googleapis.com/css2?family=Bangers&display=swap" rel="stylesheet">
176
  <style>
177
+ body { background: #121212; color: #eee; font-family: sans-serif; margin: 0; text-align: center; }
178
+
179
+ /* UPLOAD */
180
+ #upload-view { padding: 50px; }
181
+ .box { background: #1e1e1e; display: inline-block; padding: 40px; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.5); }
182
+ button { background: #f1c40f; border: none; padding: 10px 20px; font-weight: bold; cursor: pointer; border-radius: 4px; font-size: 16px; margin-top: 10px; }
183
+ button:hover { background: #f39c12; }
184
+ input { padding: 10px; margin: 5px; border-radius: 4px; border: 1px solid #555; background: #333; color: white; }
185
+
186
+ /* EDITOR */
187
+ #editor-view { display: none; padding: 20px; }
188
+ .comic-container { display: flex; flex-wrap: wrap; justify-content: center; gap: 40px; margin-top: 20px; }
189
+
190
+ /* PAGE */
191
  .comic-page {
192
+ width: 600px; height: 800px;
193
+ background: white;
194
+ border: 4px solid #000;
195
+ position: relative;
196
+ box-shadow: 0 0 20px rgba(0,0,0,0.5);
197
+ user-select: none;
198
+ overflow: hidden; /* Clean edges */
199
  }
200
+
201
+ /* 2x2 GRID LOGIC */
202
  .comic-grid {
203
+ width: 100%; height: 100%;
204
+ position: relative;
205
+ background: #000; /* Gutter color */
206
+ --x: 50%;
207
+ --y: 50%;
208
  }
209
+
210
  .panel {
211
+ position: absolute;
212
+ overflow: hidden;
213
+ background: #333;
214
+ border: 2px solid black; /* Inner borders */
215
+ box-sizing: border-box;
216
  }
217
+
218
+ .panel img {
219
+ width: 100%; height: 100%;
220
+ object-fit: cover;
221
+ pointer-events: auto; /* Allow image dragging if added later */
222
+ }
223
+
224
+ /* POSITIONS BASED ON CSS VARIABLES */
225
+ /* Top Left */
226
+ .panel:nth-child(1) { left: 0; top: 0; width: var(--x); height: var(--y); }
227
+ /* Top Right */
228
+ .panel:nth-child(2) { left: var(--x); top: 0; width: calc(100% - var(--x)); height: var(--y); }
229
+ /* Bottom Left */
230
+ .panel:nth-child(3) { left: 0; top: var(--y); width: var(--x); height: calc(100% - var(--y)); }
231
+ /* Bottom Right */
232
+ .panel:nth-child(4) { left: var(--x); top: var(--y); width: calc(100% - var(--x)); height: calc(100% - var(--y)); }
233
+
234
+ /* CENTRAL HANDLE */
235
+ .grid-handle {
236
+ position: absolute;
237
+ width: 30px; height: 30px;
238
+ background: #e74c3c;
239
+ border: 3px solid white;
240
+ border-radius: 50%;
241
+ left: var(--x); top: var(--y);
242
+ transform: translate(-50%, -50%);
243
+ cursor: move;
244
+ z-index: 999;
245
+ box-shadow: 0 4px 10px rgba(0,0,0,0.5);
246
+ pointer-events: auto;
247
  }
248
+ .grid-handle:hover { transform: translate(-50%, -50%) scale(1.1); }
249
+
250
+ /* BUBBLES */
251
  .bubble {
252
+ position: absolute;
253
+ background: white; color: black;
254
+ padding: 10px 15px; border-radius: 20px;
255
+ font-family: 'Bangers', cursive;
256
+ letter-spacing: 1px;
257
+ border: 2px solid black;
258
+ z-index: 100; cursor: move;
259
+ transform: translate(-50%, -50%);
260
+ min-width: 50px; text-align: center;
261
+ }
262
+ .bubble:after {
263
+ content: ''; position: absolute;
264
+ bottom: -10px; left: 50%; transform: translateX(-50%);
265
+ border-width: 10px 10px 0; border-style: solid;
266
+ border-color: black transparent transparent transparent;
267
+ }
268
+
269
+ /* CONTROLS */
270
+ .toolbar {
271
+ position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
272
+ background: #333; padding: 10px 20px; border-radius: 50px;
273
+ display: flex; gap: 15px; z-index: 1000; box-shadow: 0 5px 20px rgba(0,0,0,0.6);
274
  }
275
  </style>
276
  </head>
277
  <body>
278
+
279
+ <div id="upload-view">
280
+ <div class="box">
281
+ <h1>🎞️ 4-Panel Comic Maker</h1>
282
+ <p>Upload a video to create a 2x2 Comic Page.</p>
283
+ <input type="file" id="fileIn" accept="video/*"><br>
284
+ <label>Pages:</label> <input type="number" id="pgCount" value="1" min="1" max="5" style="width:50px;">
285
+ <br>
286
+ <button onclick="startUpload()">Generate</button>
287
+ <p id="status" style="color: #bbb; margin-top:10px;"></p>
288
  </div>
289
+ </div>
290
+
291
+ <div id="editor-view">
292
+ <h2>Adjust the Crosshair to resize panels!</h2>
293
+ <div class="comic-container" id="container"></div>
294
+
295
+ <div class="toolbar">
296
+ <button onclick="addBubble()">💬 Add Text</button>
297
+ <button onclick="downloadAll()">💾 Download</button>
298
+ <button style="background:#555" onclick="location.reload()">↺ Reset</button>
299
  </div>
300
  </div>
301
+
302
  <script>
303
  let sid = 'S' + Date.now();
304
+ let dragItem = null; // 'handle' or 'bubble'
305
+ let activeEl = null;
306
 
307
+ async function startUpload() {
308
  let f = document.getElementById('fileIn').files[0];
309
+ if(!f) return alert("Select a video.");
310
+
311
  let fd = new FormData();
312
  fd.append('file', f);
313
+ fd.append('pages', document.getElementById('pgCount').value);
314
+
315
+ document.getElementById('status').innerText = "Uploading & Processing (GPU)...";
316
+
317
+ let r = await fetch(`/upload?sid=${sid}`, {method:'POST', body:fd});
318
+ if(r.ok) monitorStatus();
319
  }
320
+
321
+ function monitorStatus() {
322
+ let t = setInterval(async () => {
323
  let r = await fetch(`/status?sid=${sid}`);
324
  let d = await r.json();
325
+ document.getElementById('status').innerText = d.message;
326
+ if(d.progress === 100) {
327
+ clearInterval(t);
328
+ loadEditor();
329
  }
330
+ }, 1000);
331
  }
332
+
333
  async function loadEditor() {
334
+ document.getElementById('upload-view').style.display='none';
335
+ document.getElementById('editor-view').style.display='block';
336
+
337
+ let r = await fetch(`/output/data.json?sid=${sid}`);
338
+ let data = await r.json();
339
+
340
+ let con = document.getElementById('container');
341
  con.innerHTML = '';
342
+
343
+ data.forEach((pg, i) => {
344
+ let page = document.createElement('div');
345
+ page.className = 'comic-page';
346
+
347
+ let grid = document.createElement('div');
348
+ grid.className = 'comic-grid';
349
+ // Default split
350
+ grid.style.setProperty('--x', pg.splitX || '50%');
351
+ grid.style.setProperty('--y', pg.splitY || '50%');
352
+
353
+ // 4 Panels
354
  pg.panels.forEach(pan => {
355
+ let div = document.createElement('div');
356
+ div.className = 'panel';
357
+ // Timestamp avoids caching blank images
358
+ div.innerHTML = `<img src="/frames/${pan.image}?sid=${sid}&t=${Date.now()}">`;
359
+ grid.appendChild(div);
360
  });
361
+
362
+ // Handle
363
+ let handle = document.createElement('div');
364
+ handle.className = 'grid-handle';
365
+ handle.onmousedown = (e) => {
366
+ e.stopPropagation();
367
+ dragItem = 'handle';
368
+ activeEl = { handle: handle, grid: grid };
369
+ };
370
+ grid.appendChild(handle);
371
+
372
+ // Bubbles
373
  if(pg.bubbles) pg.bubbles.forEach(b => createBubble(b.dialog, grid));
374
+
375
+ page.appendChild(grid);
376
+ con.appendChild(page);
377
  });
378
  }
379
+
380
+ function createBubble(txt, parent) {
381
+ let b = document.createElement('div');
382
+ b.className = 'bubble';
383
+ b.contentEditable = true;
384
+ b.innerText = txt || "Text";
385
+ b.style.left = '50%'; b.style.top = '50%';
386
+
387
+ b.onmousedown = (e) => {
388
+ if(e.target !== b) return;
389
+ e.stopPropagation();
390
+ dragItem = 'bubble';
391
+ activeEl = b;
392
+ };
393
+
394
+ if(!parent) parent = document.querySelector('.comic-grid');
395
+ parent.appendChild(b);
396
  }
397
+
398
+ window.addBubble = () => createBubble("New Text");
399
+
400
+ // DRAGGING LOGIC
401
  document.addEventListener('mousemove', (e) => {
402
+ if(!dragItem) return;
403
+
404
+ if(dragItem === 'handle') {
405
+ let rect = activeEl.grid.getBoundingClientRect();
406
+ let x = e.clientX - rect.left;
407
+ let y = e.clientY - rect.top;
408
+
409
+ // Constraints (10% to 90%)
410
+ let px = Math.max(10, Math.min(90, (x / rect.width) * 100));
411
+ let py = Math.max(10, Math.min(90, (y / rect.height) * 100));
412
+
413
+ activeEl.grid.style.setProperty('--x', px + '%');
414
+ activeEl.grid.style.setProperty('--y', py + '%');
415
+ }
416
+ else if(dragItem === 'bubble') {
417
+ let rect = activeEl.parentElement.getBoundingClientRect();
418
+ activeEl.style.left = (e.clientX - rect.left) + 'px';
419
+ activeEl.style.top = (e.clientY - rect.top) + 'px';
420
  }
421
  });
422
+
423
+ document.addEventListener('mouseup', () => {
424
+ dragItem = null;
425
+ activeEl = null;
426
+ });
427
+
428
  window.downloadAll = async () => {
429
  let pgs = document.querySelectorAll('.comic-page');
430
  for(let i=0; i<pgs.length; i++) {
431
+ // Hide handles for screenshot
432
+ let handles = pgs[i].querySelectorAll('.grid-handle');
433
+ handles.forEach(h => h.style.display = 'none');
434
+
435
  let url = await htmlToImage.toPng(pgs[i]);
436
+ let a = document.createElement('a');
437
+ a.download = `comic_page_${i+1}.png`;
438
+ a.href = url;
439
+ a.click();
440
+
441
+ handles.forEach(h => h.style.display = 'block');
442
  }
443
  };
444
  </script>
 
453
  def index():
454
  return INDEX_HTML
455
 
456
+ @app.route('/upload', methods=['POST'])
457
+ def upload():
458
  sid = request.args.get('sid')
459
  f = request.files['file']
460
+ pages = request.form.get('pages', 1)
461
+
462
+ host = ComicGenHost(sid)
463
+ f.save(host.video_path)
464
+
465
+ threading.Thread(target=host.run, args=(pages,)).start()
466
+ return jsonify({'ok': True})
467
 
468
  @app.route('/status')
469
  def status():
470
  sid = request.args.get('sid')
471
  p = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
472
  if os.path.exists(p): return send_file(p)
473
+ return jsonify({'progress': 0, 'message': 'Waiting...'})
474
 
475
  @app.route('/frames/<path:filename>')
476
  def frames(filename):
477
  sid = request.args.get('sid')
478
  resp = send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
479
+ resp.headers['Cache-Control'] = 'no-store'
480
  return resp
481
 
482
  @app.route('/output/<path:filename>')
 
485
  return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
486
 
487
  if __name__ == '__main__':
488
+ # Standard HF Spaces port
 
 
 
 
 
489
  app.run(host='0.0.0.0', port=7860)