tester343 commited on
Commit
f2379ad
·
verified ·
1 Parent(s): c97c04c

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +589 -167
app_enhanced.py CHANGED
@@ -16,19 +16,22 @@ try:
16
  import srt
17
  except ImportError as e:
18
  print(f"❌ CRITICAL ERROR: Missing python library. {e}")
 
19
  cv2 = None
20
  np = None
21
  Image = None
22
  srt = None
23
 
24
- # --- 2. BACKEND MODULE IMPORTS (WITH DUMMY FALLBACKS) ---
25
- def dummy_function(*args, **kwargs): return 0, 0, None, None
 
 
26
 
27
  try:
28
  from backend.keyframes.keyframes import black_bar_crop
29
  print("✅ Black bar cropping module loaded.")
30
  except Exception:
31
- black_bar_crop = dummy_function
32
 
33
  try:
34
  from backend.simple_color_enhancer import SimpleColorEnhancer
@@ -47,11 +50,14 @@ except Exception:
47
  try:
48
  from backend.class_def import bubble, panel, Page
49
  print("✅ Core class definitions loaded.")
50
- except Exception as e:
51
- print(f"⚠️ Loading fallback definitions: {e}")
52
- # Fallback if import fails
53
- def bubble(**kwargs): return kwargs
54
- def panel(**kwargs): return kwargs
 
 
 
55
  class Page:
56
  def __init__(self, panels, bubbles): self.panels, self.bubbles = panels, bubbles
57
 
@@ -59,9 +65,9 @@ try:
59
  from backend.ai_enhanced_core import image_processor, comic_styler, face_detector, layout_optimizer
60
  from backend.ai_bubble_placement import ai_bubble_placer
61
  from backend.subtitles.subs_real import get_real_subtitles
62
- from backend.keyframes.keyframes_simple import generate_keyframes_simple
63
  print("✅ Core utility modules loaded.")
64
  except Exception:
 
65
  def get_real_subtitles(v): pass
66
  class DummyDetector:
67
  def detect_faces(self, p): return []
@@ -76,7 +82,7 @@ except Exception:
76
  app = Flask(__name__)
77
  BASE_USER_DIR = "userdata"
78
 
79
- # --- HTML ---
80
  INDEX_HTML = '''
81
  <!DOCTYPE html>
82
  <html lang="en">
@@ -84,182 +90,535 @@ INDEX_HTML = '''
84
  <meta charset="UTF-8">
85
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
86
  <title>Movie to Comic Generator</title>
 
87
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
88
- <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&display=swap" rel="stylesheet">
 
 
89
  <style>
90
- body { background-color: #fdf6e3; font-family: system-ui, sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
 
 
 
91
  #upload-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; width: 100%; }
92
  .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; }
