tester343 commited on
Commit
29dc23e
·
verified ·
1 Parent(s): 3dd5234

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +295 -149
app_enhanced.py CHANGED
@@ -4,8 +4,12 @@ import time
4
  import threading
5
  import json
6
  import traceback
 
 
 
7
  import shutil
8
  import cv2
 
9
  import numpy as np
10
  import srt
11
  from flask import Flask, jsonify, request, send_from_directory, send_file
@@ -16,17 +20,27 @@ from flask import Flask, jsonify, request, send_from_directory, send_file
16
  @spaces.GPU
17
  def gpu_warmup():
18
  import torch
19
- return torch.cuda.is_available()
 
20
 
21
  # ======================================================
22
- # 💾 STORAGE SETUP
23
  # ======================================================
24
- BASE_STORAGE_PATH = '/data' if os.path.exists('/data') else '.'
 
 
 
 
 
 
25
  BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
 
 
26
  os.makedirs(BASE_USER_DIR, exist_ok=True)
 
27
 
28
  # ======================================================
29
- # 🔧 JSON SANITIZER (FIX FOR int64 SERIALIZATION ERROR)
30
  # ======================================================
31
  def sanitize_json(obj):
32
  if isinstance(obj, dict):
@@ -42,17 +56,61 @@ def sanitize_json(obj):
42
  return obj
43
 
44
  # ======================================================
45
- # 🧠 CORE GPU GENERATOR
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  # ======================================================
47
  @spaces.GPU(duration=300)
48
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
 
 
 
 
49
  from backend.keyframes.keyframes import black_bar_crop
50
  from backend.simple_color_enhancer import SimpleColorEnhancer
 
51
  from backend.subtitles.subs_real import get_real_subtitles
52
  from backend.ai_bubble_placement import ai_bubble_placer
53
  from backend.ai_enhanced_core import face_detector
54
 
55
  cap = cv2.VideoCapture(video_path)
 
56
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
57
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
58
  duration = total_frames / fps
@@ -61,150 +119,207 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
61
  user_srt = os.path.join(user_dir, 'subs.srt')
62
  try:
63
  get_real_subtitles(video_path)
64
- if os.path.exists('test1.srt'): shutil.move('test1.srt', user_srt)
 
65
  except:
66
- with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:05,000\nDialogue...\n")
67
-
68
  with open(user_srt, 'r', encoding='utf-8') as f:
69
  try: all_subs = list(srt.parse(f.read()))
70
  except: all_subs = []
71
 
 
 
 
 
72
  panels_per_page = 5
73
  target_pages = int(target_pages)
74
- total_needed = target_pages * panels_per_page
75
-
76
- if not all_subs:
77
- times = np.linspace(1, max(1.1, duration-1), total_needed)
78
- moments = [{'text': '', 'start': t} for t in times]
79
- elif len(all_subs) <= total_needed:
80
- moments = [{'text': s.content, 'start': s.start.total_seconds()} for s in all_subs]
81
- while len(moments) < total_needed: moments.append({'text': '', 'start': duration/2})
 
 
82
  else:
83
- indices = np.linspace(0, len(all_subs) - 1, total_needed, dtype=int)
84
- moments = [{'text': all_subs[i].content, 'start': all_subs[i].start.total_seconds()} for i in indices]
85
-
86
  frame_metadata = {}
87
  cap = cv2.VideoCapture(video_path)
88
- frame_files = []
89
- for i, m in enumerate(moments):
90
- cap.set(cv2.CAP_PROP_POS_MSEC, m['start'] * 1000)
 
 
 
91
  ret, frame = cap.read()
92
  if ret:
93
- fname = f"frame_{i:04d}.png"
94
  p = os.path.join(frames_dir, fname)
95
  cv2.imwrite(p, frame)
96
- frame_metadata[fname] = {'dialogue': m['text'], 'time': m['start']}
97
- frame_files.append(fname)
 
98
  cap.release()
99
 
100
- with open(metadata_path, 'w') as f:
101
  json.dump(sanitize_json(frame_metadata), f)
102
-
103
  try: black_bar_crop()
104
  except: pass
105
 
 
106
  se = SimpleColorEnhancer()
107
- pages_data = []
108
- for p_idx in range(target_pages):
109
- p_p, p_b = [], []
110
- start = p_idx * 5
111
- for i in range(start, start + 5):
112
- if i >= len(frame_files): break
113
- f_name = frame_files[i]
114
- img_p = os.path.join(frames_dir, f_name)
115
- try: se.enhance_single(img_p, img_p)
116
- except: pass
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
- txt = frame_metadata[f_name]['dialogue']
119
- try:
120
- faces = face_detector.detect_faces(img_p)
121
- lip = face_detector.get_lip_position(img_p, faces[0]) if faces else (-1, -1)
122
- bx, by = ai_bubble_placer.place_bubble_ai(img_p, lip)
123
- item = {'dialog': txt, 'x': bx, 'y': by}
124
- except:
125
- item = {'dialog': txt, 'x': 50, 'y': 25}
 
 
 
