tester343 commited on
Commit
53c6075
·
verified ·
1 Parent(s): 809edf1

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +420 -158
app_enhanced.py CHANGED
@@ -21,13 +21,9 @@ try:
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,13 +33,10 @@ except:
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,10 +62,9 @@ except:
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 = '''
@@ -82,29 +74,30 @@ INDEX_HTML = '''
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; }
@@ -113,119 +106,235 @@ INDEX_HTML = '''
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);
@@ -240,75 +349,103 @@ INDEX_HTML = '''
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;
@@ -317,28 +454,129 @@ INDEX_HTML = '''
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>
338
  </body>
339
  </html>
340
  '''
341
 
 
342
  class EnhancedComicGenerator:
343
  def __init__(self, sid):
344
  self.sid = sid
@@ -347,20 +585,17 @@ class EnhancedComicGenerator:
347
  self.frames_dir = os.path.join(self.user_dir, 'frames')
348
  self.output_dir = os.path.join(self.user_dir, 'output')
349
  self.status_file = os.path.join(self.output_dir, 'status.json')
350
-
351
  os.makedirs(self.frames_dir, exist_ok=True)
352
  os.makedirs(self.output_dir, exist_ok=True)
353
-
354
  self.video_fps = None
 
355
 
356
  def update_status(self, message, progress):
357
  try:
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))
@@ -373,32 +608,29 @@ class EnhancedComicGenerator:
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()
@@ -406,36 +638,73 @@ class EnhancedComicGenerator:
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 = []
420
  for i in range(0, len(frame_files), 4):
421
  batch_f = frame_files[i:i+4]
422
  batch_b = bubbles[i:i+4]
423
  panels = [{'image': f} for f in batch_f]
424
- b_data = [b.__dict__ if hasattr(b, '__dict__') else b for b in batch_b]
425
  pages_data.append({'panels': panels, 'bubbles': b_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('/')
441
  def index(): return INDEX_HTML
@@ -443,13 +712,11 @@ def index(): return INDEX_HTML
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})
@@ -463,31 +730,26 @@ def get_status():
463
 
464
  @app.route('/output/<path:filename>')
465
  def get_output(filename):
466
- sid = request.args.get('sid')
467
- return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
468
 
469
  @app.route('/frames/<path:filename>')
470
  def get_frame(filename):
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)
 
21
  import srt
22
  except ImportError as e:
23
  print(f"❌ CRITICAL ERROR: Missing python library. {e}")
24
+ cv2 = None; np = None; Image = None; srt = None
 
 
 
 
25
 
26
+ # --- 2. BACKEND IMPORTS WITH FALLBACKS ---
27
  def dummy_func(*args, **kwargs): return 0, 0, None, None
28
 
29
  try:
 
33
 
34
  try:
35
  from backend.simple_color_enhancer import SimpleColorEnhancer
36
+ from backend.quality_color_enhancer import QualityColorEnhancer
37
  except:
38
  class SimpleColorEnhancer:
39
  def enhance_single(self, *args): pass
 
 
 
 
40
  class QualityColorEnhancer:
41
  def enhance_single(self, *args): pass
42
 
 
62
  def place_bubble_ai(self, p, l): return 50, 20
63
  ai_bubble_placer = DummyPlacer()
64
 
 
65
  # --- FLASK APP SETUP ---
66
  app = Flask(__name__)
67
+ BASE_USER_DIR = "userdata"
68
 
69
  # --- HTML INTERFACE ---
