tester343 commited on
Commit
809edf1
·
verified ·
1 Parent(s): 75c73c6

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +243 -180
app_enhanced.py CHANGED
@@ -9,7 +9,7 @@ import logging
9
  from concurrent.futures import ThreadPoolExecutor
10
  from flask import Flask, render_template, request, jsonify, send_from_directory, send_file
11
 
12
- # --- 0. LOGGING SETUP ---
13
  logging.basicConfig(level=logging.INFO)
14
  logger = logging.getLogger(__name__)
15
 
@@ -19,13 +19,15 @@ try:
19
  import numpy as np
20
  from PIL import Image
21
  import srt
22
- print("✅ OpenCV, Numpy, Pillow, SRT loaded successfully.")
23
  except ImportError as e:
24
  print(f"❌ CRITICAL ERROR: Missing python library. {e}")
25
- print("⚠️ Please ensure 'opencv-python-headless' is in requirements.txt")
26
- cv2 = None; np = None; Image = None; srt = None
 
 
 
27
 
28
- # --- 2. BACKEND IMPORTS WITH DUMMIES ---
29
  def dummy_func(*args, **kwargs): return 0, 0, None, None
30
 
31
  try:
@@ -35,10 +37,13 @@ except:
35
 
36
  try:
37
  from backend.simple_color_enhancer import SimpleColorEnhancer
38
- from backend.quality_color_enhancer import QualityColorEnhancer
39
  except:
40
  class SimpleColorEnhancer:
41
  def enhance_single(self, *args): pass
 
 
 
 
42
  class QualityColorEnhancer:
43
  def enhance_single(self, *args): pass
44
 
@@ -64,10 +69,12 @@ except:
64
  def place_bubble_ai(self, p, l): return 50, 20
65
  ai_bubble_placer = DummyPlacer()
66
 
67
- # --- FLASK SETUP ---
 
68
  app = Flask(__name__)
69
- BASE_USER_DIR = "userdata"
70
 
 
71
  INDEX_HTML = '''
72
  <!DOCTYPE html>
73
  <html lang="en">
@@ -75,201 +82,256 @@ INDEX_HTML = '''
75
  <meta charset="UTF-8">
76
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
77
  <title>Movie to Comic Generator</title>
 
78
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
79
  <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Lato&display=swap" rel="stylesheet">
80
  <style>
81
  body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
 
 
82
  #upload-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; width: 100%; }
83
  .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; }
 
 
84
  #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; }
85
- .file-label { display: block; padding: 15px; background: #2c3e50; color: white; border-radius: 8px; cursor: pointer; margin-bottom: 10px; }
86
- .submit-btn { width: 100%; padding: 15px; background: #e67e22; color: white; border: none; border-radius: 8px; font-size: 18px; cursor: pointer; font-weight: bold; }
 
 
 
87
  .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; }
88
  @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
89
-
90
- /* COMIC STYLES */
91
- .comic-page { background: white; width: 600px; height: 400px; position: relative; overflow: hidden; border: 2px solid #000; margin: 0 auto 20px; }
92
- .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; }
93
- .panel { position: relative; overflow: hidden; border: 2px solid #000; background: #eee; }
94
- .panel img { width: 100%; height: 100%; object-fit: cover; }
95
-
96
- /* EXACT USER CSS FOR SPEECH BUBBLE (EXPORT SAFE) */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  .speech-bubble.speech {
98
- --b: 3em; --h: 1.8em; --t: 0.6; --p: var(--tail-pos, 50%); --r: 1.2em;
99
- --c: var(--bubble-fill-color, #4ECDC4);
100
- background: var(--c); color: var(--bubble-text-color, #fff); padding: 1em; position: absolute;
 
 
 
 
 
 
 
 
 
 
101
  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);
102
- font-family: 'Comic Neue', cursive; font-weight: bold; font-size: 14px; text-align: center;
103
- min-width: 80px; min-height: 40px; display: flex; align-items: center; justify-content: center;
104
- cursor: move; z-index: 10;
105
  }
 
 
106
  .speech-bubble.speech:before {
107
  content: ""; position: absolute; width: var(--b); height: var(--h);
 
108
  background: radial-gradient(100% 100% at 100% 0, transparent calc(var(--t) * 100% - 1px), var(--c) calc(var(--t) * 100%));
109
  border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
110
  }
 
 
111
  .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))); }
 
 
112
  .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); }
 
 
113
  .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; }
 
 
114
  .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; }
115
 