126
 
127
- p_p.append({'image': f_name})
128
- p_b.append(item)
129
- pages_data.append({'panels': p_p, 'bubbles': p_b})
130
 
131
- return sanitize_json(pages_data)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
  # ======================================================
134
- # 🔧 APP ENGINE
135
  # ======================================================
136
- app = Flask(__name__)
137
-
138
- @app.route('/')
139
- def index(): return INDEX_HTML
140
-
141
- @app.route('/uploader', methods=['POST'])
142
- def uploader():
143
- sid = request.args.get('sid')
144
- u_dir = os.path.join(BASE_USER_DIR, sid)
145
- f_dir = os.path.join(u_dir, 'frames'); o_dir = os.path.join(u_dir, 'output')
146
- os.makedirs(f_dir, exist_ok=True); os.makedirs(o_dir, exist_ok=True)
147
- vid_p = os.path.join(u_dir, 'video.mp4')
148
- request.files['file'].save(vid_p)
149
- pages = request.form.get('pages', 2)
150
-
151
- def task():
 
152
  try:
153
- with open(os.path.join(o_dir, 'status.json'), 'w') as f:
154
- json.dump({'message': 'Generating Pro Geometry...', 'progress': 30}, f)
155
- data = generate_comic_gpu(vid_p, u_dir, f_dir, os.path.join(f_dir, 'meta.json'), pages)
156
- with open(os.path.join(o_dir, 'pages.json'), 'w') as f: json.dump(data, f)
157
- with open(os.path.join(o_dir, 'status.json'), 'w') as f:
158
- json.dump({'message': 'Complete', 'progress': 100}, f)
159
  except Exception as e:
160
- with open(os.path.join(o_dir, 'status.json'), 'w') as f:
161
- json.dump({'message': f'Error: {str(e)}', 'progress': -1}, f)
162
-
163
- threading.Thread(target=task).start()
164
- return jsonify({'success': True})
165
-
166
- @app.route('/status')
167
- def status():
168
- sid = request.args.get('sid')
169
- p = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
170
- if os.path.exists(p): return send_file(p)
171
- return jsonify({'progress': 0, 'message': 'Initializing...'})
172
 
173
- @app.route('/frames/<sid>/<path:filename>')
174
- def get_frame(sid, filename):
175
- return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
176
-
177
- @app.route('/output/<sid>/<path:filename>')
178
- def get_output(sid, filename):
179
- return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
180
 
181
  # ======================================================
182
- # 🌐 UI HTML (100% ZOOM INTEGRATED)
183
  # ======================================================
184
  INDEX_HTML = '''
185
  <!DOCTYPE html><html lang="en">
186
  <head>
187
- <meta charset="UTF-8"><title>Pro Comic Generator (100% Zoom)</title>
188
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
189
  <link href="https://fonts.googleapis.com/css2?family=Comic+Neue:wght@700&family=Lato:wght@400;900&display=swap" rel="stylesheet">
190
  <style>
 
191
  :root {
192
  --w: 1000px;
193
  --h: 700px;
194
  --tierY: 350px;
195
- --gut: 5.25px;
 
196
 
197
  --r1-topX: 641.2px;
198
  --r1-botX: 588.2px;
 
199
  --r2L-topX: 284.2px;
200
  --r2L-botX: 314.2px;
 
201
  --r2R-topX: 618.2px;
202
  --r2R-botX: 678.2px;
203
  }
204
 
205
- body { background: #0a0a0b; font-family: 'Lato', sans-serif; margin: 0; padding: 20px; color: white; }
206
- .setup-box { max-width: 450px; margin: 80px auto; background: white; padding: 40px; border-radius: 12px; color: black; text-align: center; }
207
 
 
 
 
 
 
 
208
  .comic-page {
209
  background: white; width: var(--w); height: var(--h); margin: 40px auto;
210
  border: 12px solid black; position: relative; overflow: hidden;
@@ -212,124 +327,155 @@ INDEX_HTML = '''
212
  }
213
 