70
  INDEX_HTML = '''
 
74
  <meta charset="UTF-8">
75
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
76
  <title>Movie to Comic Generator</title>
 
77
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
78
  <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Lato&display=swap" rel="stylesheet">
79
  <style>
80
  body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
81
 
82
+ /* LAYOUT */
83
+ #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; }
84
  .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; }
 
 
85
  #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; }
86
 
87
+ /* BUTTONS & INPUTS */
88
  h1 { color: #2c3e50; margin-bottom: 30px; font-weight: 600; }
89
  .file-input { display: none; }
90
+ .file-label { display: block; padding: 15px; background: #2c3e50; color: white; border-radius: 8px; cursor: pointer; font-weight: bold; margin-bottom: 10px; transition:0.2s; }
91
+ .file-label:hover { background: #34495e; }
92
+ .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.2s; }
93
+ .submit-btn:hover { background: #d35400; }
94
+
95
+ .restore-btn { margin-top: 15px; background: #27ae60; color: white; padding: 10px; width: 100%; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; display: none; }
96
+
97
  .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; }
98
  @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
99
+
100
+ /* COMIC STYLES */
 
 
101
  .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; }
102
  .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; }
103
  .panel { position: relative; overflow: hidden; border: 2px solid #000; background: #eee; cursor: pointer; }
 
106
  .panel img.pannable { cursor: grab; }
107
  .panel img.panning { cursor: grabbing; }
108
 
109
+ /* --- SPEECH BUBBLE (EXACT SHARK FIN CSS) --- */
110
  .speech-bubble {
111
  position: absolute; display: flex; justify-content: center; align-items: center;
112
  width: 150px; height: 80px; min-width: 60px; min-height: 40px; box-sizing: border-box;
113
  z-index: 10; cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold;
114
  font-size: 14px; text-align: center;
115
  }
116
+ .bubble-text { padding: 0.8em; word-wrap: break-word; position: relative; z-index: 5; }
117
  .speech-bubble.selected { outline: 2px dashed #4CAF50; }
118
  .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; }
119
 
 
120
  .speech-bubble.speech {
121
+ --b: 3em; --h: 1.8em; --t: 0.6; --p: var(--tail-pos, 50%); --r: 1.2em;
122
+ --c: var(--bubble-fill-color, #4ECDC4);
123
+ background: var(--c); color: var(--bubble-text-color, #fff); padding: 1em; position: absolute;
 
 
 
 
 
 
 
 
 
 
124
  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);
125
  }
 
 
126
  .speech-bubble.speech:before {
127
  content: ""; position: absolute; width: var(--b); height: var(--h);
 
128
  background: radial-gradient(100% 100% at 100% 0, transparent calc(var(--t) * 100% - 1px), var(--c) calc(var(--t) * 100%));
129
  border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
130
  }
 
 
131
  .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))); }
 
132
  .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); }
133
  .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); }
 
134
  .speech-bubble.speech.tail-left { border-radius: var(--r); }
135
  .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; }
 
136
  .speech-bubble.speech.tail-right { border-radius: var(--r); }
137
  .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; }
138
 
139
  .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
140
  .speech-bubble.thought::after { display:none; }
141
+ .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
142
+ .thought-dot-1 { width: 20px; height: 20px; bottom: -20px; left: 15px; }
143
+ .thought-dot-2 { width: 12px; height: 12px; bottom: -32px; left: 5px; }
144
 
145
  .resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; }
146
  .speech-bubble.selected .resize-handle { display: block; }
147
  .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
148
 
149
+ /* CONTROLS */
150
  .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; }
151
+ .edit-controls h4 { margin: 0 0 10px 0; color: #4ECDC4; text-align: center; }
152
+ .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
153
+ button, input, select { width: 100%; margin-top: 5px; padding: 5px; }
154
+ .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
155
  .slider-container { margin-top: 10px; }
156
  </style>
157
  </head>
158
  <body>
159
+ <!-- UPLOAD SCREEN -->
160
  <div id="upload-container">
161
  <div class="upload-box">
162
+ <h1>🎬 Comic Generator</h1>
163
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
164
  <label for="file-upload" class="file-label">Choose Video</label>
165
+ <span id="fn">No file selected</span>
166
+ <button class="submit-btn" onclick="upload()">Generate Comic</button>
167
+ <button id="restore-btn" class="restore-btn" onclick="restoreSession()">📂 Restore Unsaved Session</button>
168
+
169
+ <div class="loading-view" id="loading-view" style="display:none;">
170
  <div class="loader"></div>
171
  <p id="status-text">Starting...</p>
172
  </div>
173
  </div>
174
  </div>
175
 
176
+ <!-- EDITOR SCREEN -->
177
  <div id="editor-container">
178
  <div class="comic-container-wrapper">
179
+ <h1 style="text-align:center;">Comic Editor</h1>
180
  <div id="comic-pages"></div>
181
  </div>
182
+
183
+ <!-- Hidden inputs -->
184
+ <input type="file" id="image-uploader" style="display: none;" accept="image/*">
185
+
186
  <div class="edit-controls">
187
+ <h4>Tools</h4>
188
+ <div class="control-group">
189
+ <button onclick="addBubble()">+ Bubble</button>
190
+ <button onclick="deleteBubble()" style="background:#ffcccc">Delete</button>
191
+ </div>
192
+ <div class="control-group">
193
+ <label>Colors:</label>
194
+ <div style="display:grid; grid-template-columns: 1fr 1fr; gap:5px;">
195
+ <input type="color" id="bubble-text-color" disabled title="Text Color">
196
+ <input type="color" id="bubble-fill-color" disabled title="Fill Color">
197
+ </div>
198
+ <select id="bubble-type-select" onchange="changeBubbleType(this.value)" disabled>
199
+ <option value="speech">Speech</option>
200
+ <option value="thought">Thought</option>
201
+ </select>
202
+ </div>
203
+ <div class="control-group" id="tail-controls" style="display:none;">
204
+ <label>Tail:</label>
205
+ <button onclick="rotateTail()">Rotate</button>
206
+ <input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)">
207
+ </div>
208
+ <div class="control-group">
209
+ <label>Panel:</label>
210
+ <button onclick="replacePanelImage()">🖼️ Replace</button>
211
+ <div class="button-grid">
212
+ <button onclick="adjustFrame('backward')">⬅️</button>
213
+ <button onclick="adjustFrame('forward')">➡️</button>
214
+ </div>
215
+ <input type="text" id="timestamp-input" placeholder="mm:ss">
216
+ <button onclick="gotoTimestamp()">Go to Time</button>
217
+ <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled oninput="handleZoom(this)">
218
+ </div>
219
  <hr>
220
+ <button onclick="saveLocal()">💾 Force Save Draft</button>
221
+ <button onclick="exportComic()" style="background:#ccffcc; font-weight:bold;">📥 Download PNG</button>
222
+ <button onclick="location.reload()" style="color:red; margin-top:10px;">↺ Start Over</button>
223
  </div>
224
  </div>
225
 
226
  <script>
227
+ // --- SESSION ---
228
  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);}); }
229
  let sid = localStorage.getItem('comic_sid') || genUUID();
230
  localStorage.setItem('comic_sid', sid);
231
  console.log("SID:", sid);
232
 
233
+ // Check for existing save on load
234
+ if(localStorage.getItem('comic_autosave')) {
235
+ document.getElementById('restore-btn').style.display = 'block';
236
+ }
237
+
238
+ let interval, selectedBubble, selectedPanel;
239
+ let isResizing=false, resizeHandle, startX, startY, startW, startH, startL, startT;
240
+ let isPanning=false, panStartX, panStartY, panStartTx, panStartTy;
241
+
242
+ // --- RESTORE LOGIC ---
243
+ function restoreSession() {
244
+ const savedData = localStorage.getItem('comic_autosave');
245
+ if(!savedData) return alert("No saved session found.");
246
+ try {
247
+ const state = JSON.parse(savedData);
248
+ // Restore SID from state if needed, or keep current
249
+ renderFromState(state);
250
+ document.getElementById('upload-container').style.display = 'none';
251
+ document.getElementById('editor-container').style.display = 'block';
252
+ } catch(e) {
253
+ alert("Failed to restore: " + e);
254
+ }
255
+ }
256
+
257
+ function renderFromState(pagesData) {
258
+ const con = document.getElementById('comic-pages');
259
+ con.innerHTML = '';
260
+ pagesData.forEach((page, i) => {
261
+ const div = document.createElement('div');
262
+ div.className = 'comic-page';
263
+ const grid = document.createElement('div');
264
+ grid.className = 'comic-grid';
265
+
266
+ page.panels.forEach((pan) => {
267
+ const pDiv = document.createElement('div');
268
+ pDiv.className = 'panel';
269
+ pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
270
+
271
+ const img = document.createElement('img');
272
+ // Ensure image path includes SID if it's relative, or use full saved src
273
+ img.src = pan.src;
274
+ // Restore pan/zoom
275
+ img.dataset.zoom = pan.zoom || 100;
276
+ img.dataset.translateX = pan.tx || 0;
277
+ img.dataset.translateY = pan.ty || 0;
278
+ updateImageTransform(img);
279
+ img.onmousedown = startPan;
280
+ pDiv.appendChild(img);
281
+
282
+ // Restore bubbles
283
+ pan.bubbles.forEach(bData => {
284
+ const b = createBubble(bData.text, bData.left, bData.top, bData.type, bData.tailPos, bData.width, bData.height, bData.colors);
285
+ // Re-apply rotation classes
286
+ if(bData.classes) b.className = bData.classes;
287
+ pDiv.appendChild(b);
288
+ });
289
+ grid.appendChild(pDiv);
290
+ });
291
+ div.appendChild(grid);
292
+ con.appendChild(div);
293
+ });
294
+ }
295
+
296
+ // --- SAVE LOGIC (Auto-Save) ---
297
+ function saveLocal() {
298
+ const pages = [];
299
+ document.querySelectorAll('.comic-page').forEach(page => {
300
+ const panels = [];
301
+ page.querySelectorAll('.panel').forEach(pDiv => {
302
+ const img = pDiv.querySelector('img');
303
+ const bubbles = [];
304
+ pDiv.querySelectorAll('.speech-bubble').forEach(b => {
305
+ bubbles.push({
306
+ text: b.querySelector('.bubble-text').innerText,
307
+ left: b.style.left, top: b.style.top,
308
+ width: b.style.width, height: b.style.height,
309
+ type: b.dataset.type,
310
+ tailPos: b.style.getPropertyValue('--tail-pos'),
311
+ classes: b.className,
312
+ colors: {
313
+ text: b.style.getPropertyValue('--bubble-text-color'),
314
+ fill: b.style.getPropertyValue('--bubble-fill-color')
315
+ }
316
+ });
317
+ });
318
+ panels.push({
319
+ src: img.src,
320
+ zoom: img.dataset.zoom,
321
+ tx: img.dataset.translateX,
322
+ ty: img.dataset.translateY,
323
+ bubbles: bubbles
324
+ });
325
+ });
326
+ pages.push({ panels: panels });
327
+ });
328
+ localStorage.setItem('comic_autosave', JSON.stringify(pages));
329
+ console.log("Autosaved.");
330
+ }
331
 
332
+ // --- UPLOAD ---
333
  async function upload() {
334
  const f = document.getElementById('file-upload').files[0];
335
  if(!f) return alert("Select file");
 
336
  document.querySelector('.upload-box').style.display='none';
337
  document.getElementById('loading-view').style.display='flex';
 
338
  const fd = new FormData(); fd.append('file', f);
339
  const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
340
  if(r.ok) interval = setInterval(checkStatus, 2000);
 
349
  clearInterval(interval);
350
  document.getElementById('upload-container').style.display='none';
351
  document.getElementById('editor-container').style.display='block';
352
+ loadNewComic();
353
  }
354
  }
355
 
356
+ function loadNewComic() {
357
  fetch(`/output/pages.json?sid=${sid}`).then(r=>r.json()).then(data => {
358
+ const cleanData = data.map(p => ({
359
+ panels: p.panels.map((pan, j) => ({
360
+ src: `/frames/${pan.image}?sid=${sid}`,
361
+ bubbles: (p.bubbles && p.bubbles[j] && p.bubbles[j].dialog) ? [{
362
+ text: p.bubbles[j].dialog,
363
+ left: (p.bubbles[j].bubble_offset_x||50)+'px',
364
+ top: (p.bubbles[j].bubble_offset_y||20)+'px',
365
+ type: 'speech',
366
+ tailPos: '50%'
367
+ }] : []
368
+ }))
369
+ }));
370
+ renderFromState(cleanData);
371
+ saveLocal(); // Initial save
 
 
 
 
 
 
372
  });
373
  }
374
 
375
+ // --- INTERACTIVITY ---
376
+ function createBubble(text, x, y, type='speech', tailPos='50%', w, h, colors) {
377
  const b = document.createElement('div');
378
  b.className = 'speech-bubble speech tail-bottom';
379
+ b.style.left = x; b.style.top = y;
380
+ if(w) b.style.width = w; if(h) b.style.height = h;
381
+ b.dataset.type = type;
382
+ b.style.setProperty('--tail-pos', tailPos || '50%');
383
+
384
+ if(colors) {
385
+ if(colors.text) b.style.setProperty('--bubble-text-color', colors.text);
386
+ if(colors.fill) b.style.setProperty('--bubble-fill-color', colors.fill);
387
+ }
388
+
389
+ b.innerHTML = `<span class="bubble-text">${text}</span><div class="resize-handle se"></div>`;
390
 
391
+ // Handlers
392
+ b.onmousedown = (e) => {
393
  if(e.target.classList.contains('resize-handle')) return;
394
  e.stopPropagation(); selectedBubble = b;
395
  document.querySelectorAll('.speech-bubble').forEach(el=>el.classList.remove('selected'));
396
  b.classList.add('selected');
397
+ selectBubbleUI(b);
398
+
399
  const ox = e.clientX - b.offsetLeft, oy = e.clientY - b.offsetTop;
400
+ document.onmousemove = (ev) => {
401
  b.style.left = (ev.clientX-ox)+'px'; b.style.top = (ev.clientY-oy)+'px';
402
+ };
403
+ document.onmouseup = () => { document.onmousemove=null; saveLocal(); };
404
  };
405
+
406
+ b.querySelector('.resize-handle').onmousedown = (e) => {
407
  e.stopPropagation();
408
  const ox = e.clientX, oy = e.clientY, ow = b.offsetWidth, oh = b.offsetHeight;
409
+ document.onmousemove = (ev) => {
410
  b.style.width = (ow + ev.clientX - ox) + 'px';
411
  b.style.height = (oh + ev.clientY - oy) + 'px';
412
+ };
413
+ document.onmouseup = () => { document.onmousemove=null; saveLocal(); };
414
  };
415
+
416
+ b.ondblclick = (e) => {
417
  e.stopPropagation();
418
+ const span = b.querySelector('.bubble-text');
419
+ const txt = document.createElement('textarea');
420
+ txt.value = span.innerText;
421
+ b.appendChild(txt); span.style.display='none'; txt.focus();
422
+ txt.onblur = () => { span.innerText = txt.value; txt.remove(); span.style.display='block'; saveLocal(); };
423
  };
424
+
425
  return b;
426
  }
427
 
428
+ function selectPanel(el) {
429
+ document.querySelectorAll('.panel.selected').forEach(e=>e.classList.remove('selected'));
430
+ el.classList.add('selected');
431
+ selectedPanel = el;
432
+ selectBubbleUI(null);
433
+ }
434
+
435
+ function selectBubbleUI(el) {
436
+ selectedBubble = el;
437
+ const inputs = ['bubble-text-color', 'bubble-fill-color', 'bubble-type-select'];
438
+ const tail = document.getElementById('tail-controls');
439
+ if(el) {
440
+ inputs.forEach(i => document.getElementById(i).disabled = false);
441
+ tail.style.display = (el.dataset.type === 'speech') ? 'block' : 'none';
442
+ } else {
443
+ inputs.forEach(i => document.getElementById(i).disabled = true);
444
+ tail.style.display = 'none';
445
+ }
446
+ }
447
+
448
+ function slideTail(v) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', v+'%'); saveLocal(); } }
449
 
450
  function rotateTail() {
451
  if(!selectedBubble) return;
 
454
  else if(b.classList.contains('tail-left')) b.classList.replace('tail-left','tail-top');
455
  else if(b.classList.contains('tail-top')) b.classList.replace('tail-top','tail-right');
456
  else b.className = b.className.replace(/tail-\w+/,'tail-bottom');
457
+ saveLocal();
458
+ }
459
+
460
+ function changeBubbleType(v) {
461
+ if(!selectedBubble) return;
462
+ selectedBubble.className = `speech-bubble ${v} tail-bottom selected`;
463
+ selectedBubble.dataset.type = v;
464
+ selectBubbleUI(selectedBubble);
465
+ saveLocal();
466
  }
467
 
468
  function addBubble() {
469
+ if(!selectedPanel) return alert("Select a panel");
470
+ const b = createBubble("New", "20px", "20px");
471
+ selectedPanel.appendChild(b);
472
+ saveLocal();
473
+ }
474
+ function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); saveLocal(); } }
475
+
476
+ // Panel Manipulations
477
+ function startPan(e) {
478
+ if(e.button!==0) return;
479
+ const img = e.target;
480
+ if((parseFloat(img.dataset.zoom)||100) <= 100) return;
481
+ e.preventDefault(); isPanning = true;
482
+ img.classList.add('panning');
483
+ panStartX = e.clientX; panStartY = e.clientY;
484
+ panStartTx = parseFloat(img.dataset.translateX||0);
485
+ panStartTy = parseFloat(img.dataset.translateY||0);
486
+ }
487
+ document.addEventListener('mousemove', (e) => {
488
+ if(!isPanning || !selectedPanel) return;
489
+ const img = selectedPanel.querySelector('img');
490
+ img.dataset.translateX = panStartTx + (e.clientX - panStartX);
491
+ img.dataset.translateY = panStartTy + (e.clientY - panStartY);
492
+ updateImageTransform(img);
493
+ });
494
+ document.addEventListener('mouseup', () => {
495
+ if(isPanning) { isPanning=false; selectedPanel?.querySelector('img')?.classList.remove('panning'); saveLocal(); }
496
+ });
497
+ function handleZoom(el) {
498
+ if(!selectedPanel) return;
499
+ const img = selectedPanel.querySelector('img');
500
+ img.dataset.zoom = el.value;
501
+ updateImageTransform(img);
502
+ saveLocal();
503
+ }
504
+ function updateImageTransform(img) {
505
+ const z = (img.dataset.zoom||100)/100;
506
+ const x = img.dataset.translateX||0; const y = img.dataset.translateY||0;
507
+ img.style.transform = `translateX(${x}px) translateY(${y}px) scale(${z})`;
508
+ img.classList.toggle('pannable', z>1);
509
+ }
510
+ function resetPanelTransform() {
511
+ if(!selectedPanel) return;
512
+ const img = selectedPanel.querySelector('img');
513
+ img.dataset.zoom=100; img.dataset.translateX=0; img.dataset.translateY=0;
514
+ document.getElementById('zoom-slider').value = 100;
515
+ updateImageTransform(img);
516
+ saveLocal();
517
  }
 
518
 
519
+ // API
520
+ function replacePanelImage() {
521
+ if(!selectedPanel) return alert("Select panel");
522
+ const img = selectedPanel.querySelector('img');
523
+ const inp = document.getElementById('image-uploader');
524
+ inp.onchange = async(e) => {
525
+ const fd = new FormData(); fd.append('image', e.target.files[0]);
526
+ const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd});
527
+ const d = await r.json();
528
+ if(d.success) { img.src = `/frames/${d.new_filename}?sid=${sid}&t=${Date.now()}`; saveLocal(); }
529
+ inp.value='';
530
+ };
531
+ inp.click();
532
+ }
533
+ async function adjustFrame(dir) {
534
+ if(!selectedPanel) return alert("Select panel");
535
+ const img = selectedPanel.querySelector('img');
536
+ let fname = img.src.split('/').pop().split('?')[0];
537
+ await fetch(`/regenerate_frame?sid=${sid}`, {
538
+ method:'POST', headers:{'Content-Type':'application/json'},
539
+ body:JSON.stringify({filename:fname, direction:dir})
540
+ });
541
+ img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
542
+ saveLocal();
543
+ }
544
+ async function gotoTimestamp() {
545
+ if(!selectedPanel) return alert("Select panel");
546
+ let v = document.getElementById('timestamp-input').value;
547
+ if(v.includes(':')) { let p=v.split(':'); v = parseInt(p[0])*60 + parseFloat(p[1]); }
548
+ const img = selectedPanel.querySelector('img');
549
+ let fname = img.src.split('/').pop().split('?')[0];
550
+ await fetch(`/goto_timestamp?sid=${sid}`, {
551
+ method:'POST', headers:{'Content-Type':'application/json'},
552
+ body:JSON.stringify({filename:fname, timestamp:v})
553
+ });
554
+ img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
555
+ saveLocal();
556
+ }
557
+
558
  async function exportComic() {
559
  const pgs = document.querySelectorAll('.comic-page');
560
  for(let i=0; i<pgs.length; i++) {
561
+ const u = await htmlToImage.toPng(pgs[i], {pixelRatio:3});
562
+ const a = document.createElement('a'); a.href=u; a.download=`comic-${i+1}.png`; a.click();
 
563
  }
564
  }
565
+
566
+ // Color Listeners Logic
567
+ document.getElementById('bubble-text-color').addEventListener('input', (e) => {
568
+ if(selectedBubble) { selectedBubble.style.setProperty('--bubble-text-color', e.target.value); saveLocal(); }
569
+ });
570
+ document.getElementById('bubble-fill-color').addEventListener('input', (e) => {
571
+ if(selectedBubble) { selectedBubble.style.setProperty('--bubble-fill-color', e.target.value); saveLocal(); }
572
+ });
573
+
574
  </script>
575
  </body>
576
  </html>
577
  '''