116
- .speech-bubble.selected { outline: 2px dashed #333; }
117
- .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; }
118
- .edit-controls button { width: 100%; margin-top: 5px; padding: 8px; cursor: pointer; }
119
- .slider-container { margin-top:10px; }
120
- .slider-container input { width: 100%; }
 
 
 
 
 
 
121
  </style>
122
  </head>
123
  <body>
 
124
  <div id="upload-container">
125
  <div class="upload-box">
126
- <h1>🎬 Comic Generator</h1>
127
- <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fname').innerText=this.files[0].name">
128
  <label for="file-upload" class="file-label">Choose Video</label>
129
- <span id="fname">No file</span>
130
  <button class="submit-btn" onclick="upload()">Generate</button>
131
- <div id="loading" style="display:none;">
132
  <div class="loader"></div>
133
- <p id="status">Starting...</p>
134
  </div>
135
  </div>
136
  </div>
137
 
 
138
  <div id="editor-container">
139
- <div id="comic-pages"></div>
 
 
 
140
  <div class="edit-controls">
141
  <h4>Editor</h4>
142
- <label>Tail Pos:</label>
143
- <div class="slider-container"><input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)"></div>
144
- <button onclick="exportToPng()">💾 Export PNG</button>
145
- <button onclick="location.reload()" style="color:red; margin-top:10px;">↺ Reset</button>
 
 
 
 
146
  </div>
147
  </div>
148
 
149
  <script>
150
- // --- SESSION MANAGEMENT ---
151
- function generateUUID() {
152
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
153
- var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
154
- return v.toString(16);
155
- });
156
- }
157
- let sid = localStorage.getItem('comic_sid');
158
- if(!sid) { sid = generateUUID(); localStorage.setItem('comic_sid', sid); }
159
  console.log("SID:", sid);
160
 
161
- let interval;
162
- let currentlySelectedBubble = null;
163
 
164
  async function upload() {
165
- const file = document.getElementById('file-upload').files[0];
166
- if(!file) return alert("Select file");
167
-
168
- const fd = new FormData();
169
- fd.append('file', file);
170
 
171
  document.querySelector('.upload-box').style.display='none';
172
- document.getElementById('loading').style.display='block';
173
 
174
- try {
175
- const res = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
176
- if(res.ok) {
177
- interval = setInterval(checkStatus, 2000);
178
- } else {
179
- const err = await res.json();
180
- alert("Upload Error: " + (err.message || "Unknown"));
181
- location.reload();
182
- }
183
- } catch(e) {
184
- console.error(e);
185
- alert("Connection Failed. Check logs.");
186
- }
187
  }
188
 
189
  async function checkStatus() {
190
- try {
191
- const res = await fetch(`/status?sid=${sid}`);
192
- const data = await res.json();
193
- document.getElementById('status').innerText = data.message;
194
-
195
- if(data.progress >= 100) {
196
- clearInterval(interval);
197
- document.getElementById('upload-container').style.display='none';
198
- document.getElementById('editor-container').style.display='block';
199
- loadComic();
200
- } else if(data.progress < 0) {
201
- clearInterval(interval);
202
- alert("Generation Error: " + data.message);
203
- location.reload();
204
- }
205
- } catch(e) { console.error("Status check failed", e); }
206
  }
207
 
208
  function loadComic() {
209
  fetch(`/output/pages.json?sid=${sid}`).then(r=>r.json()).then(data => {
210
- const c = document.getElementById('comic-pages');
211
- data.forEach((p, i) => {
 
212
  const div = document.createElement('div');
213
  div.className = 'comic-page';
214
- div.id = 'page-'+i;
215
  const grid = document.createElement('div');
216
  grid.className = 'comic-grid';
217
-
218
  p.panels.forEach((pan, j) => {
219
  const pDiv = document.createElement('div');
220
  pDiv.className = 'panel';
221
- const img = document.createElement('img');
222
- img.src = `/frames/${pan.image}?sid=${sid}`;
223
- pDiv.appendChild(img);
224
-
225
- if(p.bubbles && p.bubbles[j] && p.bubbles[j].dialog) {
226
- const b = document.createElement('div');
227
- b.className = 'speech-bubble speech tail-bottom';
228
- b.innerText = p.bubbles[j].dialog;
229
- b.style.left = (p.bubbles[j].bubble_offset_x || 50) + 'px';
230
- b.style.top = (p.bubbles[j].bubble_offset_y || 20) + 'px';
231
  pDiv.appendChild(b);
232
- makeInteractive(b);
233
  }
234
  grid.appendChild(pDiv);
235
  });
236
  div.appendChild(grid);
237
- c.appendChild(div);
238
  });
239
  });
240
  }