93
- #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; }
94
- .file-label { display: block; padding: 15px; background: #2c3e50; color: white; border-radius: 8px; cursor: pointer; margin-bottom: 10px; }
95
- .submit-btn { width: 100%; padding: 15px; background: #e67e22; color: white; border: none; border-radius: 8px; font-size: 18px; cursor: pointer; font-weight: bold; }
96
- .loader { width: 120px; height: 20px; background: radial-gradient(circle 10px, #e67e22 100%, transparent 0); background-size: 20px 20px; animation: ball 1s infinite linear; margin: 20px auto; }
 
 
 
 
97
  @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
 
 
 
 
 
 
 
 
98
 
99
- /* COMIC STYLES */
100
- .comic-page { background: white; width: 600px; height: 400px; position: relative; overflow: hidden; border: 2px solid #000; margin: 0 auto 20px; box-shadow: 0 4px 10px rgba(0,0,0,0.1); }
101
- .comic-grid { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 10px; width: 100%; height: 100%; padding: 10px; box-sizing: border-box; }
102
- .panel { position: relative; overflow: hidden; border: 2px solid #000; background: #eee; }
103
- .panel img { width: 100%; height: 100%; object-fit: cover; }
104
 
105
- /* EXACT SHARK FIN SPEECH BUBBLE CSS */
 
 
 
 
 
 
 
 
 
 
 
106
  .speech-bubble.speech {
107
- --b: 3em; --h: 1.8em; --t: 0.6; --p: 50%; --r: 1.2em;
108
- --c: #4ECDC4;
109
- background: var(--c); color: #fff; padding: 1em; position: absolute;
 
 
 
 
 
 
 
 
110
  border-radius: var(--r) var(--r) min(var(--r), calc(100% - var(--p) - (1 - var(--t)) * var(--b) / 2)) min(var(--r), calc(var(--p) - (1 - var(--t)) * var(--b) / 2)) / var(--r);
111
- font-family: 'Comic Neue', cursive; font-weight: bold; font-size: 14px; text-align: center;
112
- min-width: 80px; min-height: 40px; display: flex; align-items: center; justify-content: center;
113
- cursor: move; z-index: 10;
114
  }
115
- /* Export-Safe Gradient instead of Mask */
116
  .speech-bubble.speech:before {
117
  content: ""; position: absolute; width: var(--b); height: var(--h);
118
  background: radial-gradient(100% 100% at 100% 0, transparent calc(var(--t) * 100% - 1px), var(--c) calc(var(--t) * 100%));
119
  border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
120
- top: 99%; left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
121
  }
122
 
123
- .edit-controls { position: fixed; bottom: 20px; right: 20px; background: white; padding: 15px; border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.2); width: 200px; }
124
- .edit-controls button { width: 100%; margin-top: 5px; padding: 8px; cursor: pointer; background: #f0f0f0; border: 1px solid #ccc; border-radius: 4px; }
125
- .edit-controls button:hover { background: #e0e0e0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  </style>
127
  </head>
128
  <body>
 
129
  <div id="upload-container">
130
  <div class="upload-box">
131
- <h1>🎬 Comic Generator</h1>
132
- <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fname').innerText=this.files[0].name">
133
- <label for="file-upload" class="file-label">Choose Video</label>
134
- <span id="fname">No file selected</span>
135
- <button class="submit-btn" onclick="upload()">Generate</button>
136
- <div id="loading" style="display:none;">
 
 
137
  <div class="loader"></div>
138
- <p id="status">Uploading...</p>
139
  </div>
140
  </div>
141
  </div>
142
 
 
143
  <div id="editor-container">
144
- <div id="comic-pages"></div>
 
 
 
 
 
 
145
  <div class="edit-controls">
146
- <h4>Editor Tools</h4>
147
- <button onclick="exportToPng()">💾 Export PNG</button>
148
- <button onclick="location.reload()" style="color:red;">↺ Start Over</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  </div>
150
  </div>
151
 
152
  <script>
153
- // CLIENT-SIDE SESSION ID GENERATION (Fixes "Session Lost" on Hugging Face)
154
  function generateUUID() {
155
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
156
  var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
157
  return v.toString(16);
158
  });
159
  }
160
-
161
- // Get or create SID
162
  let sid = localStorage.getItem('comic_sid');
163
- if(!sid) {
164
- sid = generateUUID();
165
- localStorage.setItem('comic_sid', sid);
166
- }
167
- console.log("Using Session ID:", sid);
168
-
169
- let interval;
170
-
171
- async function upload() {
 
 
 
172
  const file = document.getElementById('file-upload').files[0];
173
- if(!file) return alert("Select file");
174
 
175
- const fd = new FormData();
176
- fd.append('file', file);
177
 
178
- document.querySelector('.upload-box').style.display='none';
179
- document.getElementById('loading').style.display='block';
180
 
181
- // Pass SID in URL
182
- const res = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
183
- if(res.ok) interval = setInterval(checkStatus, 2000);
184
- else { alert("Upload failed"); location.reload(); }
185
- }
 
186
 
187
  async function checkStatus() {
188
- const res = await fetch(`/status?sid=${sid}`);
189
- const data = await res.json();
190
- document.getElementById('status').innerText = data.message;
191
-
192
- if(data.progress >= 100) {
193
- clearInterval(interval);
194
- document.getElementById('upload-container').style.display='none';
195
- document.getElementById('editor-container').style.display='block';
196
- loadComic();
197
- } else if(data.progress < 0) {
198
- clearInterval(interval);
199
- alert("Error: " + data.message);
200
- location.reload();
201
- }
 
 
202
  }
203
 
 
204
  function loadComic() {
205
- // Fetch pages.json using SID
206
  fetch(`/output/pages.json?sid=${sid}`).then(r=>r.json()).then(data => {
207
- const c = document.getElementById('comic-pages');
208
- c.innerHTML = ''; // clear
209
- data.forEach((p, i) => {
210
- const div = document.createElement('div');
211
- div.className = 'comic-page';
212
- div.id = 'page-'+i;
 
213
  const grid = document.createElement('div');
214
  grid.className = 'comic-grid';
215
 
216
- p.panels.forEach((pan, j) => {
217
  const pDiv = document.createElement('div');
218
  pDiv.className = 'panel';
 
 
219
  const img = document.createElement('img');
220
- // Fetch image using SID
221
- img.src = `/frames/${pan.image}?sid=${sid}`;
222
  pDiv.appendChild(img);
223
 
224
- if(p.bubbles && p.bubbles[j] && p.bubbles[j].dialog) {
225
- const b = document.createElement('div');
226
- b.className = 'speech-bubble speech';
227
- b.innerText = p.bubbles[j].dialog;
228
- b.style.left = (p.bubbles[j].bubble_offset_x || 50) + 'px';
229
- b.style.top = (p.bubbles[j].bubble_offset_y || 20) + 'px';
 
230
  pDiv.appendChild(b);
231
- makeInteractive(b);
232
  }
233
  grid.appendChild(pDiv);
234
  });
235
- div.appendChild(grid);
236
- c.appendChild(div);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  });
238
- }).catch(e => console.error(e));
 
 
 
 
 
 
 
 
 
 
 
239
  }
240
 
241
- function makeInteractive(el) {
242
- el.onmousedown = function(e) {
243
- e.stopPropagation();
244
- const offX = e.clientX - el.offsetLeft;
245
- const offY = e.clientY - el.offsetTop;
246
- document.onmousemove = function(ev) {
247
- el.style.left = (ev.clientX - offX) + 'px';
248
- el.style.top = (ev.clientY - offY) + 'px';
249
- }
250
- document.onmouseup = function() { document.onmousemove = null; }
251
- };
 
 
 
 
 
 
 
 
 
 
 
252
  }
253
 
254
- async function exportToPng() {
255
- const pages = document.querySelectorAll('.comic-page');
256
- for(let i=0; i<pages.length; i++) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  try {
258
- const url = await htmlToImage.toPng(pages[i], {pixelRatio: 3});
259
  const a = document.createElement('a');
260
- a.download = `comic-page-${i+1}.png`;
261
- a.href = url;
262
- a.click();
263
  } catch(e) { console.error(e); alert("Export failed"); }
264
  }
265
  }
@@ -268,6 +627,7 @@ INDEX_HTML = '''
268
  </html>
269
  '''
270
 
 
271
  class EnhancedComicGenerator:
272
  def __init__(self, sid):
273
  self.sid = sid
@@ -281,6 +641,7 @@ class EnhancedComicGenerator:
281
  os.makedirs(self.output_dir, exist_ok=True)
282
 
283
  self.video_fps = None
 
284
 
285
  def update_status(self, message, progress):
286
  try:
@@ -288,46 +649,44 @@ class EnhancedComicGenerator:
288
  json.dump({'message': message, 'progress': progress}, f)
289
  except: pass
290
 
291
- def cleanup(self):
292
  if os.path.exists(self.frames_dir): shutil.rmtree(self.frames_dir)
 
293
  os.makedirs(self.frames_dir, exist_ok=True)
 
 
 
294
 
295
  def generate_comic(self):
296
  try:
297
  if cv2 is None: raise Exception("OpenCV missing on server.")
 
298
 
299
- self.update_status("Processing Video...", 10)
300
  cap = cv2.VideoCapture(self.video_path)
301
  if not cap.isOpened(): raise Exception("Invalid Video")
302
-
303
  self.video_fps = cap.get(cv2.CAP_PROP_FPS) or 25
 
304
  cap.release()
305
 
306
  # 1. Subtitles
307
- self.update_status("Extracting Dialogue...", 30)
 
308
  try:
309
- get_real_subtitles(self.video_path) # Creates test1.srt
310
- # Move to user dir if created in root
311
- if os.path.exists('test1.srt'):
312
- shutil.move('test1.srt', os.path.join(self.user_dir, 'subs.srt'))
313
  except:
314
- # Fallback dummy sub
315
- with open(os.path.join(self.user_dir, 'subs.srt'), 'w') as f:
316
- f.write("1\n00:00:01,000 --> 00:00:04,000\nSample Text\n")
317
 
318
- # 2. Extract Frames
319
- self.update_status("Generating Panels...", 50)
320
- cap = cv2.VideoCapture(self.video_path)
321
-
322
- subs_path = os.path.join(self.user_dir, 'subs.srt')
323
- with open(subs_path, 'r', encoding='utf-8') as f:
324
- subs = list(srt.parse(f.read()))
325
 
 
326
  frame_files = []
327
  bubbles = []
328
 
329
- # Limit frames to avoid timeout
330
- limit_subs = subs[:12]
331
 
332
  for i, sub in enumerate(limit_subs):
333
  mid = (sub.start.total_seconds() + sub.end.total_seconds()) / 2
@@ -337,55 +696,101 @@ class EnhancedComicGenerator:
337
  fname = f"frame_{i}.png"
338
  cv2.imwrite(os.path.join(self.frames_dir, fname), frame)
339
  frame_files.append(fname)
340
- # FIX: Added required arguments for bubble constructor to prevent TypeError
 
 
341
  bubbles.append(bubble(
342
  dialog=sub.content,
343
- bubble_offset_x=50,
344
- bubble_offset_y=20,
345
- lip_x=-1,
346
- lip_y=-1,
347
- emotion='normal'
348
  ))
349
  cap.release()
 
 
 
 
350
 
351
- # 3. Assemble
352
- self.update_status("Finalizing...", 80)
 
 
 
 
353
  pages_data = []
354
  for i in range(0, len(frame_files), 4):
355
  batch_f = frame_files[i:i+4]
356
  batch_b = bubbles[i:i+4]
357
  panels = [{'image': f} for f in batch_f]
358
- # safe object conversion
359
  b_data = [b.__dict__ if hasattr(b, '__dict__') else b for b in batch_b]
360
  pages_data.append({'panels': panels, 'bubbles': b_data})
361
 
362
  with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
363
  json.dump(pages_data, f)
364
-
365
  self.update_status("Done!", 100)
366
 
367
  except Exception as e:
368
  traceback.print_exc()
369
  self.update_status(f"Error: {str(e)}", -1)
370
 
371
-
372
- # --- ROUTES (SID REQUIRED IN QUERY PARAMS) ---
373
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  @app.route('/')
375
- def index():
376
- return INDEX_HTML
377
 
378
  @app.route('/uploader', methods=['POST'])
379
  def upload():
380
  sid = request.args.get('sid')
381
  if not sid: return "Missing SID", 400
382
-
383
- if 'file' not in request.files: return "No file", 400
384
-
385
  gen = EnhancedComicGenerator(sid)
386
- gen.cleanup() # Clean previous run
387
- request.files['file'].save(gen.video_path)
388
-
389
  gen.update_status("Starting...", 5)
390
  threading.Thread(target=gen.generate_comic).start()
391
  return jsonify({'success': True})
@@ -393,25 +798,42 @@ def upload():
393
  @app.route('/status')
394
  def get_status():
395
  sid = request.args.get('sid')
396
- if not sid: return jsonify({'message': 'No SID', 'progress': -1})
397
-
398
- status_path = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
399
- if os.path.exists(status_path):
400
- return send_file(status_path)
401
- return jsonify({'message': 'Waiting...', 'progress': 0})
402
 
403
  @app.route('/output/<path:filename>')
404
  def get_output(filename):
405
  sid = request.args.get('sid')
406
- if not sid: return "No SID", 400
407
  return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
408
 
409
  @app.route('/frames/<path:filename>')
410
  def get_frame(filename):
411
  sid = request.args.get('sid')
412
- if not sid: return "No SID", 400
413
  return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
414
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  if __name__ == '__main__':
416
  os.makedirs(BASE_USER_DIR, exist_ok=True)
417
  port = int(os.getenv("PORT", 7860))
 
16
  import srt
17
  except ImportError as e:
18
  print(f"❌ CRITICAL ERROR: Missing python library. {e}")
19
+ # Define dummies to allow app to start (will fail gracefully later)
20
  cv2 = None
21
  np = None
22
  Image = None
23
  srt = None
24
 
25
+ # --- 2. BACKEND MODULE IMPORTS (WITH ROBUST FALLBACKS) ---
26
+ # This section ensures the app loads even if specific backend files are missing.
27
+
28
+ def dummy_black_bar_crop(): return 0, 0, None, None
29
 
30
  try:
31
  from backend.keyframes.keyframes import black_bar_crop
32
  print("✅ Black bar cropping module loaded.")
33
  except Exception:
34
+ black_bar_crop = dummy_black_bar_crop
35
 
36
  try:
37
  from backend.simple_color_enhancer import SimpleColorEnhancer
 
50
  try:
51
  from backend.class_def import bubble, panel, Page
52
  print("✅ Core class definitions loaded.")
53
+ except Exception:
54
+ # Fallback definitions if backend/class_def.py is missing
55
+ def bubble(dialog="", bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, emotion='normal'):
56
+ return {
57
+ 'dialog': dialog, 'bubble_offset_x': bubble_offset_x, 'bubble_offset_y': bubble_offset_y,
58
+ 'lip_x': lip_x, 'lip_y': lip_y, 'emotion': emotion
59
+ }
60
+ def panel(image=""): return {'image': image}
61
  class Page:
62
  def __init__(self, panels, bubbles): self.panels, self.bubbles = panels, bubbles
63
 
 
65
  from backend.ai_enhanced_core import image_processor, comic_styler, face_detector, layout_optimizer
66
  from backend.ai_bubble_placement import ai_bubble_placer
67
  from backend.subtitles.subs_real import get_real_subtitles
 
68
  print("✅ Core utility modules loaded.")
69
  except Exception:
70
+ # Dummies for AI modules
71
  def get_real_subtitles(v): pass
72
  class DummyDetector:
73
  def detect_faces(self, p): return []
 
82
  app = Flask(__name__)
83
  BASE_USER_DIR = "userdata"
84
 
85
+ # --- HTML INTERFACE (Upload + Editor + JS) ---
86
  INDEX_HTML = '''
87
  <!DOCTYPE html>
88
  <html lang="en">
 
90
  <meta charset="UTF-8">
91
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
92
  <title>Movie to Comic Generator</title>
93
+ <!-- Export Library that supports CSS Masks/Gradients -->
94
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
95
+ <link rel="preconnect" href="https://fonts.googleapis.com">
96
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
97
+ <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Lato&display=swap" rel="stylesheet">
98
  <style>
99
+ /* GLOBAL STYLES */
100
+ body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
101
+
102
+ /* UPLOAD VIEW */
103
  #upload-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; width: 100%; }
104
  .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; }
105
+ .file-input { display: none; }
106
+ .file-label { display: block; padding: 15px 25px; background-color: #2c3e50; color: white; border-radius: 8px; cursor: pointer; margin-bottom: 15px; font-weight: bold; transition: 0.3s; }
107
+ .file-label:hover { background-color: #34495e; transform: translateY(-2px); }
108
+ .submit-btn { width: 100%; padding: 15px; background: #e67e22; color: white; border: none; border-radius: 8px; font-size: 18px; font-weight: bold; cursor: pointer; transition: 0.3s; }
109
+ .submit-btn:hover { background-color: #d35400; }
110
+
111
+ .loading-view { display: none; flex-direction: column; align-items: center; margin-top: 20px; }
112
+ .loader { width: 120px; height: 20px; background: radial-gradient(circle 10px, #e67e22 100%, transparent 0); background-size: 20px 20px; animation: ball 1s infinite linear; }
113
  @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
114
+ #status-text { margin-top: 20px; font-size: 18px; font-weight: 500; }
115
+
116
+ /* EDITOR VIEW */
117
+ #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; }
118
+ .comic-container-wrapper { max-width: 1200px; margin: 0 auto; padding-bottom: 100px; }
119
+ .comic-page { background: white; width: 600px; height: 400px; box-shadow: 0 0 10px rgba(0,0,0,0.1); position: relative; overflow: hidden; border: 1px solid #333; padding: 10px; margin: 0 auto 30px; }
120
+ .comic-grid { display: grid; grid-template-columns: 285px 285px; grid-template-rows: 185px 185px; gap: 10px; width: 100%; height: 100%; }
121
+ .page-title { text-align: center; margin-bottom: 10px; font-family: 'Bangers', cursive; letter-spacing: 1px; }
122
 
123
+ .panel { position: relative; overflow: hidden; width: 100%; height: 100%; border: 1px solid #333; cursor: pointer; background: #eee; }
124
+ .panel.selected { outline: 3px solid #2196F3; outline-offset: -3px; }
125
+ .panel img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.1s ease-out; }
126
+ .panel img.pannable { cursor: grab; }
127
+ .panel img.panning { cursor: grabbing; }
128
 
129
+ /* SPEECH BUBBLE (SHARK FIN CSS) */
130
+ .speech-bubble {
131
+ position: absolute; display: flex; justify-content: center; align-items: center;
132
+ width: 150px; height: 80px; min-width: 50px; min-height: 30px; box-sizing: border-box;
133
+ z-index: 10; cursor: move; overflow: visible; font-family: 'Comic Neue', cursive;
134
+ font-weight: bold; font-size: 14px; text-align: center;
135
+ }
136
+ .bubble-text { padding: 0.8em; word-wrap: break-word; position: relative; z-index: 5; }
137
+ .speech-bubble.selected { outline: 2px dashed #4CAF50; }
138
+ .speech-bubble textarea { position: absolute; top: 0; left: 0; width: 100%; height: 100%; box-sizing: border-box; border: 1px solid #4CAF50; background: rgba(255,255,255,0.95); font: inherit; text-align: center; resize: none; padding: 8px; z-index: 102; }
139
+
140
+ /* Exact CSS Logic provided for Shark Fin Tail (adapted for export) */
141
  .speech-bubble.speech {
142
+ --b: 3em; /* tail base width */
143
+ --h: 1.8em; /* tail height */
144
+ --t: 0.6; /* thickness */
145
+ --p: var(--tail-pos, 50%); /* Slider position */
146
+ --r: 1.2em; /* radius */
147
+ --c: var(--bubble-fill-color, #4ECDC4); /* color */
148
+
149
+ background: var(--c);
150
+ color: var(--bubble-text-color, #fff);
151
+ padding: 1em;
152
+ position: absolute;
153
  border-radius: var(--r) var(--r) min(var(--r), calc(100% - var(--p) - (1 - var(--t)) * var(--b) / 2)) min(var(--r), calc(var(--p) - (1 - var(--t)) * var(--b) / 2)) / var(--r);
 
 
 
154
  }
155
+
156
  .speech-bubble.speech:before {
157
  content: ""; position: absolute; width: var(--b); height: var(--h);
158
  background: radial-gradient(100% 100% at 100% 0, transparent calc(var(--t) * 100% - 1px), var(--c) calc(var(--t) * 100%));
159
  border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
 
160
  }
161
 
162
+ /* Tail Rotations */
163
+ .speech-bubble.speech.tail-bottom:before { top: 99%; left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); }
164
+
165
+ .speech-bubble.speech.tail-top { border-radius: min(var(--r), calc(var(--p) - (1 - var(--t)) * var(--b) / 2)) min(var(--r), calc(100% - var(--p) - (1 - var(--t)) * var(--b) / 2)) var(--r) var(--r) / var(--r); }
166
+ .speech-bubble.speech.tail-top:before { bottom: 99%; left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); transform: scaleY(-1); }
167
+
168
+ .speech-bubble.speech.tail-left { border-radius: var(--r); }
169
+ .speech-bubble.speech.tail-left:before { right: 99%; top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); transform: rotate(90deg); transform-origin: top right; }
170
+
171
+ .speech-bubble.speech.tail-right { border-radius: var(--r); }
172
+ .speech-bubble.speech.tail-right:before { left: 99%; top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); transform: rotate(-90deg); transform-origin: top left; }
173
+
174
+ /* Thought Bubble */
175
+ .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
176
+ .speech-bubble.thought::after { display: none; }
177
+ .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
178
+ .thought-dot-1 { width: 20px; height: 20px; bottom: -20px; left: 15px; }
179
+ .thought-dot-2 { width: 12px; height: 12px; bottom: -32px; left: 5px; }
180
+
181
+ /* Resize Handles */
182
+ .resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; z-index: 11; }
183
+ .speech-bubble.selected .resize-handle { display: block; }
184
+ .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
185
+ .resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
186
+ .resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
187
+ .resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
188
+
189
+ /* Controls */
190
+ .edit-controls {
191
+ position: fixed; bottom: 20px; right: 20px; width: 220px;
192
+ background: rgba(44, 62, 80, 0.95); color: white; padding: 15px;
193
+ border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 1000;
194
+ }
195
+ .edit-controls h4 { margin: 0 0 10px 0; color: #4ECDC4; text-align: center; }
196
+ .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
197
+ button, select, input { width: 100%; margin-top: 5px; padding: 6px; border-radius: 4px; border: none; cursor: pointer; font-weight: bold; }
198
+ .action-button { background-color: #4CAF50; color: white; }
199
+ .secondary-button { background-color: #f39c12; color: white; }
200
+ .reset-button { background-color: #e74c3c; color: white; }
201
+ .button-grid, .zoom-controls, .timestamp-controls, .color-picker-grid { display: grid; gap: 5px; }
202
+ .button-grid { grid-template-columns: 1fr 1fr; }
203
+ .zoom-controls { grid-template-columns: auto 1fr; align-items: center; }
204
+ .timestamp-controls { grid-template-columns: 1fr auto; }
205
+ .color-picker-grid { grid-template-columns: 1fr 1fr; }
206
+ .slider-container { display: flex; align-items: center; gap: 5px; margin-top: 5px; }
207
+ .slider-container label { font-size: 11px; min-width: 30px; }
208
  </style>
209
  </head>
210
  <body>
211
+ <!-- UPLOAD SECTION -->
212
  <div id="upload-container">
213
  <div class="upload-box">
214
+ <h1>🎬 Movie to Comic</h1>
215
+ <form id="upload-form">
216
+ <label for="file-upload" class="file-label">Choose Video File</label>
217
+ <input id="file-upload" class="file-input" type="file" accept="video/*" onchange="document.getElementById('fname').innerText=this.files[0].name">
218
+ <span id="fname" style="display:block; margin-bottom:10px; color:#7f8c8d; font-style:italic;">No file selected</span>
219
+ <button class="submit-btn" type="submit">Generate Comic</button>
220
+ </form>
221
+ <div class="loading-view" id="loading-view">
222
  <div class="loader"></div>
223
+ <p id="status-text">Starting...</p>
224
  </div>
225
  </div>
226
  </div>
227
 
228
+ <!-- EDITOR SECTION -->
229
  <div id="editor-container">
230
+ <div class="comic-container-wrapper">
231
+ <h1 class="comic-title" style="text-align:center;">🎬 Generated Comic</h1>
232
+ <div id="comic-pages"></div>
233
+ </div>
234
+
235
+ <input type="file" id="image-uploader" style="display: none;" accept="image/*">
236
+
237
  <div class="edit-controls">
238
+ <h4>✏️ Editor</h4>
239
+
240
+ <!-- Bubble Controls -->
241
+ <div class="control-group">
242
+ <label>Bubble:</label>
243
+ <select id="bubble-type-select" onchange="changeBubbleType(this.value)" disabled>
244
+ <option value="speech">Speech</option><option value="thought">Thought</option>
245
+ </select>
246
+ <div class="color-picker-grid">
247
+ <div><small>Text</small><input type="color" id="bubble-text-color" value="#ffffff" disabled></div>
248
+ <div><small>Fill</small><input type="color" id="bubble-fill-color" value="#4ECDC4" disabled></div>
249
+ </div>
250
+ <button onclick="addBubbleToPanel()" class="action-button">💬 Add Bubble</button>
251
+ <button onclick="deleteBubble()" class="reset-button">🗑️ Delete</button>
252
+ </div>
253
+
254
+ <!-- Tail Controls -->
255
+ <div class="control-group" id="tail-controls" style="display: none;">
256
+ <label>Tail:</label>
257
+ <button onclick="rotateBubbleTail()" class="secondary-button">🔄 Rotate Side</button>
258
+ <div class="slider-container">
259
+ <label>Pos</label>
260
+ <input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)">
261
+ </div>
262
+ </div>
263
+
264
+ <!-- Panel Controls -->
265
+ <div class="control-group">
266
+ <label>Panel:</label>
267
+ <button onclick="replacePanelImage()" class="action-button">🖼️ Replace Image</button>
268
+ <div class="button-grid">
269
+ <button onclick="adjustFrame('backward')" class="secondary-button">⬅️ Prev</button>
270
+ <button onclick="adjustFrame('forward')" class="action-button">Next ➡️</button>
271
+ </div>
272
+ <div class="timestamp-controls">
273
+ <input type="text" id="timestamp-input" placeholder="mm:ss">
274
+ <button onclick="gotoTimestamp()" class="action-button">Go</button>
275
+ </div>
276
+ <div class="zoom-controls" style="margin-top:5px;">
277
+ <button onclick="resetPanelTransform()" class="reset-button" style="font-size:10px; padding:2px;">Reset</button>
278
+ <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled>
279
+ </div>
280
+ </div>
281
+
282
+ <!-- Export -->
283
+ <div class="control-group">
284
+ <button onclick="exportPagesToPNG()" class="action-button" style="background-color: #2196F3;">💾 Export PNG</button>
285
+ <button onclick="location.reload()" class="reset-button">↺ Start Over</button>
286
+ </div>
287
  </div>
288
  </div>
289
 
290
  <script>
291
+ // --- CLIENT SIDE SESSION MANAGEMENT ---
292
  function generateUUID() {
293
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
294
  var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
295
  return v.toString(16);
296
  });
297
  }
 
 
298
  let sid = localStorage.getItem('comic_sid');
299
+ if(!sid) { sid = generateUUID(); localStorage.setItem('comic_sid', sid); }
300
+ console.log("SID:", sid);
301
+
302
+ let statusInterval;
303
+ let currentlySelectedBubble = null, currentlySelectedPanel = null;
304
+ let draggedBubble = null, offset = {x:0, y:0};
305
+ let isPanning = false, panStartX, panStartY, panStartTx, panStartTy;
306
+ let isResizing = false, resizeHandle, origW, origH, origX, origY, origMX, origMY;
307
+
308
+ // --- UPLOAD FLOW ---
309
+ document.getElementById('upload-form').addEventListener('submit', async (e) => {
310
+ e.preventDefault();
311
  const file = document.getElementById('file-upload').files[0];
312
+ if(!file) return alert("Please select a file");
313
 
314
+ document.getElementById('upload-view').style.display = 'none';
315
+ document.getElementById('loading-view').style.display = 'flex';
316
 
317
+ const fd = new FormData(); fd.append('file', file);
 
318
 
319
+ try {
320
+ const res = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
321
+ if(res.ok) statusInterval = setInterval(checkStatus, 2000);
322
+ else { alert("Upload failed"); location.reload(); }
323
+ } catch(e) { console.error(e); alert("Error uploading"); }
324
+ });
325
 
326
  async function checkStatus() {
327
+ try {
328
+ const res = await fetch(`/status?sid=${sid}`);
329
+ const data = await res.json();
330
+ document.getElementById('status-text').textContent = data.message;
331
+
332
+ if(data.progress >= 100) {
333
+ clearInterval(statusInterval);
334
+ document.getElementById('upload-container').style.display = 'none';
335
+ document.getElementById('editor-container').style.display = 'block';
336
+ loadComic();
337
+ } else if(data.progress < 0) {
338
+ clearInterval(statusInterval);
339
+ alert("Error: " + data.message);
340
+ location.reload();
341
+ }
342
+ } catch(e) {}
343
  }
344
 
345
+ // --- EDITOR LOGIC ---
346
  function loadComic() {
 
347
  fetch(`/output/pages.json?sid=${sid}`).then(r=>r.json()).then(data => {
348
+ const container = document.getElementById('comic-pages');
349
+ container.innerHTML = '';
350
+ data.forEach((page, i) => {
351
+ const wrapper = document.createElement('div');
352
+ wrapper.className = 'comic-page';
353
+ wrapper.id = 'page-'+i;
354
+
355
  const grid = document.createElement('div');
356
  grid.className = 'comic-grid';
357
 
358
+ page.panels.forEach((p, j) => {
359
  const pDiv = document.createElement('div');
360
  pDiv.className = 'panel';
361
+ pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
362
+
363
  const img = document.createElement('img');
364
+ img.src = `/frames/${p.image}?sid=${sid}`;
365
+ img.onmousedown = startPan;
366
  pDiv.appendChild(img);
367
 
368
+ // Bubbles
369
+ if(page.bubbles && page.bubbles[j] && page.bubbles[j].dialog) {
370
+ const b = createBubbleElement({
371
+ text: page.bubbles[j].dialog,
372
+ left: (page.bubbles[j].bubble_offset_x||50)+'px',
373
+ top: (page.bubbles[j].bubble_offset_y||20)+'px'
374
+ });
375
  pDiv.appendChild(b);
 
376
  }
377
  grid.appendChild(pDiv);
378
  });
379
+ wrapper.appendChild(grid);
380
+
381
+ // Title wrapper
382
+ const outer = document.createElement('div');
383
+ outer.className = 'page-wrapper';
384
+ outer.innerHTML = `<h2 class="page-title">Page ${i+1}</h2>`;
385
+ outer.appendChild(wrapper);
386
+ container.appendChild(outer);
387
+ });
388
+
389
+ // Setup Global Listeners
390
+ document.addEventListener('mousemove', (e) => {
391
+ if(isPanning) panImage(e);
392
+ if(draggedBubble) drag(e);
393
+ if(isResizing) resizeBubble(e);
394
+ });
395
+ document.addEventListener('mouseup', () => {
396
+ stopPan(); stopDrag(); stopResize();
397
  });
398
+
399
+ // Color Listeners
400
+ document.getElementById('bubble-text-color').addEventListener('input', (e) => {
401
+ if(currentlySelectedBubble) currentlySelectedBubble.style.setProperty('--bubble-text-color', e.target.value);
402
+ });
403
+ document.getElementById('bubble-fill-color').addEventListener('input', (e) => {
404
+ if(currentlySelectedBubble) currentlySelectedBubble.style.setProperty('--bubble-fill-color', e.target.value);
405
+ });
406
+ // Slider Listener
407
+ document.getElementById('zoom-slider').addEventListener('input', handleZoom);
408
+ document.getElementById('tail-slider').addEventListener('input', (e) => slideTail(e.target.value));
409
+ });
410
  }
411
 
412
+ function createBubbleElement(d) {
413
+ const b = document.createElement('div');
414
+ b.className = 'speech-bubble speech tail-bottom';
415
+ b.style.left = d.left; b.style.top = d.top;
416
+ b.dataset.type = 'speech';
417
+
418
+ const span = document.createElement('span');
419
+ span.className = 'bubble-text';
420
+ span.textContent = d.text;
421
+ b.appendChild(span);
422
+
423
+ // Handles
424
+ ['nw','ne','sw','se'].forEach(dir => {
425
+ const h = document.createElement('div'); h.className = `resize-handle ${dir}`;
426
+ h.onmousedown = (e) => startResize(e, dir);
427
+ b.appendChild(h);
428
+ });
429
+
430
+ b.onmousedown = startDrag;
431
+ b.onclick = (e) => { e.stopPropagation(); selectBubble(b); };
432
+ b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
433
+ return b;
434
  }
435
 
436
+ function selectPanel(el) {
437
+ document.querySelectorAll('.panel.selected').forEach(e=>e.classList.remove('selected'));
438
+ el.classList.add('selected');
439
+ currentlySelectedPanel = el;
440
+ selectBubble(null);
441
+ resetPanelTransform(true); // Load zoom state
442
+ }
443
+
444
+ function selectBubble(el) {
445
+ if(currentlySelectedBubble) currentlySelectedBubble.classList.remove('selected');
446
+ currentlySelectedBubble = el;
447
+ const tailControls = document.getElementById('tail-controls');
448
+
449
+ if(el) {
450
+ el.classList.add('selected');
451
+ document.getElementById('bubble-text-color').disabled = false;
452
+ document.getElementById('bubble-fill-color').disabled = false;
453
+ document.getElementById('bubble-type-select').disabled = false;
454
+ document.getElementById('zoom-slider').disabled = true;
455
+ tailControls.style.display = (el.dataset.type === 'speech') ? 'block' : 'none';
456
+ } else {
457
+ document.getElementById('bubble-text-color').disabled = true;
458
+ document.getElementById('bubble-fill-color').disabled = true;
459
+ document.getElementById('bubble-type-select').disabled = true;
460
+ document.getElementById('zoom-slider').disabled = false;
461
+ tailControls.style.display = 'none';
462
+ }
463
+ }
464
+
465
+ // --- BUBBLE ACTIONS ---
466
+ function startDrag(e) {
467
+ const b = e.target.closest('.speech-bubble');
468
+ if(!b || e.target.classList.contains('resize-handle')) return;
469
+ draggedBubble = b; selectBubble(b);
470
+ const r = b.getBoundingClientRect();
471
+ offset = {x: e.clientX - r.left, y: e.clientY - r.top};
472
+ }
473
+ function drag(e) {
474
+ if(!draggedBubble) return;
475
+ e.preventDefault();
476
+ const p = draggedBubble.parentElement.getBoundingClientRect();
477
+ draggedBubble.style.left = (e.clientX - p.left - offset.x) + 'px';
478
+ draggedBubble.style.top = (e.clientY - p.top - offset.y) + 'px';
479
+ }
480
+ function stopDrag() { draggedBubble = null; }
481
+
482
+ function startResize(e, dir) {
483
+ e.stopPropagation(); e.preventDefault();
484
+ isResizing = true; resizeHandle = dir;
485
+ const b = currentlySelectedBubble;
486
+ const r = b.getBoundingClientRect();
487
+ origW = r.width; origH = r.height; origX = b.offsetLeft; origY = b.offsetTop;
488
+ origMX = e.clientX; origMY = e.clientY;
489
+ }
490
+ function resizeBubble(e) {
491
+ if(!isResizing) return;
492
+ const dx = e.clientX - origMX; const dy = e.clientY - origMY;
493
+ const b = currentlySelectedBubble;
494
+ if(resizeHandle.includes('e')) b.style.width = (origW + dx) + 'px';
495
+ if(resizeHandle.includes('w')) { b.style.width = (origW - dx) + 'px'; b.style.left = (origX + dx) + 'px'; }
496
+ if(resizeHandle.includes('s')) b.style.height = (origH + dy) + 'px';
497
+ if(resizeHandle.includes('n')) { b.style.height = (origH - dy) + 'px'; b.style.top = (origY + dy) + 'px'; }
498
+ }
499
+ function stopResize() { isResizing = false; }
500
+
501
+ function slideTail(val) { if(currentlySelectedBubble) currentlySelectedBubble.style.setProperty('--tail-pos', val+'%'); }
502
+ function rotateBubbleTail() {
503
+ if(!currentlySelectedBubble) return;
504
+ const b = currentlySelectedBubble;
505
+ if(b.classList.contains('tail-bottom')) b.classList.replace('tail-bottom','tail-left');
506
+ else if(b.classList.contains('tail-left')) b.classList.replace('tail-left','tail-top');
507
+ else if(b.classList.contains('tail-top')) b.classList.replace('tail-top','tail-right');
508
+ else b.className = b.className.replace(/tail-\w+/,'tail-bottom');
509
+ }
510
+ function changeBubbleType(val) {
511
+ if(!currentlySelectedBubble) return;
512
+ currentlySelectedBubble.className = `speech-bubble ${val} tail-bottom selected`;
513
+ currentlySelectedBubble.dataset.type = val;
514
+ selectBubble(currentlySelectedBubble); // refresh UI
515
+ }
516
+ function editBubbleText(b) {
517
+ const span = b.querySelector('.bubble-text');
518
+ const txt = document.createElement('textarea');
519
+ txt.value = span.textContent;
520
+ b.appendChild(txt); span.style.display='none';
521
+ txt.focus();
522
+ txt.onblur = () => { span.textContent = txt.value; txt.remove(); span.style.display='block'; };
523
+ }
524
+ function addBubbleToPanel() {
525
+ if(!currentlySelectedPanel) return alert("Select a panel first");
526
+ const b = createBubbleElement({text:"Text...", left:"20px", top:"20px"});
527
+ currentlySelectedPanel.appendChild(b);
528
+ selectBubble(b);
529
+ }
530
+ function deleteBubble() { if(currentlySelectedBubble) { currentlySelectedBubble.remove(); selectBubble(null); } }
531
+
532
+ // --- PANEL ACTIONS ---
533
+ function startPan(e) {
534
+ if(e.button!==0) return;
535
+ const img = e.target;
536
+ if((parseFloat(img.dataset.zoom)||100) <= 100) return;
537
+ e.preventDefault(); isPanning = true;
538
+ img.classList.add('panning');
539
+ panStartX = e.clientX; panStartY = e.clientY;
540
+ panStartTx = parseFloat(img.dataset.translateX||0);
541
+ panStartTy = parseFloat(img.dataset.translateY||0);
542
+ }
543
+ function panImage(e) {
544
+ if(!isPanning || !currentlySelectedPanel) return;
545
+ const img = currentlySelectedPanel.querySelector('img');
546
+ img.dataset.translateX = panStartTx + (e.clientX - panStartX);
547
+ img.dataset.translateY = panStartTy + (e.clientY - panStartY);
548
+ updateImageTransform(img);
549
+ }
550
+ function stopPan() { isPanning = false; currentlySelectedPanel?.querySelector('img')?.classList.remove('panning'); }
551
+ function handleZoom(e) {
552
+ if(!currentlySelectedPanel) return;
553
+ const img = currentlySelectedPanel.querySelector('img');
554
+ img.dataset.zoom = e.target.value;
555
+ updateImageTransform(img);
556
+ }
557
+ function updateImageTransform(img) {
558
+ const z = (img.dataset.zoom||100)/100;
559
+ const x = img.dataset.translateX||0; const y = img.dataset.translateY||0;
560
+ img.style.transform = `translateX(${x}px) translateY(${y}px) scale(${z})`;
561
+ img.classList.toggle('pannable', z>1);
562
+ }
563
+ function resetPanelTransform(loadOnly=false) {
564
+ if(!currentlySelectedPanel) return;
565
+ const img = currentlySelectedPanel.querySelector('img');
566
+ if(!loadOnly) {
567
+ img.dataset.zoom=100; img.dataset.translateX=0; img.dataset.translateY=0;
568
+ }
569
+ document.getElementById('zoom-slider').value = img.dataset.zoom || 100;
570
+ updateImageTransform(img);
571
+ }
572
+
573
+ function replacePanelImage() {
574
+ if(!currentlySelectedPanel) return alert("Select panel");
575
+ const img = currentlySelectedPanel.querySelector('img');
576
+ const inp = document.getElementById('image-uploader');
577
+ inp.onchange = async (e) => {
578
+ const f = e.target.files[0]; if(!f) return;
579
+ const fd = new FormData(); fd.append('image', f);
580
+ img.style.opacity=0.5;
581
+ const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd});
582
+ const d = await r.json();
583
+ if(d.success) { img.src = `/frames/${d.new_filename}?sid=${sid}&t=${Date.now()}`; resetPanelTransform(); }
584
+ else alert(d.error);
585
+ img.style.opacity=1;
586
+ };
587
+ inp.click();
588
+ }
589
+ async function adjustFrame(dir) {
590
+ if(!currentlySelectedPanel) return alert("Select panel");
591
+ const img = currentlySelectedPanel.querySelector('img');
592
+ let fname = img.src.split('/').pop().split('?')[0];
593
+ const r = await fetch(`/regenerate_frame?sid=${sid}`, {
594
+ method:'POST', headers:{'Content-Type':'application/json'},
595
+ body: JSON.stringify({filename:fname, direction:dir})
596
+ });
597
+ const d = await r.json();
598
+ if(d.success) img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
599
+ else alert(d.message);
600
+ }
601
+ async function gotoTimestamp() {
602
+ if(!currentlySelectedPanel) return alert("Select panel");
603
+ const img = currentlySelectedPanel.querySelector('img');
604
+ let val = document.getElementById('timestamp-input').value;
605
+ if(val.includes(':')) { const p=val.split(':'); val = parseInt(p[0])*60 + parseFloat(p[1]); }
606
+ let fname = img.src.split('/').pop().split('?')[0];
607
+ const r = await fetch(`/goto_timestamp?sid=${sid}`, {
608
+ method:'POST', headers:{'Content-Type':'application/json'},
609
+ body: JSON.stringify({filename:fname, timestamp:val})
610
+ });
611
+ const d = await r.json();
612
+ if(d.success) img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
613
+ else alert(d.message);
614
+ }
615
+ async function exportPagesToPNG() {
616
+ const pgs = document.querySelectorAll('.comic-page');
617
+ for(let i=0; i<pgs.length; i++) {
618
  try {
619
+ const url = await htmlToImage.toPng(pgs[i], {pixelRatio:3});
620
  const a = document.createElement('a');
621
+ a.download = `comic-page-${i+1}.png`; a.href=url; a.click();
 
 
622
  } catch(e) { console.error(e); alert("Export failed"); }
623
  }
624
  }
 
627
  </html>
628
  '''
629
 
630
+ # --- BACKEND LOGIC ---
631
  class EnhancedComicGenerator:
632
  def __init__(self, sid):
633
  self.sid = sid
 
641
  os.makedirs(self.output_dir, exist_ok=True)
642
 
643
  self.video_fps = None
644
+ self.frame_metadata = {}
645
 
646
  def update_status(self, message, progress):
647
  try:
 
649
  json.dump({'message': message, 'progress': progress}, f)
650
  except: pass
651
 
652
+ def cleanup_generated(self):
653
  if os.path.exists(self.frames_dir): shutil.rmtree(self.frames_dir)
654
+ if os.path.exists(self.output_dir): shutil.rmtree(self.output_dir)
655
  os.makedirs(self.frames_dir, exist_ok=True)
656
+ os.makedirs(self.output_dir, exist_ok=True)
657
+ srt = os.path.join(self.user_dir, 'subs.srt')
658
+ if os.path.exists(srt): os.remove(srt)
659
 
660
  def generate_comic(self):
661
  try:
662
  if cv2 is None: raise Exception("OpenCV missing on server.")
663
+ self.cleanup_generated()
664
 
665
+ self.update_status("Processing Video...", 5)
666
  cap = cv2.VideoCapture(self.video_path)
667
  if not cap.isOpened(): raise Exception("Invalid Video")
 
668
  self.video_fps = cap.get(cv2.CAP_PROP_FPS) or 25
669
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
670
  cap.release()
671
 
672
  # 1. Subtitles
673
+ self.update_status("Extracting Dialogue...", 20)
674
+ user_srt = os.path.join(self.user_dir, 'subs.srt')
675
  try:
676
+ get_real_subtitles(self.video_path)
677
+ if os.path.exists('test1.srt'): shutil.move('test1.srt', user_srt)
 
 
678
  except:
679
+ with open(user_srt, 'w') as f: f.write("1\n00:00:00,000 --> 00:00:05,000\nStart\n")
 
 
680
 
681
+ # 2. Keyframes
682
+ self.update_status("Generating Panels...", 40)
683
+ with open(user_srt, 'r', encoding='utf-8') as f: subs = list(srt.parse(f.read()))
 
 
 
 
684
 
685
+ cap = cv2.VideoCapture(self.video_path)
686
  frame_files = []
687
  bubbles = []
688
 
689
+ limit_subs = subs[:12] # Limit to 12 panels for demo speed
 
690
 
691
  for i, sub in enumerate(limit_subs):
692
  mid = (sub.start.total_seconds() + sub.end.total_seconds()) / 2
 
696
  fname = f"frame_{i}.png"
697
  cv2.imwrite(os.path.join(self.frames_dir, fname), frame)
698
  frame_files.append(fname)
699
+ # Map file to time for regeneration
700
+ self.frame_metadata[fname] = mid
701
+
702
  bubbles.append(bubble(
703
  dialog=sub.content,
704
+ bubble_offset_x=50, bubble_offset_y=20,
705
+ lip_x=-1, lip_y=-1, emotion='normal'
 
 
 
706
  ))
707
  cap.release()
708
+
709
+ # Save metadata for features like "Next Frame"
710
+ with open(os.path.join(self.frames_dir, 'frame_metadata.json'), 'w') as f:
711
+ json.dump(self.frame_metadata, f)
712
 
713
+ # 3. Enhance
714
+ self.update_status("Enhancing...", 70)
715
+ # (Call enhancers here if needed)
716
+
717
+ # 4. Assemble
718
+ self.update_status("Finalizing...", 90)
719
  pages_data = []
720
  for i in range(0, len(frame_files), 4):
721
  batch_f = frame_files[i:i+4]
722
  batch_b = bubbles[i:i+4]
723
  panels = [{'image': f} for f in batch_f]
 
724
  b_data = [b.__dict__ if hasattr(b, '__dict__') else b for b in batch_b]
725
  pages_data.append({'panels': panels, 'bubbles': b_data})
726
 
727
  with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
728
  json.dump(pages_data, f)
729
+
730
  self.update_status("Done!", 100)
731
 
732
  except Exception as e:
733
  traceback.print_exc()
734
  self.update_status(f"Error: {str(e)}", -1)
735
 
736
+ # --- Helper Methods for Editor ---
737
+ def regenerate_frame(self, fname, direction):
738
+ try:
739
+ meta_path = os.path.join(self.frames_dir, 'frame_metadata.json')
740
+ if not os.path.exists(meta_path): return {"success":False, "message":"No metadata"}
741
+ with open(meta_path,'r') as f: meta = json.load(f)
742
+
743
+ if fname not in meta: return {"success":False, "message":"Frame not found"}
744
+ curr_time = meta[fname]
745
+
746
+ if not self.video_fps:
747
+ cap = cv2.VideoCapture(self.video_path); self.video_fps = cap.get(cv2.CAP_PROP_FPS); cap.release()
748
+
749
+ offset = (1.0/self.video_fps) * (1 if direction=='forward' else -1)
750
+ new_time = max(0, curr_time + offset)
751
+
752
+ cap = cv2.VideoCapture(self.video_path)
753
+ cap.set(cv2.CAP_PROP_POS_MSEC, new_time*1000)
754
+ ret, frame = cap.read()
755
+ cap.release()
756
+
757
+ if ret:
758
+ cv2.imwrite(os.path.join(self.frames_dir, fname), frame)
759
+ meta[fname] = new_time
760
+ with open(meta_path,'w') as f: json.dump(meta, f)
761
+ return {"success":True}
762
+ return {"success":False, "message":"End of video"}
763
+ except Exception as e: return {"success":False, "message":str(e)}
764
+
765
+ def get_frame_at_timestamp(self, fname, ts):
766
+ try:
767
+ cap = cv2.VideoCapture(self.video_path)
768
+ cap.set(cv2.CAP_PROP_POS_MSEC, float(ts)*1000)
769
+ ret, frame = cap.read()
770
+ cap.release()
771
+ if ret:
772
+ cv2.imwrite(os.path.join(self.frames_dir, fname), frame)
773
+ # Update meta
774
+ meta_path = os.path.join(self.frames_dir, 'frame_metadata.json')
775
+ if os.path.exists(meta_path):
776
+ with open(meta_path,'r') as f: meta = json.load(f)
777
+ meta[fname] = float(ts)
778
+ with open(meta_path,'w') as f: json.dump(meta, f)
779
+ return {"success":True}
780
+ return {"success":False, "message":"Invalid time"}
781
+ except Exception as e: return {"success":False, "message":str(e)}
782
+
783
+ # --- ROUTES ---
784
  @app.route('/')
785
+ def index(): return INDEX_HTML
 
786
 
787
  @app.route('/uploader', methods=['POST'])
788
  def upload():
789
  sid = request.args.get('sid')
790
  if not sid: return "Missing SID", 400
791
+ f = request.files['file']
 
 
792
  gen = EnhancedComicGenerator(sid)
793
+ f.save(gen.video_path)
 
 
794
  gen.update_status("Starting...", 5)
795
  threading.Thread(target=gen.generate_comic).start()
796
  return jsonify({'success': True})
 
798
  @app.route('/status')
799
  def get_status():
800
  sid = request.args.get('sid')
801
+ if not sid: return jsonify({'progress': -1, 'message': "No SID"})
802
+ path = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
803
+ if os.path.exists(path): return send_file(path)
804
+ return jsonify({'progress': 0, 'message': "Waiting..."})
 
 
805
 
806
  @app.route('/output/<path:filename>')
807
  def get_output(filename):
808
  sid = request.args.get('sid')
 
809
  return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
810
 
811
  @app.route('/frames/<path:filename>')
812
  def get_frame(filename):
813
  sid = request.args.get('sid')
 
814
  return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
815
 
816
+ @app.route('/regenerate_frame', methods=['POST'])
817
+ def regen_frame():
818
+ sid = request.args.get('sid')
819
+ d = request.get_json()
820
+ return jsonify(EnhancedComicGenerator(sid).regenerate_frame(d['filename'], d['direction']))
821
+
822
+ @app.route('/goto_timestamp', methods=['POST'])
823
+ def go_time():
824
+ sid = request.args.get('sid')
825
+ d = request.get_json()
826
+ return jsonify(EnhancedComicGenerator(sid).get_frame_at_timestamp(d['filename'], d['timestamp']))
827
+
828
+ @app.route('/replace_panel', methods=['POST'])
829
+ def rep_panel():
830
+ sid = request.args.get('sid')
831
+ f = request.files['image']
832
+ gen = EnhancedComicGenerator(sid)
833
+ fname = f"replaced_{int(time.time())}.png"
834
+ f.save(os.path.join(gen.frames_dir, fname))
835
+ return jsonify({'success': True, 'new_filename': fname})
836
+
837
  if __name__ == '__main__':
838
  os.makedirs(BASE_USER_DIR, exist_ok=True)
839
  port = int(os.getenv("PORT", 7860))