578
 
579
+ # --- BACKEND LOGIC ---
580
  class EnhancedComicGenerator:
581
  def __init__(self, sid):
582
  self.sid = sid
 
585
  self.frames_dir = os.path.join(self.user_dir, 'frames')
586
  self.output_dir = os.path.join(self.user_dir, 'output')
587
  self.status_file = os.path.join(self.output_dir, 'status.json')
 
588
  os.makedirs(self.frames_dir, exist_ok=True)
589
  os.makedirs(self.output_dir, exist_ok=True)
 
590
  self.video_fps = None
591
+ self.frame_metadata = {}
592
 
593
  def update_status(self, message, progress):
594
  try:
595
+ with open(self.status_file, 'w') as f: json.dump({'message': message, 'progress': progress}, f)
 
596
  except: pass
597
 
598
  def cleanup_previous_run(self):
 
599
  if os.path.exists(self.frames_dir):
600
  for f in os.listdir(self.frames_dir):
601
  try: os.remove(os.path.join(self.frames_dir, f))
 
608
 
609
  def generate_comic(self):
610
  try:
611
+ if cv2 is None: raise Exception("OpenCV not installed")
 
612
  self.update_status("Processing Video...", 5)
613
  cap = cv2.VideoCapture(self.video_path)
614
  if not cap.isOpened(): raise Exception("Invalid Video")