214
  .panel { position: absolute; background: #000; overflow: hidden; cursor: pointer; border: 1px solid black; }
215
-
216
- /* ⚡ 100% ZOOM SETTINGS ⚡ */
217
  .panel img {
218
- width: 100%;
219
- height: 100%;
220
- object-fit: cover;
221
- position: absolute;
222
- top: 0;
223
- left: 0;
224
- pointer-events: none;
225
  }
226
 
227
- /* ROW 1 */
228
  .p1 { top: 0; left: 0; width: 100%; height: var(--tierY); clip-path: polygon(0 0, calc(var(--r1-topX) - var(--gut)) 0, calc(var(--r1-botX) - var(--gut)) var(--tierY), 0 var(--tierY)); }
229
  .p2 { top: 0; left: 0; width: 100%; height: var(--tierY); clip-path: polygon(calc(var(--r1-topX) + var(--gut)) 0, var(--w) 0, var(--w) var(--tierY), calc(var(--r1-botX) + var(--gut)) var(--tierY)); }
230
 
231
- /* ROW 2 */
232
  .p3 { top: calc(var(--tierY) + var(--gut)); left: 0; width: 100%; height: calc(var(--h) - var(--tierY)); clip-path: polygon(0 0, calc(var(--r2L-topX) - var(--gut)) 0, calc(var(--r2L-botX) - var(--gut)) 100%, 0 100%); }
233
  .p4 { top: calc(var(--tierY) + var(--gut)); left: 0; width: 100%; height: calc(var(--h) - var(--tierY)); clip-path: polygon(calc(var(--r2L-topX) + var(--gut)) 0, calc(var(--r2R-topX) - var(--gut)) 0, calc(var(--r2R-botX) - var(--gut)) 100%, calc(var(--r2L-botX) + var(--gut)) 100%); }
234
  .p5 { top: calc(var(--tierY) + var(--gut)); left: 0; width: 100%; height: calc(var(--h) - var(--tierY)); clip-path: polygon(calc(var(--r2R-topX) + var(--gut)) 0, var(--w) 0, var(--w) 100%, calc(var(--r2R-botX) + var(--gut)) 100%); }
235
 
236
- .bubble {
 
237
  position: absolute; background: white; border: 2.5px solid black; border-radius: 25px;
238
  padding: 10px 18px; font-family: 'Comic Neue'; font-weight: bold; font-size: 15px;
239
  color: black; min-width: 110px; text-align: center; cursor: move; z-index: 100;
240
  box-shadow: 4px 4px 0 rgba(0,0,0,0.1);
241
  }
242
- .bubble::after { content: ""; position: absolute; bottom: -15px; left: 30px; border-left: 10px solid transparent; border-right: 10px solid transparent; border-top: 15px solid black; }
243
- .bubble::before { content: ""; position: absolute; bottom: -11px; left: 31px; border-left: 9px solid transparent; border-right: 9px solid transparent; border-top: 14px solid white; z-index: 2; }
 
244
 
245
- .controls { position: fixed; bottom: 20px; right: 20px; background: #000; padding: 25px; border-radius: 12px; width: 240px; border: 2px solid #333; z-index: 2000; }
246
  button { width: 100%; padding: 12px; margin-top: 10px; cursor: pointer; font-weight: bold; border-radius: 6px; border: none; }
247
- .btn-gen { background: #00d2ff; color: black; }
248
  .hidden { display: none; }
249
- .loader { border: 5px solid #333; border-top: 5px solid #00d2ff; border-radius: 50%; width: 35px; height: 35px; animation: spin 1s linear infinite; margin: 20px auto; }
 
250
  </style>
251
  </head>
252
  <body>
253
- <div id="u-zone" class="setup-box">
254
- <h1>🎬 Pro Comic Maker</h1>
255
- <p>100% Zoom / Zero Crop Mode</p>
256
- <input type="file" id="vid" accept="video/mp4"><br><br>
257
- <label>Total Pages: </label><input type="number" id="pg" value="2" style="width:40px"><br><br>
258
- <button class="btn-gen" onclick="start()">🚀 GENERATE COMIC</button>
259
- <div id="loading" class="hidden"><div class="loader"></div><p id="st">Working...</p></div>
 
260
  </div>
261
 
262
- <div id="editor-zone" class="hidden">
263
- <div id="output"></div>
264
- <div class="controls">
265
- <button onclick="addB()" style="background:#2ecc71; color:white;">💬 Add Bubble</button>
266
  <button onclick="exportPNG()" style="background:#3498db; color:white;">📥 Download PNGs</button>
267
- <button onclick="location.reload()" style="background:#e74c3c; color:white;">🏠 Reset</button>
268
  </div>
269
  </div>
270
 
271
  <script>
272
  let sid = 's' + Math.random().toString(36).substr(2,9);
273
- let selP = null;
274
 
275
- async function start() {
276
- const f = document.getElementById('vid').files[0];
277
- if(!f) return alert("Select video!");
278
- document.getElementById('loading').classList.remove('hidden');
279
- const fd = new FormData(); fd.append('file', f); fd.append('pages', document.getElementById('pg').value);
280
  await fetch(`/uploader?sid=${sid}`, {method: 'POST', body: fd});
281
  const itv = setInterval(async () => {
282
  const r = await fetch(`/status?sid=${sid}`); const d = await r.json();
283
- document.getElementById('st').innerText = d.message || "Working...";
284
- if(d.progress >= 100) { clearInterval(itv); load(); }
285
- }, 2000);
286
  }
287
 
288
- async function load() {
289
  const r = await fetch(`/output/${sid}/pages.json`); const pages = await r.json();
290
- document.getElementById('u-zone').classList.add('hidden');
291
- document.getElementById('editor-zone').classList.remove('hidden');
292
- const out = document.getElementById('output');
293
  pages.forEach(p => {
294
  const pgDiv = document.createElement('div'); pgDiv.className = 'comic-page';
295
  p.panels.forEach((pan, i) => {
296
  const pDiv = document.createElement('div'); pDiv.className = 'panel p' + (i+1);
297
- pDiv.onclick = (e) => { e.stopPropagation(); selP=pDiv; };
298
  const img = document.createElement('img'); img.src = `/frames/${sid}/${pan.image}`;
299
  pDiv.appendChild(img);
300
- if(p.bubbles[i]) pDiv.appendChild(createB(p.bubbles[i].dialog, p.bubbles[i].x, p.bubbles[i].y));
301
  pgDiv.appendChild(pDiv);
302
  });
303
  out.appendChild(pgDiv);
304
  });
305
  }
306
 
307
- function createB(txt, x, y) {
308
- const b = document.createElement('div'); b.className = 'bubble';
309
- b.innerText = txt || '...'; b.style.left = (x || 50) + 'px'; b.style.top = (y || 20) + 'px';
 
310
  b.onmousedown = (e) => {
311
  e.stopPropagation();
312
- let ox = e.clientX - b.offsetLeft, oy = e.clientY - b.offsetTop;
313
- document.onmousemove = (ev) => { b.style.left=(ev.clientX-ox)+'px'; b.style.top=(ev.clientY-oy)+'px'; };
314
- document.onmouseup = () => { document.onmousemove = null; };
315
  };
316
- b.ondblclick = () => { let n = prompt("Edit text:", b.innerText); if(n) b.innerText = n; };
317
  return b;
318
  }
319
 
320
- function addB() { if(selP) selP.appendChild(createB("Dialogue", 60, 60)); }
 
 
 
321
 
322
  async function exportPNG() {
323
  const pgs = document.querySelectorAll('.comic-page');
324
  for(let pg of pgs) {
325
  const url = await htmlToImage.toPng(pg, {pixelRatio: 3});
326
- const l = document.createElement('a'); l.download='ComicPage.png'; l.href=url; l.click();
327
  }
328
  }
329
  </script>
330
  </body></html>
331
  '''
332
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  if __name__ == '__main__':
334
  try: gpu_warmup()
335
  except: pass
 
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
 
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/Local Storage")
35
+
36
  BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
37
+ SAVED_COMICS_DIR = os.path.join(BASE_STORAGE_PATH, "saved_comics")
38
+
39
  os.makedirs(BASE_USER_DIR, exist_ok=True)
40
+ os.makedirs(SAVED_COMICS_DIR, exist_ok=True)
41
 
42
  # ======================================================
43
+ # 🔧 UTILS & SANITIZATION (FIX FOR int64 ERROR)
44
  # ======================================================
45
  def sanitize_json(obj):
46
  if isinstance(obj, dict):
 
56
  return obj
57
 
58
  # ======================================================
59
+ # 🧱 DATA CLASSES
60
+ # ======================================================
61
+ def bubble(dialog="", bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, emotion='normal', type='speech'):
62
+ return {
63
+ 'dialog': dialog,
64
+ 'bubble_offset_x': int(bubble_offset_x),
65
+ 'bubble_offset_y': int(bubble_offset_y),
66
+ 'lip_x': int(lip_x),
67
+ 'lip_y': int(lip_y),
68
+ 'emotion': emotion,
69
+ 'type': type,
70
+ 'tail_pos': '50%',
71
+ 'classes': f'speech-bubble {type} tail-bottom'
72
+ }
73
+
74
+ def panel(image=""):
75
+ return {'image': image}
76
+
77
+ class Page:
78
+ def __init__(self, panels, bubbles):
79
+ self.panels = panels
80
+ self.bubbles = bubbles
81
+
82
+ # ======================================================
83
+ # 🔧 APP CONFIG
84
+ # ======================================================
85
+ logging.basicConfig(level=logging.INFO)
86
+ logger = logging.getLogger(__name__)
87
+ app = Flask(__name__)
88
+
89
+ def generate_save_code(length=8):
90
+ chars = string.ascii_uppercase + string.digits
91
+ while True:
92
+ code = ''.join(random.choices(chars, k=length))
93
+ if not os.path.exists(os.path.join(SAVED_COMICS_DIR, code)):
94
+ return code
95
+
96
+ # ======================================================
97
+ # 🧠 GLOBAL GPU FUNCTIONS
98
  # ======================================================
99
  @spaces.GPU(duration=300)
100
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
101
+ # Heavy AI imports inside to prevent startup timeout
102
+ import cv2
103
+ import srt
104
+ import numpy as np
105
  from backend.keyframes.keyframes import black_bar_crop
106
  from backend.simple_color_enhancer import SimpleColorEnhancer
107
+ from backend.quality_color_enhancer import QualityColorEnhancer
108
  from backend.subtitles.subs_real import get_real_subtitles
109
  from backend.ai_bubble_placement import ai_bubble_placer
110
  from backend.ai_enhanced_core import face_detector
111
 
112
  cap = cv2.VideoCapture(video_path)
113
+ if not cap.isOpened(): raise Exception("Cannot open video")
114
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
115
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
116
  duration = total_frames / fps
 
119
  user_srt = os.path.join(user_dir, 'subs.srt')
120
  try:
121
  get_real_subtitles(video_path)
122
+ if os.path.exists('test1.srt'):
123
+ shutil.move('test1.srt', user_srt)
124
  except:
125
+ with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
126
+
127
  with open(user_srt, 'r', encoding='utf-8') as f:
128
  try: all_subs = list(srt.parse(f.read()))
129
  except: all_subs = []
130
 
131
+ valid_subs = [s for s in all_subs if s.content.strip()]
132
+ raw_moments = [{'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
133
+
134
+ # SETTING 5 PANELS PER PAGE
135
  panels_per_page = 5
136
  target_pages = int(target_pages)
137
+ total_panels_needed = target_pages * panels_per_page
138
+
139
+ selected_moments = []
140
+ if not raw_moments:
141
+ times = np.linspace(1, max(1.1, duration-1), total_panels_needed)
142
+ for t in times: selected_moments.append({'text': '', 'start': t, 'end': t+1})
143
+ elif len(raw_moments) <= total_panels_needed:
144
+ selected_moments = raw_moments
145
+ while len(selected_moments) < total_panels_needed:
146
+ selected_moments.append({'text': '', 'start': duration/2, 'end': duration/2+1})
147
  else:
148
+ indices = np.linspace(0, len(raw_moments) - 1, total_panels_needed, dtype=int)
149
+ selected_moments = [raw_moments[i] for i in indices]
150
+
151
  frame_metadata = {}
152
  cap = cv2.VideoCapture(video_path)
153
+ count = 0
154
+ frame_files_ordered = []
155
+
156
+ for i, moment in enumerate(selected_moments):
157
+ mid = (moment['start'] + moment['end']) / 2
158
+ cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
159
  ret, frame = cap.read()
160
  if ret:
161
+ fname = f"frame_{count:04d}.png"
162
  p = os.path.join(frames_dir, fname)
163
  cv2.imwrite(p, frame)
164
+ frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
165
+ frame_files_ordered.append(fname)
166
+ count += 1
167
  cap.release()
168
 
169
+ with open(metadata_path, 'w') as f:
170
  json.dump(sanitize_json(frame_metadata), f)
171
+
172
  try: black_bar_crop()
173
  except: pass
174
 
175
+ # AI Enhancement
176
  se = SimpleColorEnhancer()
177
+ qe = QualityColorEnhancer()
178
+
179
+ for f in frame_files_ordered:
180
+ p = os.path.join(frames_dir, f)
181
+ try: se.enhance_single(p, p)
182
+ except: pass
183
+ try: qe.enhance_single(p, p)
184
+ except: pass
185
+
186
+ bubbles_list = []
187
+ for f in frame_files_ordered:
188
+ p = os.path.join(frames_dir, f)
189
+ dialogue = frame_metadata.get(f, {}).get('dialogue', '')
190
+
191
+ try:
192
+ faces = face_detector.detect_faces(p)
193
+ lip = face_detector.get_lip_position(p, faces[0]) if faces else (-1, -1)
194
+ bx, by = ai_bubble_placer.place_bubble_ai(p, lip)
195
+ b = bubble(dialog=dialogue, bubble_offset_x=bx, bubble_offset_y=by, lip_x=lip[0], lip_y=lip[1])
196
+ bubbles_list.append(b)
197
+ except:
198
+ bubbles_list.append(bubble(dialog=dialogue))
199
 
200
+ pages = []
201
+ for i in range(target_pages):
202
+ start_idx = i * 5
203
+ end_idx = start_idx + 5
204
+ p_frames = frame_files_ordered[start_idx:end_idx]
205
+ p_bubbles = bubbles_list[start_idx:end_idx]
206
+ if p_frames:
207
+ pages.append({
208
+ 'panels': [panel(image=f).__dict__ for f in p_frames],
209
+ 'bubbles': [b if isinstance(b, dict) else b.__dict__ for b in p_bubbles]
210
+ })
211
 
212
+ return sanitize_json(pages)
 
 
213
 
214
+ @spaces.GPU
215
+ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
216
+ import cv2
217
+ import json
218
+ from backend.simple_color_enhancer import SimpleColorEnhancer
219
+ with open(metadata_path, 'r') as f: meta = json.load(f)
220
+ t = meta[fname]['time']
221
+ cap = cv2.VideoCapture(video_path)
222
+ fps = cap.get(cv2.CAP_PROP_FPS) or 25
223
+ new_t = max(0, t + ((1.0/fps) * (1 if direction == 'forward' else -1)))
224
+ cap.set(cv2.CAP_PROP_POS_MSEC, new_t * 1000)
225
+ ret, frame = cap.read()
226
+ if ret:
227
+ cv2.imwrite(os.path.join(frames_dir, fname), frame)
228
+ SimpleColorEnhancer().enhance_single(os.path.join(frames_dir, fname), os.path.join(frames_dir, fname))
229
+ meta[fname]['time'] = new_t
230
+ with open(metadata_path, 'w') as f: json.dump(sanitize_json(meta), f)
231
+ return {"success": True}
232
+ return {"success": False}
233
+
234
+ @spaces.GPU
235
+ def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
236
+ import cv2
237
+ import json
238
+ from backend.simple_color_enhancer import SimpleColorEnhancer
239
+ cap = cv2.VideoCapture(video_path)
240
+ cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
241
+ ret, frame = cap.read()
242
+ if ret:
243
+ cv2.imwrite(os.path.join(frames_dir, fname), frame)
244
+ SimpleColorEnhancer().enhance_single(os.path.join(frames_dir, fname), os.path.join(frames_dir, fname))
245
+ if os.path.exists(metadata_path):
246
+ with open(metadata_path, 'r') as f: meta = json.load(f)
247
+ meta[fname]['time'] = float(ts)
248
+ with open(metadata_path, 'w') as f: json.dump(sanitize_json(meta), f)
249
+ return {"success": True}
250
+ return {"success": False}
251
 
252
  # ======================================================
253
+ # 💻 BACKEND MANAGER
254
  # ======================================================
255
+ class EnhancedComicGenerator:
256
+ def __init__(self, sid):
257
+ self.sid = sid
258
+ self.user_dir = os.path.join(BASE_USER_DIR, sid)
259
+ self.video_path = os.path.join(self.user_dir, 'uploaded.mp4')
260
+ self.frames_dir = os.path.join(self.user_dir, 'frames')
261
+ self.output_dir = os.path.join(self.user_dir, 'output')
262
+ os.makedirs(self.frames_dir, exist_ok=True)
263
+ os.makedirs(self.output_dir, exist_ok=True)
264
+ self.metadata_path = os.path.join(self.frames_dir, 'frame_metadata.json')
265
+
266
+ def cleanup(self):
267
+ if os.path.exists(self.user_dir): shutil.rmtree(self.user_dir)
268
+ os.makedirs(self.frames_dir, exist_ok=True)
269
+ os.makedirs(self.output_dir, exist_ok=True)
270
+
271
+ def run(self, target_pages):
272
  try:
273
+ self.write_status("GPU Engine Running...", 15)
274
+ data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, target_pages)
275
+ with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
276
+ json.dump(data, f)
277
+ self.write_status("Complete!", 100)
 
278
  except Exception as e:
279
+ traceback.print_exc()
280
+ self.write_status(f"Error: {str(e)}", -1)
 
 
 
 
 
 
 
 
 
 
281
 
282
+ def write_status(self, msg, prog):
283
+ with open(os.path.join(self.output_dir, 'status.json'), 'w') as f:
284
+ json.dump({'message': msg, 'progress': prog}, f)
 
 
 
 
285
 
286
  # ======================================================
287
+ # 🌐 UI (LATEST GEOMETRIC TEMPLATE)
288
  # ======================================================
289
  INDEX_HTML = '''
290
  <!DOCTYPE html><html lang="en">
291
  <head>
292
+ <meta charset="UTF-8"><title>Elite Geometric Comic Generator</title>
293
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
294
  <link href="https://fonts.googleapis.com/css2?family=Comic+Neue:wght@700&family=Lato:wght@400;900&display=swap" rel="stylesheet">
295
  <style>
296
+ /* ⚡ GEOMETRIC PARALLEL COORDINATES ⚡ */
297
  :root {
298
  --w: 1000px;
299
  --h: 700px;
300
  --tierY: 350px;
301
+ --gut: 5.25px; /* Half of 10.5px Gutter */
302
+ --hairline: 0.1px;
303
 
304
  --r1-topX: 641.2px;
305
  --r1-botX: 588.2px;
306
+
307
  --r2L-topX: 284.2px;
308
  --r2L-botX: 314.2px;
309
+
310
  --r2R-topX: 618.2px;
311
  --r2R-botX: 678.2px;
312
  }
313
 
314
+ * { box-sizing: border-box; }
315
+ body { background: #000; font-family: 'Lato', sans-serif; margin: 0; padding: 20px; color: white; }
316
 
317
+ #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 80vh; }
318
+ .upload-box { max-width: 450px; width: 100%; padding: 40px; background: white; border-radius: 12px; text-align: center; color: black; }
319
+
320
+ #editor-container { display: none; padding-bottom: 150px; }
321
+
322
+ /* THE PERFECT GEOMETRIC PAGE */
323
  .comic-page {
324
  background: white; width: var(--w); height: var(--h); margin: 40px auto;
325
  border: 12px solid black; position: relative; overflow: hidden;
 
327
  }
328
 
329
  .panel { position: absolute; background: #000; overflow: hidden; cursor: pointer; border: 1px solid black; }
 
 
330
  .panel img {
331
+ width: 112%; height: 112%; object-fit: cover; position: absolute;
332
+ top: -6%; left: -6%; object-position: center 15%; pointer-events: none;
 
 
 
 
 
333
  }
334
 
335
+ /* 📐 POLYGON CLIPPING 📐 */
336
  .p1 { top: 0; left: 0; width: 100%; height: var(--tierY); clip-path: polygon(0 0, calc(var(--r1-topX) - var(--gut)) 0, calc(var(--r1-botX) - var(--gut)) var(--tierY), 0 var(--tierY)); }
337
  .p2 { top: 0; left: 0; width: 100%; height: var(--tierY); clip-path: polygon(calc(var(--r1-topX) + var(--gut)) 0, var(--w) 0, var(--w) var(--tierY), calc(var(--r1-botX) + var(--gut)) var(--tierY)); }
338
 
 
339
  .p3 { top: calc(var(--tierY) + var(--gut)); left: 0; width: 100%; height: calc(var(--h) - var(--tierY)); clip-path: polygon(0 0, calc(var(--r2L-topX) - var(--gut)) 0, calc(var(--r2L-botX) - var(--gut)) 100%, 0 100%); }
340
  .p4 { top: calc(var(--tierY) + var(--gut)); left: 0; width: 100%; height: calc(var(--h) - var(--tierY)); clip-path: polygon(calc(var(--r2L-topX) + var(--gut)) 0, calc(var(--r2R-topX) - var(--gut)) 0, calc(var(--r2R-botX) - var(--gut)) 100%, calc(var(--r2L-botX) + var(--gut)) 100%); }
341
  .p5 { top: calc(var(--tierY) + var(--gut)); left: 0; width: 100%; height: calc(var(--h) - var(--tierY)); clip-path: polygon(calc(var(--r2R-topX) + var(--gut)) 0, var(--w) 0, var(--w) 100%, calc(var(--r2R-botX) + var(--gut)) 100%); }
342
 
343
+ /* BUBBLE STYLE - CAPSULE WITH BEAKED TAIL */
344
+ .speech-bubble {
345
  position: absolute; background: white; border: 2.5px solid black; border-radius: 25px;
346
  padding: 10px 18px; font-family: 'Comic Neue'; font-weight: bold; font-size: 15px;
347
  color: black; min-width: 110px; text-align: center; cursor: move; z-index: 100;
348
  box-shadow: 4px 4px 0 rgba(0,0,0,0.1);
349
  }
350
+ .speech-bubble::after { content: ""; position: absolute; bottom: -15px; left: 30px; border-left: 10px solid transparent; border-right: 10px solid transparent; border-top: 15px solid black; }
351
+ .speech-bubble::before { content: ""; position: absolute; bottom: -11px; left: 31px; border-left: 9px solid transparent; border-right: 9px solid transparent; border-top: 14px solid white; z-index: 2; }
352
+ .bubble-text { word-wrap: break-word; pointer-events: none; }
353
 
354
+ .edit-controls { position: fixed; bottom: 20px; right: 20px; background: #000; padding: 25px; border-radius: 12px; width: 260px; border: 2px solid #333; z-index: 2000; }
355
  button { width: 100%; padding: 12px; margin-top: 10px; cursor: pointer; font-weight: bold; border-radius: 6px; border: none; }
356
+ .action-btn { background: #00d2ff; color: black; }
357
  .hidden { display: none; }
358
+ .loader { border: 5px solid #333; border-top: 5px solid #00d2ff; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 20px auto; }
359
+ @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
360
  </style>
361
  </head>
362
  <body>
363
+ <div id="upload-container">
364
+ <div class="upload-box">
365
+ <h1>🎬 Pro Comic Maker</h1>
366
+ <input type="file" id="vid-input" accept="video/mp4"><br><br>
367
+ <label>Pages: </label><input type="number" id="pg-count" value="2" style="width:50px"><br><br>
368
+ <button class="action-btn" onclick="upload()">🚀 GENERATE COMIC</button>
369
+ <div id="loading-view" class="hidden"><div class="loader"></div><p id="status-text">Warming GPU...</p></div>
370
+ </div>
371
  </div>
372
 
373
+ <div id="editor-container">
374
+ <div id="comic-output"></div>
375
+ <div class="edit-controls">
376
+ <button onclick="addBubble()" style="background:#2ecc71; color:white;">💬 Add Text Box</button>
377
  <button onclick="exportPNG()" style="background:#3498db; color:white;">📥 Download PNGs</button>
378
+ <button onclick="location.reload()" style="background:#e74c3c; color:white;">🏠 Start New</button>
379
  </div>
380
  </div>
381
 
382
  <script>
383
  let sid = 's' + Math.random().toString(36).substr(2,9);
384
+ let selectedPanel = null, isDragging = false, startX, startY, initX, initY, selectedBubble = null;
385
 
386
+ async function upload() {
387
+ const f = document.getElementById('vid-input').files[0];
388
+ if(!f) return alert("Select a video!");
389
+ document.getElementById('loading-view').classList.remove('hidden');
390
+ const fd = new FormData(); fd.append('file', f); fd.append('pages', document.getElementById('pg-count').value);
391
  await fetch(`/uploader?sid=${sid}`, {method: 'POST', body: fd});
392
  const itv = setInterval(async () => {
393
  const r = await fetch(`/status?sid=${sid}`); const d = await r.json();
394
+ document.getElementById('status-text').innerText = d.message || "Working...";
395
+ if(d.progress >= 100) { clearInterval(itv); loadComic(); }
396
+ }, 3000);
397
  }
398
 
399
+ async function loadComic() {
400
  const r = await fetch(`/output/${sid}/pages.json`); const pages = await r.json();
401
+ document.getElementById('upload-container').style.display = 'none';
402
+ document.getElementById('editor-container').style.display = 'block';
403
+ const out = document.getElementById('comic-output');
404
  pages.forEach(p => {
405
  const pgDiv = document.createElement('div'); pgDiv.className = 'comic-page';
406
  p.panels.forEach((pan, i) => {
407
  const pDiv = document.createElement('div'); pDiv.className = 'panel p' + (i+1);
408
+ pDiv.onclick = (e) => { e.stopPropagation(); if(selectedPanel) selectedPanel.style.outline="none"; selectedPanel=pDiv; pDiv.style.outline="5px solid #00d2ff"; };
409
  const img = document.createElement('img'); img.src = `/frames/${sid}/${pan.image}`;
410
  pDiv.appendChild(img);
411
+ if(p.bubbles[i]) pDiv.appendChild(createBubbleUI(p.bubbles[i].dialog, p.bubbles[i].x, p.bubbles[i].y));
412
  pgDiv.appendChild(pDiv);
413
  });
414
  out.appendChild(pgDiv);
415
  });
416
  }
417
 
418
+ function createBubbleUI(txt, x, y) {
419
+ const b = document.createElement('div'); b.className = 'speech-bubble';
420
+ b.innerHTML = `<div class="bubble-text">${txt || '...'}</div>`;
421
+ b.style.left = (x || 50) + 'px'; b.style.top = (y || 20) + 'px';
422
  b.onmousedown = (e) => {
423
  e.stopPropagation();
424
+ if(selectedBubble) selectedBubble.style.boxShadow="4px 4px 0 rgba(0,0,0,0.1)";
425
+ selectedBubble = b; b.style.boxShadow="0 0 15px #00d2ff";
426
+ isDragging = true; startX = e.clientX; startY = e.clientY; initX = b.offsetLeft; initY = b.offsetTop;
427
  };
428
+ b.ondblclick = () => { const n = prompt("Edit text:", b.innerText); if(n) b.querySelector('.bubble-text').innerText = n; };
429
  return b;
430
  }
431
 
432
+ document.addEventListener('mousemove', (e) => { if(isDragging && selectedBubble) { selectedBubble.style.left = (initX + e.clientX - startX) + 'px'; selectedBubble.style.top = (initY + e.clientY - startY) + 'px'; } });
433
+ document.addEventListener('mouseup', () => isDragging = false);
434
+
435
+ function addBubble() { if(selectedPanel) selectedPanel.appendChild(createBubbleUI("New Text", 60, 60)); else alert("Select a panel first!"); }
436
 
437
  async function exportPNG() {
438
  const pgs = document.querySelectorAll('.comic-page');
439
  for(let pg of pgs) {
440
  const url = await htmlToImage.toPng(pg, {pixelRatio: 3});
441
+ const l = document.createElement('a'); l.download='Comic_Page.png'; l.href=url; l.click();
442
  }
443
  }
444
  </script>
445
  </body></html>
446
  '''
447
 
448
+ # ======================================================
449
+ # ⚡ ROUTES
450
+ # ======================================================
451
+ @app.route('/')
452
+ def index(): return INDEX_HTML
453
+
454
+ @app.route('/uploader', methods=['POST'])
455
+ def uploader():
456
+ sid = request.args.get('sid')
457
+ mgr = ComicGenerator(sid)
458
+ mgr.cleanup()
459
+ f = request.files['file']
460
+ f.save(mgr.video_path)
461
+ threading.Thread(target=mgr.run, args=(request.form.get('pages', 2),)).start()
462
+ return jsonify({'success': True})
463
+
464
+ @app.route('/status')
465
+ def status():
466
+ sid = request.args.get('sid')
467
+ p = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
468
+ if os.path.exists(p): return send_file(p)
469
+ return jsonify({'progress': 0})
470
+
471
+ @app.route('/frames/<sid>/<path:filename>')
472
+ def get_frame(sid, filename):
473
+ return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
474
+
475
+ @app.route('/output/<sid>/<path:filename>')
476
+ def get_output(sid, filename):
477
+ return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
478
+
479
  if __name__ == '__main__':
480
  try: gpu_warmup()
481
  except: pass