241
 
242
- function makeInteractive(el) {
243
- el.onmousedown = function(e) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  e.stopPropagation();
245
- currentlySelectedBubble = el;
246
- document.querySelectorAll('.speech-bubble').forEach(b => b.classList.remove('selected'));
247
- el.classList.add('selected');
248
-
249
- const offX = e.clientX - el.offsetLeft;
250
- const offY = e.clientY - el.offsetTop;
251
  document.onmousemove = function(ev) {
252
- el.style.left = (ev.clientX - offX) + 'px';
253
- el.style.top = (ev.clientY - offY) + 'px';
254
  }
255
- document.onmouseup = function() { document.onmousemove = null; }
 
 
 
 
 
 
 
 
 
256
  };
 
 
 
 
 
 
 
 
 
 
 
 
257
  }
258
 
259
- function slideTail(val) {
260
- if(currentlySelectedBubble) {
261
- currentlySelectedBubble.style.setProperty('--tail-pos', val + '%');
262
  }
263
  }
 
264
 
265
- async function exportToPng() {
266
- const pages = document.querySelectorAll('.comic-page');
267
- for(let p of pages) {
268
- const url = await htmlToImage.toPng(p, {pixelRatio: 2});
269
  const a = document.createElement('a');
270
- a.download = 'comic.png';
271
- a.href = url;
272
- a.click();
273
  }
274
  }
275
  </script>
@@ -296,74 +358,62 @@ class EnhancedComicGenerator:
296
  with open(self.status_file, 'w') as f:
297
  json.dump({'message': message, 'progress': progress}, f)
298
  except: pass
299
-
300
  def cleanup_previous_run(self):
301
- # Remove old frames/output files but keep directories
302
- for folder in [self.frames_dir, self.output_dir]:
303
- if os.path.exists(folder):
304
- for filename in os.listdir(folder):
305
- file_path = os.path.join(folder, filename)
306
- try:
307
- if os.path.isfile(file_path) or os.path.islink(file_path):
308
- os.unlink(file_path)
309
- elif os.path.isdir(file_path):
310
- shutil.rmtree(file_path)
311
- except Exception as e:
312
- print(f'Failed to delete {file_path}. Reason: {e}')
313
 
314
  def generate_comic(self):
315
  try:
316
- print(f"[{self.sid}] Generation thread started.")
317
- if cv2 is None: raise Exception("OpenCV not installed on server.")
318
 
319
- self.update_status("Processing Video...", 10)
320
  cap = cv2.VideoCapture(self.video_path)
321
- if not cap.isOpened(): raise Exception("Invalid Video File")
322
-
323
  self.video_fps = cap.get(cv2.CAP_PROP_FPS) or 25
324
- total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
325
  cap.release()
326
 
327
  # 1. Subtitles
328
- self.update_status("Extracting Dialogue...", 30)
329
  user_srt = os.path.join(self.user_dir, 'subs.srt')
330
  try:
331
- get_real_subtitles(self.video_path) # Should produce test1.srt
332
- if os.path.exists('test1.srt'):
333
- shutil.move('test1.srt', user_srt)
334
- except Exception as e:
335
- print(f"Subtitle generation warning: {e}")
336
- if not os.path.exists(user_srt):
337
- with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\nComic Scene 1\n")
338
-
339
- # 2. Extract Frames
340
- self.update_status("Generating Panels...", 50)
341
- with open(user_srt, 'r', encoding='utf-8') as f:
342
- subs = list(srt.parse(f.read()))
343
-
344
  cap = cv2.VideoCapture(self.video_path)
345
  frame_files = []
346
  bubbles = []
347
 
348
- # Simple sampling logic
349
- limit_subs = subs[:12]
350
-
351
  for i, sub in enumerate(limit_subs):
352
- mid = (sub.start.total_seconds() + sub.end.total_seconds()) / 2
353
- cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
354
  ret, frame = cap.read()
355
  if ret:
356
  fname = f"frame_{i}.png"
357
  cv2.imwrite(os.path.join(self.frames_dir, fname), frame)
358
  frame_files.append(fname)
359
- # Pass all necessary args to prevent TypeError
360
  bubbles.append(bubble(
361
  dialog=sub.content,
362
  bubble_offset_x=50, bubble_offset_y=20,
363
  lip_x=-1, lip_y=-1, emotion='normal'
364
  ))