615
  self.video_fps = cap.get(cv2.CAP_PROP_FPS) or 25
616
  cap.release()
617
 
 
618
  self.update_status("Extracting Dialogue...", 20)
619
  user_srt = os.path.join(self.user_dir, 'subs.srt')
620
  try:
621
  get_real_subtitles(self.video_path)
622
  if os.path.exists('test1.srt'): shutil.move('test1.srt', user_srt)
623
  except:
624
+ with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\nHello\n")
625
 
 
626
  self.update_status("Generating Panels...", 40)
627
  with open(user_srt, 'r', encoding='utf-8') as f: subs = list(srt.parse(f.read()))
628
+
629
  cap = cv2.VideoCapture(self.video_path)
630
  frame_files = []
631
  bubbles = []
632
 
633
+ for i, sub in enumerate(subs[:12]):
 
634
  mid = (sub.start.total_seconds() + sub.end.total_seconds())/2
635
  cap.set(cv2.CAP_PROP_POS_MSEC, mid*1000)
636
  ret, frame = cap.read()
 
638
  fname = f"frame_{i}.png"
639
  cv2.imwrite(os.path.join(self.frames_dir, fname), frame)
640
  frame_files.append(fname)
641
+ self.frame_metadata[fname] = mid
642
  bubbles.append(bubble(
643
+ dialog=sub.content, bubble_offset_x=50, bubble_offset_y=20,
 
644
  lip_x=-1, lip_y=-1, emotion='normal'
645
  ))
646
  cap.release()
647
+
648
+ with open(os.path.join(self.frames_dir, 'frame_metadata.json'), 'w') as f: json.dump(self.frame_metadata, f)
649
 
 
650
  self.update_status("Finalizing...", 90)
651
  pages_data = []
652
  for i in range(0, len(frame_files), 4):
653
  batch_f = frame_files[i:i+4]
654
  batch_b = bubbles[i:i+4]
655
  panels = [{'image': f} for f in batch_f]
656
+ b_data = [b if isinstance(b, dict) else b.__dict__ for b in batch_b]
657
  pages_data.append({'panels': panels, 'bubbles': b_data})
658
 
659
+ with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f: json.dump(pages_data, f)
 
660
 
 
 
 
 
661
  self.update_status("Done!", 100)
662
  except Exception as e:
663
  traceback.print_exc()
664
  self.update_status(f"Error: {str(e)}", -1)
665
 
666
+ def regenerate_frame(self, fname, direction):
667
+ try:
668
+ meta_path = os.path.join(self.frames_dir, 'frame_metadata.json')
669
+ with open(meta_path,'r') as f: meta = json.load(f)
670
+ curr_time = meta[fname]
671
+
672
+ if not self.video_fps:
673
+ cap = cv2.VideoCapture(self.video_path); self.video_fps = cap.get(cv2.CAP_PROP_FPS); cap.release()
674
+
675
+ offset = (1.0/self.video_fps) * (1 if direction=='forward' else -1)
676
+ new_time = max(0, curr_time + offset)
677
+
678
+ cap = cv2.VideoCapture(self.video_path)
679
+ cap.set(cv2.CAP_PROP_POS_MSEC, new_time*1000)
680
+ ret, frame = cap.read()
681
+ cap.release()
682
+
683
+ if ret:
684
+ cv2.imwrite(os.path.join(self.frames_dir, fname), frame)
685
+ meta[fname] = new_time
686
+ with open(meta_path,'w') as f: json.dump(meta, f)
687
+ return {"success":True}
688
+ return {"success":False, "message":"End of video"}
689
+ except Exception as e: return {"success":False, "message":str(e)}
690
+
691
+ def get_frame_at_timestamp(self, fname, ts):
692
+ try:
693
+ cap = cv2.VideoCapture(self.video_path)
694
+ cap.set(cv2.CAP_PROP_POS_MSEC, float(ts)*1000)
695
+ ret, frame = cap.read()
696
+ cap.release()
697
+ if ret:
698
+ cv2.imwrite(os.path.join(self.frames_dir, fname), frame)
699
+ meta_path = os.path.join(self.frames_dir, 'frame_metadata.json')
700
+ if os.path.exists(meta_path):
701
+ with open(meta_path,'r') as f: meta = json.load(f)
702
+ meta[fname] = float(ts)
703
+ with open(meta_path,'w') as f: json.dump(meta, f)
704
+ return {"success":True}
705
+ return {"success":False, "message":"Invalid time"}
706
+ except Exception as e: return {"success":False, "message":str(e)}
707
+
708
  # --- ROUTES ---