365
  cap.release()
366
-
367
  # 3. Assemble
368
  self.update_status("Finalizing...", 90)
369
  pages_data = []
@@ -376,17 +426,15 @@ class EnhancedComicGenerator:
376
 
377
  with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
378
  json.dump(pages_data, f)
379
-
380
- # Also save the HTML template for debug purposes
381
- with open(os.path.join(self.output_dir, 'page.html'), 'w') as f:
382
  f.write(INDEX_HTML)
383
 
384
  self.update_status("Done!", 100)
385
- print(f"[{self.sid}] Generation Complete.")
386
-
387
  except Exception as e:
388
  traceback.print_exc()
389
- self.update_status(f"Failed: {str(e)}", -1)
390
 
391
  # --- ROUTES ---
392
  @app.route('/')
@@ -395,27 +443,20 @@ def index(): return INDEX_HTML
395
  @app.route('/uploader', methods=['POST'])
396
  def upload():
397
  sid = request.args.get('sid')
398
- if not sid: return jsonify({'message': 'No SID provided'}), 400
399
-
400
- print(f"[SERVER] Upload received for SID: {sid}")
401
-
402
- if 'file' not in request.files: return jsonify({'message': 'No file part'}), 400
403
  f = request.files['file']
404
 
405
  gen = EnhancedComicGenerator(sid)
406
- gen.cleanup_previous_run()
407
  f.save(gen.video_path)
408
 
409
  gen.update_status("Starting...", 5)
410
  threading.Thread(target=gen.generate_comic).start()
411
-
412
  return jsonify({'success': True})
413
 
414
  @app.route('/status')
415
  def get_status():
416
  sid = request.args.get('sid')
417
- if not sid: return jsonify({'progress': -1, 'message': "No SID"})
418
-
419
  path = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
420
  if os.path.exists(path): return send_file(path)
421
  return jsonify({'progress': 0, 'message': "Waiting..."})
@@ -430,8 +471,30 @@ def get_frame(filename):
430
  sid = request.args.get('sid')
431
  return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
432
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
433
  if __name__ == '__main__':
434
  os.makedirs(BASE_USER_DIR, exist_ok=True)
435
- port = int(os.getenv("PORT", 7860))
436
- print(f"🚀 Server starting on port {port}")
437
- app.run(host='0.0.0.0', port=port)
 
9
  from concurrent.futures import ThreadPoolExecutor
10
  from flask import Flask, render_template, request, jsonify, send_from_directory, send_file
11
 
12
+ # --- 0. LOGGING & CONFIG ---
13
  logging.basicConfig(level=logging.INFO)
14
  logger = logging.getLogger(__name__)
15
 
 
19
  import numpy as np
20
  from PIL import Image
21
  import srt
 
22
  except ImportError as e:
23
  print(f"❌ CRITICAL ERROR: Missing python library. {e}")
24
+ # Dummies to allow app startup (will fail on generation if not fixed)
25
+ cv2 = None
26
+ np = None
27
+ Image = None
28
+ srt = None
29
 
30
+ # --- 2. BACKEND MODULE IMPORTS (WITH FALLBACKS) ---
31
  def dummy_func(*args, **kwargs): return 0, 0, None, None
32
 
33
  try:
 
37
 
38
  try:
39
  from backend.simple_color_enhancer import SimpleColorEnhancer
 
40
  except:
41
  class SimpleColorEnhancer:
42
  def enhance_single(self, *args): pass
43
+
44
+ try:
45
+ from backend.quality_color_enhancer import QualityColorEnhancer
46
+ except:
47
  class QualityColorEnhancer:
48
  def enhance_single(self, *args): pass
49
 
 
69
  def place_bubble_ai(self, p, l): return 50, 20
70
  ai_bubble_placer = DummyPlacer()
71
 
72
+
73
+ # --- FLASK APP SETUP ---
74
  app = Flask(__name__)
75
+ BASE_USER_DIR = "userdata" # Root folder for all sessions
76
 
77
+ # --- HTML INTERFACE ---
78
  INDEX_HTML = '''
79
  <!DOCTYPE html>
80
  <html lang="en">
 
82
  <meta charset="UTF-8">
83
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
84
  <title>Movie to Comic Generator</title>
85
+ <!-- html-to-image: Required for accurate PNG export of CSS gradients -->
86
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
87
  <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Lato&display=swap" rel="stylesheet">
88
  <style>
89
  body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
90
+
91
+ /* UPLOAD VIEW */
92
  #upload-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; width: 100%; }
93
  .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; }
94
+
95
+ /* EDITOR VIEW */
96
  #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; }
97
+
98
+ h1 { color: #2c3e50; margin-bottom: 30px; font-weight: 600; }
99
+ .file-input { display: none; }
100
+ .file-label { display: block; padding: 15px; background: #2c3e50; color: white; border-radius: 8px; cursor: pointer; font-weight: bold; }
101
+ .submit-btn { width: 100%; padding: 15px; background: #e67e22; color: white; border: none; border-radius: 8px; font-size: 18px; font-weight: bold; cursor: pointer; margin-top: 20px; }
102
  .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; }
103
  @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
104
+ #status-text { margin-top: 10px; font-size: 18px; }
105
+
106
+ /* COMIC LAYOUT */
107
+ .comic-container-wrapper { max-width: 1200px; margin: 0 auto; padding-bottom: 100px; }
108
+ .comic-page { background: white; width: 600px; height: 400px; box-shadow: 0 4px 10px rgba(0,0,0,0.1); position: relative; overflow: hidden; border: 2px solid #000; margin: 0 auto 30px; }
109
+ .comic-grid { display: grid; grid-template-columns: 285px 285px; grid-template-rows: 185px 185px; gap: 10px; width: 100%; height: 100%; padding: 10px; box-sizing: border-box; }
110
+ .panel { position: relative; overflow: hidden; border: 2px solid #000; background: #eee; cursor: pointer; }
111
+ .panel.selected { outline: 3px solid #2196F3; outline-offset: -3px; }
112
+ .panel img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.1s; }
113
+ .panel img.pannable { cursor: grab; }
114
+ .panel img.panning { cursor: grabbing; }
115
+
116
+ /* SPEECH BUBBLE (SHARK FIN - EXPORT SAFE) */
117
+ .speech-bubble {
118
+ position: absolute; display: flex; justify-content: center; align-items: center;
119
+ width: 150px; height: 80px; min-width: 60px; min-height: 40px; box-sizing: border-box;
120
+ z-index: 10; cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold;
121
+ font-size: 14px; text-align: center;
122
+ }
123
+ .bubble-text { padding: 0.8em; position: relative; z-index: 5; }
124
+ .speech-bubble.selected { outline: 2px dashed #4CAF50; }
125
+ .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.9); text-align:center; padding:8px; z-index:100; resize:none; }
126
+
127
+ /* The Exact CSS Logic Requested (Adapted for Export Compatibility) */
128
  .speech-bubble.speech {
129
+ --b: 3em; /* tail base */
130
+ --h: 1.8em; /* tail height */
131
+ --t: 0.6; /* thickness */
132
+ --p: var(--tail-pos, 50%);
133
+ --r: 1.2em; /* radius */
134
+ --c: var(--bubble-fill-color, #4ECDC4); /* color */
135
+
136
+ background: var(--c);
137
+ color: var(--bubble-text-color, #fff);
138
+ padding: 1em;
139
+ position: absolute;
140
+
141
+ /* Body Radius */
142
  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);
 
 
 
143
  }
144
+
145
+ /* The Tail (Using Gradient instead of Mask for Export Safety) */
146
  .speech-bubble.speech:before {
147
  content: ""; position: absolute; width: var(--b); height: var(--h);
148
+ /* Radial Gradient simulates the concave mask curve */
149
  background: radial-gradient(100% 100% at 100% 0, transparent calc(var(--t) * 100% - 1px), var(--c) calc(var(--t) * 100%));
150
  border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
151
  }
152
+
153
+ /* Tail Positioning */
154
  .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))); }
155
+
156
+ .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); }
157
  .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); }
158
+
159
+ .speech-bubble.speech.tail-left { border-radius: var(--r); }
160
  .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; }
161
+
162
+ .speech-bubble.speech.tail-right { border-radius: var(--r); }
163
  .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; }
164
 
165
+ .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
166
+ .speech-bubble.thought::after { display:none; }
167
+ /* (Thought dots CSS omitted for brevity, handled in JS if needed) */
168
+
169
+ .resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; }
170
+ .speech-bubble.selected .resize-handle { display: block; }
171
+ .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
172
+
173
+ .edit-controls { position: fixed; bottom: 20px; right: 20px; width: 220px; background: white; padding: 15px; border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 100; }
174
+ .edit-controls button, input, select { width: 100%; margin-top: 5px; padding: 5px; }
175
+ .slider-container { margin-top: 10px; }
176
  </style>
177
  </head>
178
  <body>
179
+ <!-- UPLOAD -->
180
  <div id="upload-container">
181
  <div class="upload-box">
182
+ <h1>🎬 Movie to Comic</h1>
183
+ <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
184
  <label for="file-upload" class="file-label">Choose Video</label>
185
+ <span id="fn">No file</span>
186
  <button class="submit-btn" onclick="upload()">Generate</button>
187
+ <div class="loading-view" id="loading-view">
188
  <div class="loader"></div>
189
+ <p id="status-text">Starting...</p>
190
  </div>
191
  </div>
192
  </div>
193
 
194
+ <!-- EDITOR -->
195
  <div id="editor-container">
196
+ <div class="comic-container-wrapper">
197
+ <h1 style="text-align:center;">Generated Comic</h1>
198
+ <div id="comic-pages"></div>
199
+ </div>
200
  <div class="edit-controls">
201
  <h4>Editor</h4>
202
+ <button onclick="addBubble()">+ Bubble</button>
203
+ <button onclick="deleteBubble()" style="background:#ffcccc">Delete</button>
204
+ <label>Tail Position:</label>
205
+ <input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)">
206
+ <button onclick="rotateTail()">Rotate Tail</button>
207
+ <hr>
208
+ <button onclick="exportComic()" style="background:#ccffcc">💾 Save PNG</button>
209
+ <button onclick="location.reload()">↺ New Video</button>
210
  </div>
211
  </div>
212
 
213
  <script>
214
+ // Session ID
215
+ function genUUID(){ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,c=>{var r=Math.random()*16|0,v=c=='x'?r:(r&0x3|0x8);return v.toString(16);}); }
216
+ let sid = localStorage.getItem('comic_sid') || genUUID();
217
+ localStorage.setItem('comic_sid', sid);
 
 
 
 
 
218
  console.log("SID:", sid);
219
 
220
+ let interval, selectedBubble;
 
221
 
222
  async function upload() {
223
+ const f = document.getElementById('file-upload').files[0];
224
+ if(!f) return alert("Select file");
 
 
 
225
 
226
  document.querySelector('.upload-box').style.display='none';
227
+ document.getElementById('loading-view').style.display='flex';
228
 
229
+ const fd = new FormData(); fd.append('file', f);
230
+ const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
231
+ if(r.ok) interval = setInterval(checkStatus, 2000);
232
+ else { alert("Upload failed"); location.reload(); }
 
 
 
 
 
 
 
 
 
233
  }
234
 
235
  async function checkStatus() {
236
+ const r = await fetch(`/status?sid=${sid}`);
237
+ const d = await r.json();
238
+ document.getElementById('status-text').innerText = d.message;
239
+ if(d.progress >= 100) {
240
+ clearInterval(interval);
241
+ document.getElementById('upload-container').style.display='none';
242
+ document.getElementById('editor-container').style.display='block';
243
+ loadComic();
244
+ }
 
 
 
 
 
 
 
245
  }
246
 
247
  function loadComic() {
248
  fetch(`/output/pages.json?sid=${sid}`).then(r=>r.json()).then(data => {
249
+ const con = document.getElementById('comic-pages');
250
+ con.innerHTML = '';
251
+ data.forEach((p,i) => {
252
  const div = document.createElement('div');
253
  div.className = 'comic-page';
 
254
  const grid = document.createElement('div');
255
  grid.className = 'comic-grid';
 
256
  p.panels.forEach((pan, j) => {
257
  const pDiv = document.createElement('div');
258
  pDiv.className = 'panel';
259
+ pDiv.innerHTML = `<img src="/frames/${pan.image}?sid=${sid}">`;
260
+ if(p.bubbles && p.bubbles[j]) {
261
+ const b = createBubble(p.bubbles[j].dialog, p.bubbles[j].bubble_offset_x, p.bubbles[j].bubble_offset_y);
 
 
 
 
 
 
 
262
  pDiv.appendChild(b);
 
263
  }
264
  grid.appendChild(pDiv);
265
  });
266
  div.appendChild(grid);
267
+ con.appendChild(div);
268
  });
269
  });
270
  }
271
 
272
+ function createBubble(text, x, y) {
273
+ const b = document.createElement('div');
274
+ b.className = 'speech-bubble speech tail-bottom';
275
+ b.style.left = (x||50)+'px'; b.style.top = (y||20)+'px';
276
+ b.innerHTML = `<span class="bubble-text">${text||'Text'}</span><div class="resize-handle se"></div>`;
277
+
278
+ b.onmousedown = function(e) {
279
+ if(e.target.classList.contains('resize-handle')) return;
280
+ e.stopPropagation(); selectedBubble = b;
281
+ document.querySelectorAll('.speech-bubble').forEach(el=>el.classList.remove('selected'));
282
+ b.classList.add('selected');
283
+ const ox = e.clientX - b.offsetLeft, oy = e.clientY - b.offsetTop;
284
+ document.onmousemove = function(ev) {
285
+ b.style.left = (ev.clientX-ox)+'px'; b.style.top = (ev.clientY-oy)+'px';
286
+ }
287
+ document.onmouseup = () => document.onmousemove = null;
288
+ };
289
+
290
+ b.querySelector('.resize-handle').onmousedown = function(e) {
291
  e.stopPropagation();
292
+ const ox = e.clientX, oy = e.clientY, ow = b.offsetWidth, oh = b.offsetHeight;
 
 
 
 
 
293
  document.onmousemove = function(ev) {
294
+ b.style.width = (ow + ev.clientX - ox) + 'px';
295
+ b.style.height = (oh + ev.clientY - oy) + 'px';
296
  }
297
+ document.onmouseup = () => document.onmousemove = null;
298
+ };
299
+
300
+ b.ondblclick = function(e) {
301
+ e.stopPropagation();
302
+ const t = b.querySelector('.bubble-text');
303
+ const i = document.createElement('textarea');
304
+ i.value = t.innerText;
305
+ b.appendChild(i); t.style.display='none'; i.focus();
306
+ i.onblur = function() { t.innerText = i.value; i.remove(); t.style.display='block'; }
307
  };
308
+ return b;
309
+ }
310
+
311
+ function slideTail(v) { if(selectedBubble) selectedBubble.style.setProperty('--tail-pos', v+'%'); }
312
+
313
+ function rotateTail() {
314
+ if(!selectedBubble) return;
315
+ const b = selectedBubble;
316
+ if(b.classList.contains('tail-bottom')) b.classList.replace('tail-bottom','tail-left');
317
+ else if(b.classList.contains('tail-left')) b.classList.replace('tail-left','tail-top');
318
+ else if(b.classList.contains('tail-top')) b.classList.replace('tail-top','tail-right');
319
+ else b.className = b.className.replace(/tail-\w+/,'tail-bottom');
320
  }
321
 
322
+ function addBubble() {
323
+ if(document.querySelector('.panel')) {
324
+ document.querySelector('.panel').appendChild(createBubble("New", 20, 20));
325
  }
326
  }
327
+ function deleteBubble() { if(selectedBubble) selectedBubble.remove(); }
328
 
329
+ async function exportComic() {
330
+ const pgs = document.querySelectorAll('.comic-page');
331
+ for(let i=0; i<pgs.length; i++) {
332
+ const url = await htmlToImage.toPng(pgs[i], {pixelRatio:3});
333
  const a = document.createElement('a');
334
+ a.download = `comic-${i+1}.png`; a.href=url; a.click();
 
 
335
  }
336
  }
337
  </script>
 
358
  with open(self.status_file, 'w') as f:
359
  json.dump({'message': message, 'progress': progress}, f)
360
  except: pass
361
+
362
  def cleanup_previous_run(self):
363
+ # Wipe old frames to prevent mixing comics
364
+ if os.path.exists(self.frames_dir):
365
+ for f in os.listdir(self.frames_dir):
366
+ try: os.remove(os.path.join(self.frames_dir, f))
367
+ except: pass
368
+ if os.path.exists(self.output_dir):
369
+ for f in os.listdir(self.output_dir):
370
+ if f != 'status.json':
371
+ try: os.remove(os.path.join(self.output_dir, f))
372
+ except: pass
 
 
373
 
374
  def generate_comic(self):
375
  try:
376
+ if cv2 is None: raise Exception("OpenCV missing on server.")
 
377
 
378
+ self.update_status("Processing Video...", 5)
379
  cap = cv2.VideoCapture(self.video_path)
380
+ if not cap.isOpened(): raise Exception("Invalid Video")
 
381
  self.video_fps = cap.get(cv2.CAP_PROP_FPS) or 25
 
382
  cap.release()
383
 
384
  # 1. Subtitles
385
+ self.update_status("Extracting Dialogue...", 20)
386
  user_srt = os.path.join(self.user_dir, 'subs.srt')
387
  try:
388
+ get_real_subtitles(self.video_path)
389
+ if os.path.exists('test1.srt'): shutil.move('test1.srt', user_srt)
390
+ except:
391
+ with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\nScene 1\n")
392
+
393
+ # 2. Frames
394
+ self.update_status("Generating Panels...", 40)
395
+ with open(user_srt, 'r', encoding='utf-8') as f: subs = list(srt.parse(f.read()))
 
 
 
 
 
396
  cap = cv2.VideoCapture(self.video_path)
397
  frame_files = []
398
  bubbles = []
399
 
400
+ limit_subs = subs[:12]
 
 
401
  for i, sub in enumerate(limit_subs):
402
+ mid = (sub.start.total_seconds() + sub.end.total_seconds())/2
403
+ cap.set(cv2.CAP_PROP_POS_MSEC, mid*1000)
404
  ret, frame = cap.read()
405
  if ret:
406
  fname = f"frame_{i}.png"
407
  cv2.imwrite(os.path.join(self.frames_dir, fname), frame)
408
  frame_files.append(fname)
409
+ # FIX: Provide ALL args to avoid TypeError
410
  bubbles.append(bubble(
411
  dialog=sub.content,
412
  bubble_offset_x=50, bubble_offset_y=20,
413
  lip_x=-1, lip_y=-1, emotion='normal'
414
  ))
415
  cap.release()
416
+
417
  # 3. Assemble
418
  self.update_status("Finalizing...", 90)
419
  pages_data = []
 
426
 
427
  with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
428
  json.dump(pages_data, f)
429
+
430
+ # Save template for debugging (optional in this mode)
431
+ with open(os.path.join(self.output_dir, 'page.html'), 'w', encoding='utf-8') as f:
432
  f.write(INDEX_HTML)
433
 
434
  self.update_status("Done!", 100)
 
 
435
  except Exception as e:
436
  traceback.print_exc()
437
+ self.update_status(f"Error: {str(e)}", -1)
438
 
439
  # --- ROUTES ---
440
  @app.route('/')
 
443
  @app.route('/uploader', methods=['POST'])
444
  def upload():
445
  sid = request.args.get('sid')
446
+ if not sid: return "No SID", 400
 
 
 
 
447
  f = request.files['file']
448
 
449
  gen = EnhancedComicGenerator(sid)
450
+ gen.cleanup_previous_run() # Clean old files BEFORE new generation
451
  f.save(gen.video_path)
452
 
453
  gen.update_status("Starting...", 5)
454
  threading.Thread(target=gen.generate_comic).start()
 
455
  return jsonify({'success': True})
456
 
457
  @app.route('/status')
458
  def get_status():
459
  sid = request.args.get('sid')
 
 
460
  path = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
461
  if os.path.exists(path): return send_file(path)
462
  return jsonify({'progress': 0, 'message': "Waiting..."})
 
471
  sid = request.args.get('sid')
472
  return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
473
 
474
+ # --- UTILITY ROUTES FOR EDITOR ---
475
+ @app.route('/regenerate_frame', methods=['POST'])
476
+ def regenerate_frame():
477
+ sid = request.args.get('sid')
478
+ data = request.get_json()
479
+ gen = EnhancedComicGenerator(sid)
480
+ return jsonify(gen.regenerate_frame(data['filename'], data['direction']))
481
+
482
+ @app.route('/goto_timestamp', methods=['POST'])
483
+ def goto_timestamp():
484
+ sid = request.args.get('sid')
485
+ data = request.get_json()
486
+ gen = EnhancedComicGenerator(sid)
487
+ return jsonify(gen.get_frame_at_timestamp(data['filename'], data['timestamp']))
488
+
489
+ @app.route('/replace_panel', methods=['POST'])
490
+ def replace_panel():
491
+ sid = request.args.get('sid')
492
+ f = request.files['image']
493
+ gen = EnhancedComicGenerator(sid)
494
+ fname = f"replaced_{int(time.time())}.png"
495
+ f.save(os.path.join(gen.frames_dir, fname))
496
+ return jsonify({'success': True, 'new_filename': fname})
497
+
498
  if __name__ == '__main__':
499
  os.makedirs(BASE_USER_DIR, exist_ok=True)
500
+ app.run(host='0.0.0.0', port=7860)