709
  @app.route('/')
710
  def index(): return INDEX_HTML
 
712
  @app.route('/uploader', methods=['POST'])
713
  def upload():
714
  sid = request.args.get('sid')
715
+ if not sid: return "Missing SID", 400
716
  f = request.files['file']
 
717
  gen = EnhancedComicGenerator(sid)
718
+ gen.cleanup_previous_run()
719
  f.save(gen.video_path)
 
720
  gen.update_status("Starting...", 5)
721
  threading.Thread(target=gen.generate_comic).start()
722
  return jsonify({'success': True})
 
730
 
731
  @app.route('/output/<path:filename>')
732
  def get_output(filename):
733
+ return send_from_directory(os.path.join(BASE_USER_DIR, request.args.get('sid'), 'output'), filename)
 
734
 
735
  @app.route('/frames/<path:filename>')
736
  def get_frame(filename):
737
+ return send_from_directory(os.path.join(BASE_USER_DIR, request.args.get('sid'), 'frames'), filename)
 
738
 
 
739
  @app.route('/regenerate_frame', methods=['POST'])
740
+ def regen_frame():
741
  sid = request.args.get('sid')
742
+ d = request.get_json()
743
+ return jsonify(EnhancedComicGenerator(sid).regenerate_frame(d['filename'], d['direction']))
 
744
 
745
  @app.route('/goto_timestamp', methods=['POST'])
746
+ def go_time():
747
  sid = request.args.get('sid')
748
+ d = request.get_json()
749
+ return jsonify(EnhancedComicGenerator(sid).get_frame_at_timestamp(d['filename'], d['timestamp']))
 
750
 
751
  @app.route('/replace_panel', methods=['POST'])
752
+ def rep_panel():
753
  sid = request.args.get('sid')
754
  f = request.files['image']
755
  gen = EnhancedComicGenerator(sid)