tester343 commited on
Commit
e258333
·
verified ·
1 Parent(s): 91d71b0

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +911 -291
app_enhanced.py CHANGED
@@ -21,39 +21,45 @@ try:
21
  import srt
22
  except ImportError as e:
23
  print(f"❌ CRITICAL ERROR: Missing python library. {e}")
24
- # We define placeholders to prevent startup crash, but functionality will fail gracefully
25
  cv2 = None
26
  np = None
27
  Image = None
28
  srt = None
29
 
30
- # --- 2. BACKEND IMPORTS WITH PROPER INDENTATION & FALLBACKS ---
31
  def dummy_func(*args, **kwargs):
32
  return 0, 0, None, None
33
 
34
  try:
35
  from backend.keyframes.keyframes import black_bar_crop
36
- except Exception:
 
 
37
  black_bar_crop = dummy_func
38
 
39
  try:
40
  from backend.simple_color_enhancer import SimpleColorEnhancer
41
- except Exception:
 
 
42
  class SimpleColorEnhancer:
43
- def enhance_single(self, *args, **kwargs):
44
- pass
45
 
46
  try:
47
  from backend.quality_color_enhancer import QualityColorEnhancer
48
- except Exception:
 
 
49
  class QualityColorEnhancer:
50
- def enhance_single(self, *args, **kwargs):
51
- pass
52
 
53
  try:
54
  from backend.class_def import bubble, panel, Page
55
- except Exception:
56
- # Fallback class definitions
 
57
  def bubble(dialog="", bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, emotion='normal'):
58
  return {
59
  'dialog': dialog,
@@ -63,18 +69,23 @@ except Exception:
63
  'lip_y': lip_y,
64
  'emotion': emotion
65
  }
66
- def panel(image=""): return {'image': image}
 
67
  class Page:
68
  def __init__(self, panels, bubbles):
69
  self.panels = panels
70
  self.bubbles = bubbles
71
 
72
  try:
73
- from backend.ai_enhanced_core import image_processor, face_detector
74
  from backend.ai_bubble_placement import ai_bubble_placer
75
  from backend.subtitles.subs_real import get_real_subtitles
76
- except Exception:
 
 
 
77
  def get_real_subtitles(v): pass
 
78
  class DummyDetector:
79
  def detect_faces(self, p): return []
80
  def get_lip_position(self, p, f): return -1, -1
@@ -87,7 +98,7 @@ except Exception:
87
  app = Flask(__name__)
88
  BASE_USER_DIR = "userdata"
89
 
90
- # --- HTML INTERFACE (FULL CSS/JS) ---
91
  INDEX_HTML = '''
92
  <!DOCTYPE html>
93
  <html lang="en">
@@ -96,9 +107,10 @@ INDEX_HTML = '''
96
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
97
  <title>Movie to Comic Generator</title>
98
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
99
- <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Lato&display=swap" rel="stylesheet">
100
  <style>
101
  /* --- GLOBAL STYLES --- */
 
102
  body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
103
 
104
  /* --- UPLOAD VIEW --- */
@@ -123,33 +135,35 @@ INDEX_HTML = '''
123
 
124
  /* --- COMIC PAGE LAYOUT --- */
125
  .comic-wrapper { max-width: 1000px; margin: 0 auto; }
126
- .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; }
127
- .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; }
 
 
128
 
129
  .panel { position: relative; overflow: hidden; border: 2px solid #000; background: #eee; cursor: pointer; }
130
  .panel.selected { outline: 3px solid #2196F3; outline-offset: -3px; }
131
  .panel img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.1s ease-out; transform-origin: center center; }
132
  .panel img.pannable { cursor: grab; }
133
  .panel img.panning { cursor: grabbing; }
134
-
135
  /* --- SPEECH BUBBLE GENERAL --- */
136
  .speech-bubble {
137
  position: absolute; display: flex; justify-content: center; align-items: center;
138
- width: 150px; height: 80px; min-width: 60px; min-height: 40px; box-sizing: border-box;
139
  z-index: 10; cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold;
140
- font-size: 14px; text-align: center;
141
  }
142
  .bubble-text { padding: 0.8em; word-wrap: break-word; position: relative; z-index: 5; }
143
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
144
- .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:102; resize:none; }
145
-
146
- /* --- EXACT "SHARK FIN" CSS (EXPORT SAFE) --- */
147
  .speech-bubble.speech {
148
- --b: 3em; /* tail base */
149
- --h: 1.8em; /* tail height */
150
- --t: 0.6; /* thickness */
151
- --p: var(--tail-pos, 50%); /* slider pos */
152
- --r: 1.2em; /* radius */
153
  --c: var(--bubble-fill-color, #4ECDC4);
154
 
155
  background: var(--c);
@@ -157,73 +171,104 @@ INDEX_HTML = '''
157
  padding: 1em;
158
  position: absolute;
159
 
160
- /* Exact border radius formula provided */
161
  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);
162
  }
163
-
164
- /* TAIL: Using 'radial-gradient' background instead of mask for Export Compatibility */
165
  .speech-bubble.speech:before {
166
  content: ""; position: absolute; width: var(--b); height: var(--h);
167
- /* Gradient mimics the mask curve exactly */
168
- background: radial-gradient(100% 100% at 100% 0, transparent calc(var(--t) * 100% - 1px), var(--c) calc(var(--t) * 100%));
169
  border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
 
 
170
  }
171
-
172
- /* ROTATION LOGIC */
173
- /* Bottom (Default) */
174
- .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))); }
175
 
176
- /* Top */
 
 
 
177
  .speech-bubble.speech.tail-top {
178
  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);
179
  }
180
  .speech-bubble.speech.tail-top:before {
181
- bottom: 99%; left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
182
  transform: scaleY(-1);
183
  }
184
 
185
- /* Left */
186
  .speech-bubble.speech.tail-left { border-radius: var(--r); }
187
  .speech-bubble.speech.tail-left:before {
188
- right: 99%; top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
189
  transform: rotate(90deg); transform-origin: top right;
190
  }
191
 
192
- /* Right */
193
  .speech-bubble.speech.tail-right { border-radius: var(--r); }
194
  .speech-bubble.speech.tail-right:before {
195
- left: 99%; top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
196
  transform: rotate(-90deg); transform-origin: top left;
197
  }
198
-
199
  /* THOUGHT BUBBLE */
200
  .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
201
  .speech-bubble.thought::after { display:none; }
202
  .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
203
  .thought-dot-1 { width: 20px; height: 20px; bottom: -20px; left: 15px; }
204
  .thought-dot-2 { width: 12px; height: 12px; bottom: -32px; left: 5px; }
205
-
206
- /* HANDLES */
207
- .resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  .speech-bubble.selected .resize-handle { display: block; }
209
  .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
 
 
 
210
 
211
  /* FLOATING TOOLBAR */
212
  .edit-controls {
213
- position: fixed; bottom: 20px; right: 20px; width: 240px;
214
- background: white; padding: 15px; border-radius: 8px;
215
- box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 900;
216
  }
217
  .edit-controls h4 { margin: 0 0 10px 0; color: #4ECDC4; text-align: center; }
218
- .control-group { margin-top: 10px; border-top: 1px solid #eee; padding-top: 10px; }
 
219
  button, input, select { width: 100%; margin-top: 5px; padding: 6px; border-radius: 4px; border: 1px solid #ddd; cursor: pointer; font-weight: bold; font-size: 12px; }
220
  button:hover { background: #f5f5f5; }
221
  .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
222
- .color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
 
 
 
 
 
 
 
 
 
 
223
  </style>
224
  </head>
225
  <body>
226
-
227
  <!-- 1. UPLOAD SCREEN -->
228
  <div id="upload-container">
229
  <div class="upload-box">
@@ -240,83 +285,109 @@ INDEX_HTML = '''
240
  </div>
241
  </div>
242
  </div>
243
-
244
  <!-- 2. EDITOR SCREEN -->
245
  <div id="editor-container">
246
  <div class="comic-wrapper" id="comic-container"></div>
247
 
248
- <!-- Hidden inputs -->
249
  <input type="file" id="image-uploader" style="display: none;" accept="image/*">
250
-
251
  <div class="edit-controls">
252
- <h4>Editor Tools</h4>
253
- <div class="control-group">
254
- <button onclick="addBubble()">+ New Bubble</button>
255
- <button onclick="deleteBubble()" style="background:#ffcccc">Delete Selected</button>
256
- </div>
257
 
 
258
  <div class="control-group">
259
- <label style="font-size:12px; font-weight:bold;">Colors:</label>
260
- <div class="color-grid">
261
- <input type="color" id="bubble-text-color" disabled title="Text Color">
262
- <input type="color" id="bubble-fill-color" disabled title="Fill Color">
263
- </div>
264
  <select id="bubble-type-select" onchange="changeBubbleType(this.value)" disabled>
265
  <option value="speech">Speech</option>
266
  <option value="thought">Thought</option>
 
 
 
267
  </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  </div>
269
-
 
270
  <div class="control-group" id="tail-controls" style="display:none;">
271
- <label style="font-size:12px; font-weight:bold;">Tail:</label>
272
- <div style="display:flex; gap:5px;">
273
- <button onclick="rotateTail()">Rotate</button>
 
 
274
  </div>
275
- <input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)">
276
  </div>
277
-
 
278
  <div class="control-group">
279
- <label style="font-size:12px; font-weight:bold;">Panel:</label>
280
- <button onclick="replacePanelImage()">🖼️ Swap Image</button>
281
  <div class="button-grid">
282
- <button onclick="adjustFrame('backward')">⬅️ Frame</button>
283
- <button onclick="adjustFrame('forward')">Frame ➡️</button>
 
 
 
 
284
  </div>
 
 
 
 
 
285
  <div class="button-grid">
286
- <input type="text" id="timestamp-input" placeholder="mm:ss" style="width:90%;">
287
- <button onclick="gotoTimestamp()">Go</button>
288
  </div>
289
- <label style="font-size:10px;">Zoom:</label>
290
- <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled oninput="handleZoom(this)">
291
  </div>
292
-
293
- <hr>
294
- <button onclick="saveLocal()" style="background:#f1c40f; margin-bottom:5px;">⚠️ Force Save Draft</button>
295
- <button onclick="exportComic()" style="background:#27ae60; color:white; font-weight:bold;">📥 Download PNG</button>
296
- <button onclick="location.reload()" style="color:red; background:transparent; border:none; margin-top:10px;">❌ Close & Reset</button>
 
 
297
  </div>
298
  </div>
299
-
300
  <script>
301
  // --- SESSION LOGIC ---
302
  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);}); }
303
  let sid = localStorage.getItem('comic_sid') || genUUID();
304
  localStorage.setItem('comic_sid', sid);
305
  console.log("Session ID:", sid);
306
-
307
- // Check autosave on load
308
- if(localStorage.getItem('comic_autosave')) {
309
  document.getElementById('restore-btn').style.display = 'block';
310
  }
311
-
312
- let interval, selectedBubble, selectedPanel;
313
- let isDragging=false, isResizing=false, isPanning=false;
314
  let startX, startY, initX, initY, initW, initH;
315
  let panStartX, panStartY, panStartTx, panStartTy;
316
-
 
 
317
  // --- RESTORE FUNCTION ---
318
  function restoreSession() {
319
- const savedData = localStorage.getItem('comic_autosave');
320
  if(!savedData) return alert("No saved session found.");
321
  try {
322
  const state = JSON.parse(savedData);
@@ -325,11 +396,18 @@ INDEX_HTML = '''
325
  document.getElementById('editor-container').style.display = 'block';
326
  } catch(e) { console.error(e); alert("Failed to restore session."); }
327
  }
328
-
329
  function renderFromState(pagesData) {
330
  const con = document.getElementById('comic-container');
331
  con.innerHTML = '';
332
- pagesData.forEach((page, i) => {
 
 
 
 
 
 
 
333
  const div = document.createElement('div');
334
  div.className = 'comic-page';
335
  const grid = document.createElement('div');
@@ -341,26 +419,27 @@ INDEX_HTML = '''
341
  pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
342
 
343
  const img = document.createElement('img');
344
- img.src = pan.src; // Use exact saved src
345
  img.dataset.zoom = pan.zoom || 100;
346
- img.dataset.tx = pan.tx || 0;
347
- img.dataset.ty = pan.ty || 0;
348
  updateImageTransform(img);
349
  img.onmousedown = (e) => startPan(e, img);
350
  pDiv.appendChild(img);
351
-
352
  // Restore bubbles
353
- pan.bubbles.forEach(bData => {
354
  const b = createBubbleHTML(bData);
355
  pDiv.appendChild(b);
356
  });
357
  grid.appendChild(pDiv);
358
  });
359
  div.appendChild(grid);
360
- con.appendChild(div);
 
361
  });
362
  }
363
-
364
  // --- UPLOAD LOGIC ---
365
  async function upload() {
366
  const f = document.getElementById('file-upload').files[0];
@@ -372,7 +451,7 @@ INDEX_HTML = '''
372
  if(r.ok) interval = setInterval(checkStatus, 2000);
373
  else { alert("Upload failed"); location.reload(); }
374
  }
375
-
376
  async function checkStatus() {
377
  try {
378
  const r = await fetch(`/status?sid=${sid}`);
@@ -383,19 +462,23 @@ INDEX_HTML = '''
383
  document.getElementById('upload-container').style.display='none';
384
  document.getElementById('editor-container').style.display='block';
385
  loadNewComic();
 
 
 
 
386
  }
387
  } catch(e) {}
388
  }
389
-
390
  function loadNewComic() {
391
  fetch(`/output/pages.json?sid=${sid}`).then(r=>r.json()).then(data => {
392
- const cleanData = data.map(p => ({
393
  panels: p.panels.map((pan, j) => ({
394
  src: `/frames/${pan.image}?sid=${sid}`,
395
  bubbles: (p.bubbles && p.bubbles[j] && p.bubbles[j].dialog) ? [{
396
  text: p.bubbles[j].dialog,
397
- left: (p.bubbles[j].bubble_offset_x||50)+'px',
398
- top: (p.bubbles[j].bubble_offset_y||20)+'px',
399
  type: 'speech',
400
  tailPos: '50%',
401
  colors: { text: '#ffffff', fill: '#4ECDC4' }
@@ -406,54 +489,104 @@ INDEX_HTML = '''
406
  saveLocal();
407
  });
408
  }
409
-
410
  // --- BUBBLE CREATION ---
411
  function createBubbleHTML(data) {
412
  const b = document.createElement('div');
413
- b.className = data.classes || 'speech-bubble speech tail-bottom';
 
 
 
 
414
  b.style.left = data.left;
415
  b.style.top = data.top;
416
  if(data.width) b.style.width = data.width;
417
  if(data.height) b.style.height = data.height;
418
-
419
- b.dataset.type = data.type || 'speech';
420
 
421
  if(data.colors) {
422
- b.style.setProperty('--bubble-fill-color', data.colors.fill);
423
- b.style.setProperty('--bubble-text-color', data.colors.text);
424
  }
425
  if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
426
-
427
- b.innerHTML = `<div class="bubble-text">${data.text}</div><div class="resize-handle se"></div>`;
428
-
 
 
 
 
 
 
 
 
 
 
 
429
  // Drag
430
  b.onmousedown = (e) => {
431
  if(e.target.classList.contains('resize-handle')) return;
432
  e.stopPropagation(); selectBubble(b);
433
- isDragging=true; startX=e.clientX; startY=e.clientY;
434
- initX=b.offsetLeft; initY=b.offsetTop;
435
  };
436
 
437
- // Resize
438
- b.querySelector('.resize-handle').onmousedown = (e) => {
439
- e.stopPropagation(); isResizing=true;
440
- startX=e.clientX; startY=e.clientY;
441
- initW=b.offsetWidth; initH=b.offsetHeight;
442
- };
443
-
444
  // Edit Text
445
  b.ondblclick = (e) => {
446
  e.stopPropagation();
447
- const t = b.querySelector('.bubble-text');
448
- const i = document.createElement('textarea');
449
- i.value = t.innerText;
450
- b.appendChild(i); t.style.opacity=0; i.focus();
451
- i.onblur = () => { t.innerText=i.value; i.remove(); t.style.opacity=1; saveLocal(); };
452
  };
453
-
 
 
454
  return b;
455
  }
456
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  // --- GLOBAL EVENTS ---
458
  document.addEventListener('mousemove', (e) => {
459
  if(isDragging && selectedBubble) {
@@ -461,171 +594,328 @@ INDEX_HTML = '''
461
  selectedBubble.style.top = (initY + e.clientY - startY) + 'px';
462
  }
463
  if(isResizing && selectedBubble) {
464
- selectedBubble.style.width = (initW + e.clientX - startX) + 'px';
465
- selectedBubble.style.height = (initH + e.clientY - startY) + 'px';
466
  }
467
  if(isPanning && selectedPanel) {
468
- const img = selectedPanel.querySelector('img');
469
- img.dataset.tx = panStartTx + (e.clientX - panStartX);
470
- img.dataset.ty = panStartTy + (e.clientY - panStartY);
471
- applyTransform(img);
472
  }
473
  });
474
-
475
  document.addEventListener('mouseup', () => {
476
  if(isDragging || isResizing || isPanning) saveLocal();
477
  isDragging = false; isResizing = false; isPanning = false;
478
  });
479
-
480
- // --- INTERACTION LOGIC ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
  function selectBubble(el) {
482
  if(selectedBubble) selectedBubble.classList.remove('selected');
 
483
  selectedBubble = el;
484
  el.classList.add('selected');
485
- if(selectedPanel) selectedPanel.classList.remove('selected'); selectedPanel=null;
486
 
487
  // Sync Controls
488
- document.getElementById('tail-controls').style.display = (el.dataset.type === 'speech') ? 'block' : 'none';
489
- ['bubble-text-color','bubble-fill-color','bubble-type-select'].forEach(i=>document.getElementById(i).disabled=false);
 
 
490
  document.getElementById('zoom-slider').disabled = true;
 
 
 
 
 
 
 
 
 
 
 
 
491
  }
492
-
493
  function selectPanel(el) {
494
  if(selectedPanel) selectedPanel.classList.remove('selected');
495
- if(selectedBubble) selectedBubble.classList.remove('selected'); selectedBubble=null;
496
  selectedPanel = el;
497
  el.classList.add('selected');
498
 
499
- // Sync Zoom
500
  const img = el.querySelector('img');
501
  document.getElementById('zoom-slider').disabled = false;
502
  document.getElementById('zoom-slider').value = img.dataset.zoom || 100;
503
 
504
- ['bubble-text-color','bubble-fill-color','bubble-type-select'].forEach(i=>document.getElementById(i).disabled=true);
505
  document.getElementById('tail-controls').style.display = 'none';
506
  }
507
-
 
508
  function addBubble() {
509
  if(!selectedPanel) return alert("Select a panel first");
510
  const b = createBubbleHTML({
511
- text: "New Text", left:"50px", top:"30px",
512
- colors: {text:'#ffffff', fill:'#4ECDC4'}
 
513
  });
514
  selectedPanel.appendChild(b);
515
  selectBubble(b);
516
  saveLocal();
517
  }
518
-
519
- function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); saveLocal(); } }
520
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
  function rotateTail() {
522
  if(!selectedBubble) return;
523
- const c = selectedBubble.classList;
524
- if(c.contains('tail-bottom')) c.replace('tail-bottom','tail-left');
525
- else if(c.contains('tail-left')) c.replace('tail-left','tail-top');
526
- else if(c.contains('tail-top')) c.replace('tail-top','tail-right');
527
- else c.replace('tail-right','tail-bottom') || c.add('tail-bottom');
 
 
 
 
 
 
 
 
 
 
 
528
  saveLocal();
529
  }
530
-
531
- function slideTail(v) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', v+'%'); saveLocal(); } }
532
-
533
- // Colors
 
 
 
 
 
534
  document.getElementById('bubble-text-color').addEventListener('input', (e) => {
535
- if(selectedBubble) { selectedBubble.style.setProperty('--bubble-text-color', e.target.value); saveLocal(); }
 
 
 
536
  });
537
  document.getElementById('bubble-fill-color').addEventListener('input', (e) => {
538
- if(selectedBubble) { selectedBubble.style.setProperty('--bubble-fill-color', e.target.value); saveLocal(); }
539
- });
540
- document.getElementById('bubble-type-select').addEventListener('change', (e) => {
541
  if(selectedBubble) {
542
- selectedBubble.dataset.type = e.target.value;
543
- selectedBubble.className = `speech-bubble ${e.target.value} tail-bottom selected`;
544
- selectBubble(selectedBubble); // Refresh UI
545
  saveLocal();
546
  }
547
  });
548
-
549
  // --- PAN & ZOOM ---
550
  function handleZoom(el) {
551
  if(!selectedPanel) return;
552
  const img = selectedPanel.querySelector('img');
553
  img.dataset.zoom = el.value;
554
- applyTransform(img);
 
555
  }
 
556
  function startPan(e, img) {
557
- if(parseFloat(img.dataset.zoom) <= 100) return;
558
- e.preventDefault(); isPanning = true;
559
- panStartX = e.clientX; panStartY = e.clientY;
560
- panStartTx = parseFloat(img.dataset.tx || 0);
561
- panStartTy = parseFloat(img.dataset.ty || 0);
 
 
 
 
562
  }
563
- function applyTransform(img) {
 
 
 
 
 
 
 
 
 
564
  const z = (img.dataset.zoom || 100) / 100;
565
- const x = img.dataset.tx || 0;
566
- const y = img.dataset.ty || 0;
567
- img.style.transform = `scale(${z}) translate(${x/z}px, ${y/z}px)`;
 
568
  }
 
569
  function resetPanelTransform() {
570
- if(!selectedPanel) return;
571
  const img = selectedPanel.querySelector('img');
572
- img.dataset.zoom = 100; img.dataset.tx = 0; img.dataset.ty = 0;
 
 
573
  document.getElementById('zoom-slider').value = 100;
574
- applyTransform(img);
575
  saveLocal();
576
  }
577
-
578
  // --- BACKEND ACTIONS ---
579
  function replacePanelImage() {
580
- if(!selectedPanel) return alert("Select Panel");
581
  const inp = document.getElementById('image-uploader');
582
  inp.onchange = async (e) => {
583
- const fd = new FormData(); fd.append('image', e.target.files[0]);
 
 
 
584
  const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd});
585
  const d = await r.json();
586
- if(d.success) { selectedPanel.querySelector('img').src = `/frames/${d.new_filename}?sid=${sid}`; saveLocal(); }
 
 
 
 
 
 
 
587
  inp.value = '';
588
  };
589
  inp.click();
590
  }
591
 
592
  async function adjustFrame(dir) {
593
- if(!selectedPanel) return alert("Select Panel");
594
  const img = selectedPanel.querySelector('img');
595
  let fname = img.src.split('/').pop().split('?')[0];
596
- await fetch(`/regenerate_frame?sid=${sid}`, {
 
597
  method:'POST', headers:{'Content-Type':'application/json'},
598
  body:JSON.stringify({filename:fname, direction:dir})
599
  });
600
- img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
 
 
 
 
 
 
601
  saveLocal();
602
  }
603
 
604
  async function gotoTimestamp() {
605
- if(!selectedPanel) return alert("Select Panel");
606
- let v = document.getElementById('timestamp-input').value;
607
- if(v.includes(':')) { let p=v.split(':'); v = parseInt(p[0])*60 + parseFloat(p[1]); }
 
 
 
 
 
 
 
 
 
 
608
  const img = selectedPanel.querySelector('img');
609
  let fname = img.src.split('/').pop().split('?')[0];
610
- await fetch(`/goto_timestamp?sid=${sid}`, {
 
611
  method:'POST', headers:{'Content-Type':'application/json'},
612
  body:JSON.stringify({filename:fname, timestamp:v})
613
  });
614
- img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
 
 
 
 
 
 
 
 
615
  saveLocal();
616
  }
617
-
618
  // --- EXPORT ---
619
  async function exportComic() {
620
  const pgs = document.querySelectorAll('.comic-page');
621
- for(let i=0; i<pgs.length; i++) {
622
- // html-to-image supports gradients!
623
- const u = await htmlToImage.toPng(pgs[i], {pixelRatio:3});
624
- const a = document.createElement('a'); a.href=u; a.download=`Page-${i+1}.png`; a.click();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
625
  }
 
 
 
 
 
 
626
  }
627
-
628
- // --- SAVE STATE ---
629
  function saveLocal() {
630
  const pages = [];
631
  document.querySelectorAll('.comic-page').forEach(p => {
@@ -634,11 +924,16 @@ INDEX_HTML = '''
634
  const img = pan.querySelector('img');
635
  const bubbles = [];
636
  pan.querySelectorAll('.speech-bubble').forEach(b => {
 
637
  bubbles.push({
638
- text: b.innerText,
639
- left: b.style.left, top: b.style.top,
640
- width: b.style.width, height: b.style.height,
 
 
641
  classes: b.className,
 
 
642
  tailPos: b.style.getPropertyValue('--tail-pos'),
643
  colors: {
644
  fill: b.style.getPropertyValue('--bubble-fill-color'),
@@ -648,20 +943,41 @@ INDEX_HTML = '''
648
  });
649
  panels.push({
650
  src: img.src,
651
- zoom: img.dataset.zoom, tx: img.dataset.tx, ty: img.dataset.ty,
 
 
652
  bubbles: bubbles
653
  });
654
  });
655
  pages.push({ panels: panels });
656
  });
657
- localStorage.setItem('comic_autosave', JSON.stringify(pages));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
658
  }
659
  </script>
660
  </body>
661
  </html>
662
  '''
663
 
664
- # --- 3. BACKEND LOGIC ---
665
  class EnhancedComicGenerator:
666
  def __init__(self, sid):
667
  self.sid = sid
@@ -670,175 +986,479 @@ class EnhancedComicGenerator:
670
  self.frames_dir = os.path.join(self.user_dir, 'frames')
671
  self.output_dir = os.path.join(self.user_dir, 'output')
672
  self.status_file = os.path.join(self.output_dir, 'status.json')
 
673
  os.makedirs(self.frames_dir, exist_ok=True)
674
  os.makedirs(self.output_dir, exist_ok=True)
675
  self.video_fps = None
676
  self.frame_metadata = {}
 
677
 
678
  def update_status(self, message, progress):
679
  try:
680
- with open(self.status_file, 'w') as f: json.dump({'message': message, 'progress': progress}, f)
681
- except: pass
 
 
682
 
683
  def cleanup_previous_run(self):
 
684
  if os.path.exists(self.frames_dir):
685
  for f in os.listdir(self.frames_dir):
686
- try: os.remove(os.path.join(self.frames_dir, f))
687
- except: pass
 
 
688
  if os.path.exists(self.output_dir):
689
  for f in os.listdir(self.output_dir):
690
  if f != 'status.json':
691
- try: os.remove(os.path.join(self.output_dir, f))
692
- except: pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
693
 
694
  def generate_comic(self):
 
 
695
  try:
696
- if cv2 is None: raise Exception("OpenCV not installed")
697
- self.update_status("Processing Video...", 5)
 
 
 
 
 
698
  cap = cv2.VideoCapture(self.video_path)
699
- if not cap.isOpened(): raise Exception("Invalid Video")
 
700
  self.video_fps = cap.get(cv2.CAP_PROP_FPS) or 25
701
  cap.release()
702
-
703
- self.update_status("Extracting Dialogue...", 20)
 
704
  user_srt = os.path.join(self.user_dir, 'subs.srt')
705
  try:
706
  get_real_subtitles(self.video_path)
707
- if os.path.exists('test1.srt'): shutil.move('test1.srt', user_srt)
708
- except:
709
- with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\nHello\n")
710
-
711
- self.update_status("Generating 12 Pages...", 40)
712
- with open(user_srt, 'r', encoding='utf-8') as f: subs = list(srt.parse(f.read()))
713
 
714
- cap = cv2.VideoCapture(self.video_path)
715
- frame_files, bubbles = [], []
716
- meta = {}
717
 
718
- # Max 48 Panels (12 Pages x 4 Panels)
719
- for i, sub in enumerate(subs[:48]):
720
- mid = (sub.start.total_seconds() + sub.end.total_seconds())/2
721
- cap.set(cv2.CAP_PROP_POS_MSEC, mid*1000)
722
- ret, frame = cap.read()
723
- if ret:
724
- fname = f"frame_{i}.png"
725
- cv2.imwrite(os.path.join(self.frames_dir, fname), frame)
726
- frame_files.append(fname)
727
- meta[fname] = mid
728
- bubbles.append(bubble(
729
- dialog=sub.content, bubble_offset_x=50, bubble_offset_y=20,
730
- lip_x=-1, lip_y=-1, emotion='normal'
731
- ))
732
- cap.release()
733
 
734
- with open(os.path.join(self.frames_dir, 'frame_metadata.json'), 'w') as f: json.dump(meta, f)
735
-
736
- pages_data = []
737
- for i in range(0, len(frame_files), 4):
738
- batch_f = frame_files[i:i+4]
739
- batch_b = bubbles[i:i+4]
740
- panels = [{'image': f} for f in batch_f]
741
- b_data = [b if isinstance(b, dict) else b.__dict__ for b in batch_b]
742
- pages_data.append({'panels': panels, 'bubbles': b_data})
743
-
744
- with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f: json.dump(pages_data, f)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
745
 
746
- self.update_status("Done!", 100)
747
  except Exception as e:
 
748
  traceback.print_exc()
749
  self.update_status(f"Error: {str(e)}", -1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
750
 
751
  def regenerate_frame(self, fname, direction):
 
752
  try:
753
- meta_path = os.path.join(self.frames_dir, 'frame_metadata.json')
754
- with open(meta_path,'r') as f: meta = json.load(f)
755
- curr_time = meta[fname]
 
 
756
 
 
 
 
 
 
 
 
 
 
 
 
757
  if not self.video_fps:
758
- cap = cv2.VideoCapture(self.video_path); self.video_fps = cap.get(cv2.CAP_PROP_FPS); cap.release()
 
 
759
 
760
- offset = (1.0/self.video_fps) * (1 if direction=='forward' else -1)
 
761
  new_time = max(0, curr_time + offset)
762
 
 
763
  cap = cv2.VideoCapture(self.video_path)
764
- cap.set(cv2.CAP_PROP_POS_MSEC, new_time*1000)
765
  ret, frame = cap.read()
766
  cap.release()
767
 
768
  if ret:
769
- cv2.imwrite(os.path.join(self.frames_dir, fname), frame)
770
- meta[fname] = new_time
771
- with open(meta_path,'w') as f: json.dump(meta, f)
772
- return {"success":True}
773
- return {"success":False, "message":"End of video"}
774
- except Exception as e: return {"success":False, "message":str(e)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
775
 
776
  def get_frame_at_timestamp(self, fname, ts):
 
777
  try:
778
  cap = cv2.VideoCapture(self.video_path)
779
- cap.set(cv2.CAP_PROP_POS_MSEC, float(ts)*1000)
 
 
 
 
 
 
 
 
 
 
780
  ret, frame = cap.read()
781
  cap.release()
 
782
  if ret:
783
- cv2.imwrite(os.path.join(self.frames_dir, fname), frame)
784
- meta_path = os.path.join(self.frames_dir, 'frame_metadata.json')
785
- if os.path.exists(meta_path):
786
- with open(meta_path,'r') as f: meta = json.load(f)
787
- meta[fname] = float(ts)
788
- with open(meta_path,'w') as f: json.dump(meta, f)
789
- return {"success":True}
790
- return {"success":False, "message":"Invalid time"}
791
- except Exception as e: return {"success":False, "message":str(e)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
792
 
793
  # --- ROUTES ---
794
  @app.route('/')
795
- def index(): return INDEX_HTML
 
796
 
797
  @app.route('/uploader', methods=['POST'])
798
  def upload():
799
  sid = request.args.get('sid')
800
- if not sid: return "Missing SID", 400
 
 
 
 
 
801
  f = request.files['file']
802
  gen = EnhancedComicGenerator(sid)
803
  gen.cleanup_previous_run()
804
  f.save(gen.video_path)
805
  gen.update_status("Starting...", 5)
 
806
  threading.Thread(target=gen.generate_comic).start()
807
- return jsonify({'success': True})
808
 
809
  @app.route('/status')
810
  def get_status():
811
  sid = request.args.get('sid')
 
 
 
812
  path = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
813
- if os.path.exists(path): return send_file(path)
 
814
  return jsonify({'progress': 0, 'message': "Waiting..."})
815
 
816
  @app.route('/output/<path:filename>')
817
  def get_output(filename):
818
- return send_from_directory(os.path.join(BASE_USER_DIR, request.args.get('sid'), 'output'), filename)
 
 
 
819
 
820
  @app.route('/frames/<path:filename>')
821
  def get_frame(filename):
822
- return send_from_directory(os.path.join(BASE_USER_DIR, request.args.get('sid'), 'frames'), filename)
 
 
 
823
 
824
  @app.route('/regenerate_frame', methods=['POST'])
825
  def regen():
 
 
 
 
826
  d = request.get_json()
827
- return jsonify(EnhancedComicGenerator(request.args.get('sid')).regenerate_frame(d['filename'], d['direction']))
 
828
 
829
  @app.route('/goto_timestamp', methods=['POST'])
830
  def go_time():
 
 
 
 
831
  d = request.get_json()
832
- return jsonify(EnhancedComicGenerator(request.args.get('sid')).get_frame_at_timestamp(d['filename'], d['timestamp']))
 
833
 
834
  @app.route('/replace_panel', methods=['POST'])
835
  def rep_panel():
836
  sid = request.args.get('sid')
 
 
 
 
 
 
837
  f = request.files['image']
838
- fname = f"rep_{int(time.time())}.png"
839
  f.save(os.path.join(BASE_USER_DIR, sid, 'frames', fname))
840
- return jsonify({'success':True, 'new_filename':fname})
 
841
 
842
  if __name__ == '__main__':
843
  os.makedirs(BASE_USER_DIR, exist_ok=True)
844
- app.run(host='0.0.0.0', port=7860)
 
 
 
21
  import srt
22
  except ImportError as e:
23
  print(f"❌ CRITICAL ERROR: Missing python library. {e}")
 
24
  cv2 = None
25
  np = None
26
  Image = None
27
  srt = None
28
 
29
+ # --- 2. BACKEND IMPORTS WITH FALLBACKS ---
30
  def dummy_func(*args, **kwargs):
31
  return 0, 0, None, None
32
 
33
  try:
34
  from backend.keyframes.keyframes import black_bar_crop
35
+ print("✅ Black bar cropping module loaded.")
36
+ except Exception as e:
37
+ print(f"⚠️ Could not load black_bar_crop: {e}. Cropping will be SKIPPED.")
38
  black_bar_crop = dummy_func
39
 
40
  try:
41
  from backend.simple_color_enhancer import SimpleColorEnhancer
42
+ print("✅ SimpleColorEnhancer loaded.")
43
+ except Exception as e:
44
+ print(f"⚠️ Could not load SimpleColorEnhancer: {e}. This feature will be SKIPPED.")
45
  class SimpleColorEnhancer:
46
+ def enhance_batch(self, *args, **kwargs): pass
47
+ def enhance_single(self, *args, **kwargs): pass
48
 
49
  try:
50
  from backend.quality_color_enhancer import QualityColorEnhancer
51
+ print("✅ QualityColorEnhancer loaded.")
52
+ except Exception as e:
53
+ print(f"⚠️ Could not load QualityColorEnhancer: {e}. This feature will be SKIPPED.")
54
  class QualityColorEnhancer:
55
+ def batch_enhance(self, *args, **kwargs): pass
56
+ def enhance_single(self, *args, **kwargs): pass
57
 
58
  try:
59
  from backend.class_def import bubble, panel, Page
60
+ print("✅ Core class definitions (bubble, panel, Page) loaded.")
61
+ except Exception as e:
62
+ print(f"⚠️ CRITICAL: Could not load core class definitions: {e}. Using fallback definitions.")
63
  def bubble(dialog="", bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, emotion='normal'):
64
  return {
65
  'dialog': dialog,
 
69
  'lip_y': lip_y,
70
  'emotion': emotion
71
  }
72
+ def panel(image=""):
73
+ return {'image': image}
74
  class Page:
75
  def __init__(self, panels, bubbles):
76
  self.panels = panels
77
  self.bubbles = bubbles
78
 
79
  try:
80
+ from backend.ai_enhanced_core import image_processor, comic_styler, face_detector, layout_optimizer
81
  from backend.ai_bubble_placement import ai_bubble_placer
82
  from backend.subtitles.subs_real import get_real_subtitles
83
+ from backend.keyframes.keyframes_simple import generate_keyframes_simple
84
+ print("✅ Core utility modules loaded.")
85
+ except Exception as e:
86
+ print(f"⚠️ Could not load a core utility module: {e}")
87
  def get_real_subtitles(v): pass
88
+ def generate_keyframes_simple(*args, **kwargs): pass
89
  class DummyDetector:
90
  def detect_faces(self, p): return []
91
  def get_lip_position(self, p, f): return -1, -1
 
98
  app = Flask(__name__)
99
  BASE_USER_DIR = "userdata"
100
 
101
+ # --- FULL HTML INTERFACE ---
102
  INDEX_HTML = '''
103
  <!DOCTYPE html>
104
  <html lang="en">
 
107
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
108
  <title>Movie to Comic Generator</title>
109
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
110
+ <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Gloria+Hallelujah&family=Lato&display=swap" rel="stylesheet">
111
  <style>
112
  /* --- GLOBAL STYLES --- */
113
+ * { box-sizing: border-box; }
114
  body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
115
 
116
  /* --- UPLOAD VIEW --- */
 
135
 
136
  /* --- COMIC PAGE LAYOUT --- */
137
  .comic-wrapper { max-width: 1000px; margin: 0 auto; }
138
+ .page-wrapper { margin: 30px auto; display: flex; flex-direction: column; align-items: center; }
139
+ .page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
140
+ .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; padding: 10px; }
141
+ .comic-grid { display: grid; grid-template-columns: 285px 285px; grid-template-rows: 185px 185px; gap: 10px; width: 100%; height: 100%; }
142
 
143
  .panel { position: relative; overflow: hidden; border: 2px solid #000; background: #eee; cursor: pointer; }
144
  .panel.selected { outline: 3px solid #2196F3; outline-offset: -3px; }
145
  .panel img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.1s ease-out; transform-origin: center center; }
146
  .panel img.pannable { cursor: grab; }
147
  .panel img.panning { cursor: grabbing; }
148
+
149
  /* --- SPEECH BUBBLE GENERAL --- */
150
  .speech-bubble {
151
  position: absolute; display: flex; justify-content: center; align-items: center;
152
+ width: 150px; height: 80px; min-width: 50px; min-height: 30px; box-sizing: border-box;
153
  z-index: 10; cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold;
154
+ font-size: 13px; text-align: center; overflow: visible;
155
  }
156
  .bubble-text { padding: 0.8em; word-wrap: break-word; position: relative; z-index: 5; }
157
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
158
+ .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); text-align:center; padding:8px; z-index:102; resize:none; font: inherit; }
159
+
160
+ /* --- EXACT "SHARK FIN" CSS WITH MASK --- */
161
  .speech-bubble.speech {
162
+ --b: 3em;
163
+ --h: 1.8em;
164
+ --t: 0.6;
165
+ --p: var(--tail-pos, 50%);
166
+ --r: 1.2em;
167
  --c: var(--bubble-fill-color, #4ECDC4);
168
 
169
  background: var(--c);
 
171
  padding: 1em;
172
  position: absolute;
173
 
 
174
  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);
175
  }
 
 
176
  .speech-bubble.speech:before {
177
  content: ""; position: absolute; width: var(--b); height: var(--h);
178
+ background: inherit;
 
179
  border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
180
+ -webkit-mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
181
+ mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
182
  }
 
 
 
 
183
 
184
+ /* BOTTOM TAIL (Default) */
185
+ .speech-bubble.speech.tail-bottom:before { top: 100%; left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); }
186
+
187
+ /* TOP TAIL */
188
  .speech-bubble.speech.tail-top {
189
  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);
190
  }
191
  .speech-bubble.speech.tail-top:before {
192
+ bottom: 100%; left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
193
  transform: scaleY(-1);
194
  }
195
 
196
+ /* LEFT TAIL */
197
  .speech-bubble.speech.tail-left { border-radius: var(--r); }
198
  .speech-bubble.speech.tail-left:before {
199
+ right: 100%; top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
200
  transform: rotate(90deg); transform-origin: top right;
201
  }
202
 
203
+ /* RIGHT TAIL */
204
  .speech-bubble.speech.tail-right { border-radius: var(--r); }
205
  .speech-bubble.speech.tail-right:before {
206
+ left: 100%; top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
207
  transform: rotate(-90deg); transform-origin: top left;
208
  }
209
+
210
  /* THOUGHT BUBBLE */
211
  .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
212
  .speech-bubble.thought::after { display:none; }
213
  .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
214
  .thought-dot-1 { width: 20px; height: 20px; bottom: -20px; left: 15px; }
215
  .thought-dot-2 { width: 12px; height: 12px; bottom: -32px; left: 5px; }
216
+ .speech-bubble.flipped .thought-dot-1 { left: auto; right: 15px; }
217
+ .speech-bubble.flipped .thought-dot-2 { left: auto; right: 5px; }
218
+ .speech-bubble.flipped-vertical .thought-dot-1 { bottom: auto; top: -20px; }
219
+ .speech-bubble.flipped-vertical .thought-dot-2 { bottom: auto; top: -32px; }
220
+
221
+ /* REACTION BUBBLE */
222
+ .speech-bubble.reaction {
223
+ background: #FFD700; border: 3px solid #E53935; color: #D32F2F;
224
+ font-weight: 900; text-transform: uppercase; width: 180px;
225
+ clip-path: polygon(0% 25%, 17% 21%, 17% 0%, 31% 16%, 50% 4%, 69% 16%, 83% 0%, 83% 21%, 100% 25%, 85% 45%, 95% 62%, 82% 79%, 100% 97%, 79% 89%, 60% 98%, 46% 82%, 27% 95%, 15% 78%, 5% 62%, 15% 45%);
226
+ }
227
+
228
+ /* NARRATION BOX */
229
+ .speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; }
230
+
231
+ /* IDEA BUBBLE */
232
+ .speech-bubble.idea {
233
+ background: linear-gradient(180deg,#FFFDD0 0%, #FFF8B5 100%);
234
+ border: 2px solid #FFA500; color: #6a4b00;
235
+ border-radius: 40% 60% 40% 60% / 60% 40% 60% 40%;
236
+ }
237
+
238
+ /* RESIZE HANDLES */
239
+ .resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; z-index: 11; }
240
  .speech-bubble.selected .resize-handle { display: block; }
241
  .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
242
+ .resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
243
+ .resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
244
+ .resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
245
 
246
  /* FLOATING TOOLBAR */
247
  .edit-controls {
248
+ position: fixed; bottom: 20px; right: 20px; width: 250px;
249
+ background: rgba(44, 62, 80, 0.95); color: white; padding: 15px; border-radius: 8px;
250
+ box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 900; font-size: 13px;
251
  }
252
  .edit-controls h4 { margin: 0 0 10px 0; color: #4ECDC4; text-align: center; }
253
+ .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
254
+ .control-group label { font-size: 11px; font-weight: bold; display: block; margin-bottom: 3px; }
255
  button, input, select { width: 100%; margin-top: 5px; padding: 6px; border-radius: 4px; border: 1px solid #ddd; cursor: pointer; font-weight: bold; font-size: 12px; }
256
  button:hover { background: #f5f5f5; }
257
  .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
258
+ .color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
259
+ .color-grid div { text-align: center; }
260
+ .color-grid input[type="color"] { height: 30px; padding: 2px; }
261
+ .slider-container { display: flex; align-items: center; gap: 5px; margin-top: 5px; }
262
+ .slider-container label { min-width: 40px; font-size: 11px; }
263
+ .timestamp-controls { display: grid; grid-template-columns: 1fr auto; gap: 5px; }
264
+ .timestamp-controls input { color: #333; font-weight: normal; }
265
+ .action-btn { background: #4CAF50; color: white; }
266
+ .reset-btn { background: #e74c3c; color: white; }
267
+ .secondary-btn { background: #f39c12; color: white; }
268
+ .export-btn { background: #2196F3; color: white; }
269
  </style>
270
  </head>
271
  <body>
 
272
  <!-- 1. UPLOAD SCREEN -->
273
  <div id="upload-container">
274
  <div class="upload-box">
 
285
  </div>
286
  </div>
287
  </div>
288
+
289
  <!-- 2. EDITOR SCREEN -->
290
  <div id="editor-container">
291
  <div class="comic-wrapper" id="comic-container"></div>
292
 
 
293
  <input type="file" id="image-uploader" style="display: none;" accept="image/*">
294
+
295
  <div class="edit-controls">
296
+ <h4>✏️ Interactive Editor</h4>
 
 
 
 
297
 
298
+ <!-- Bubble Controls -->
299
  <div class="control-group">
300
+ <label>Bubble Tools:</label>
 
 
 
 
301
  <select id="bubble-type-select" onchange="changeBubbleType(this.value)" disabled>
302
  <option value="speech">Speech</option>
303
  <option value="thought">Thought</option>
304
+ <option value="reaction">Reaction</option>
305
+ <option value="narration">Narration</option>
306
+ <option value="idea">Idea</option>
307
  </select>
308
+ <select id="font-select" onchange="changeFont(this.value)" disabled>
309
+ <option value="'Comic Neue', cursive">Comic Neue</option>
310
+ <option value="'Bangers', cursive">Bangers</option>
311
+ <option value="'Gloria Hallelujah', cursive">Gloria</option>
312
+ <option value="'Lato', sans-serif">Lato</option>
313
+ </select>
314
+ <div class="color-grid">
315
+ <div>
316
+ <label>Text</label>
317
+ <input type="color" id="bubble-text-color" value="#ffffff" disabled>
318
+ </div>
319
+ <div>
320
+ <label>Fill</label>
321
+ <input type="color" id="bubble-fill-color" value="#4ECDC4" disabled>
322
+ </div>
323
+ </div>
324
+ <button onclick="addBubble()" class="action-btn">💬 Add Bubble</button>
325
+ <button onclick="deleteBubble()" class="reset-btn">🗑️ Delete Bubble</button>
326
  </div>
327
+
328
+ <!-- Tail Controls -->
329
  <div class="control-group" id="tail-controls" style="display:none;">
330
+ <label>Tail Adjustment:</label>
331
+ <button onclick="rotateTail()" class="secondary-btn">🔄 Rotate Side</button>
332
+ <div class="slider-container">
333
+ <label>Pos:</label>
334
+ <input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)">
335
  </div>
 
336
  </div>
337
+
338
+ <!-- Panel Controls -->
339
  <div class="control-group">
340
+ <label>Panel Tools:</label>
341
+ <button onclick="replacePanelImage()" class="action-btn">🖼️ Replace Image</button>
342
  <div class="button-grid">
343
+ <button onclick="adjustFrame('backward')" class="secondary-btn">⬅️ Prev</button>
344
+ <button onclick="adjustFrame('forward')" class="action-btn">Next ➡️</button>
345
+ </div>
346
+ <div class="timestamp-controls">
347
+ <input type="text" id="timestamp-input" placeholder="mm:ss or secs">
348
+ <button onclick="gotoTimestamp()" class="action-btn">Go</button>
349
  </div>
350
+ </div>
351
+
352
+ <!-- Zoom Controls -->
353
+ <div class="control-group">
354
+ <label>Zoom & Pan:</label>
355
  <div class="button-grid">
356
+ <button onclick="resetPanelTransform()" class="secondary-btn">Reset</button>
357
+ <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled oninput="handleZoom(this)">
358
  </div>
 
 
359
  </div>
360
+
361
+ <!-- Actions -->
362
+ <div class="control-group">
363
+ <button onclick="saveLocal()" class="secondary-btn">💾 Save Draft</button>
364
+ <button onclick="exportComic()" class="export-btn">📥 Export Pages</button>
365
+ <button onclick="clearAndReset()" class="reset-btn" style="margin-top:10px;">🔄 Clear & Reset</button>
366
+ </div>
367
  </div>
368
  </div>
369
+
370
  <script>
371
  // --- SESSION LOGIC ---
372
  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);}); }
373
  let sid = localStorage.getItem('comic_sid') || genUUID();
374
  localStorage.setItem('comic_sid', sid);
375
  console.log("Session ID:", sid);
376
+
377
+ if(localStorage.getItem('comic_autosave_'+sid)) {
 
378
  document.getElementById('restore-btn').style.display = 'block';
379
  }
380
+
381
+ let interval, selectedBubble = null, selectedPanel = null;
382
+ let isDragging = false, isResizing = false, isPanning = false;
383
  let startX, startY, initX, initY, initW, initH;
384
  let panStartX, panStartY, panStartTx, panStartTy;
385
+ let resizeHandle = '', originalWidth, originalHeight, originalX, originalY, originalMouseX, originalMouseY;
386
+ let currentlyEditing = null;
387
+
388
  // --- RESTORE FUNCTION ---
389
  function restoreSession() {
390
+ const savedData = localStorage.getItem('comic_autosave_'+sid);
391
  if(!savedData) return alert("No saved session found.");
392
  try {
393
  const state = JSON.parse(savedData);
 
396
  document.getElementById('editor-container').style.display = 'block';
397
  } catch(e) { console.error(e); alert("Failed to restore session."); }
398
  }
399
+
400
  function renderFromState(pagesData) {
401
  const con = document.getElementById('comic-container');
402
  con.innerHTML = '';
403
+ pagesData.forEach((page, pageIdx) => {
404
+ const pageWrapper = document.createElement('div');
405
+ pageWrapper.className = 'page-wrapper';
406
+ const pageTitle = document.createElement('h2');
407
+ pageTitle.className = 'page-title';
408
+ pageTitle.textContent = `Page ${pageIdx + 1}`;
409
+ pageWrapper.appendChild(pageTitle);
410
+
411
  const div = document.createElement('div');
412
  div.className = 'comic-page';
413
  const grid = document.createElement('div');
 
419
  pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
420
 
421
  const img = document.createElement('img');
422
+ img.src = pan.src;
423
  img.dataset.zoom = pan.zoom || 100;
424
+ img.dataset.translateX = pan.tx || 0;
425
+ img.dataset.translateY = pan.ty || 0;
426
  updateImageTransform(img);
427
  img.onmousedown = (e) => startPan(e, img);
428
  pDiv.appendChild(img);
429
+
430
  // Restore bubbles
431
+ (pan.bubbles || []).forEach(bData => {
432
  const b = createBubbleHTML(bData);
433
  pDiv.appendChild(b);
434
  });
435
  grid.appendChild(pDiv);
436
  });
437
  div.appendChild(grid);
438
+ pageWrapper.appendChild(div);
439
+ con.appendChild(pageWrapper);
440
  });
441
  }
442
+
443
  // --- UPLOAD LOGIC ---
444
  async function upload() {
445
  const f = document.getElementById('file-upload').files[0];
 
451
  if(r.ok) interval = setInterval(checkStatus, 2000);
452
  else { alert("Upload failed"); location.reload(); }
453
  }
454
+
455
  async function checkStatus() {
456
  try {
457
  const r = await fetch(`/status?sid=${sid}`);
 
462
  document.getElementById('upload-container').style.display='none';
463
  document.getElementById('editor-container').style.display='block';
464
  loadNewComic();
465
+ } else if (d.progress < 0) {
466
+ clearInterval(interval);
467
+ document.getElementById('status-text').textContent = "Error: " + d.message;
468
+ document.querySelector('.loader').style.display = 'none';
469
  }
470
  } catch(e) {}
471
  }
472
+
473
  function loadNewComic() {
474
  fetch(`/output/pages.json?sid=${sid}`).then(r=>r.json()).then(data => {
475
+ const cleanData = data.map((p, pi) => ({
476
  panels: p.panels.map((pan, j) => ({
477
  src: `/frames/${pan.image}?sid=${sid}`,
478
  bubbles: (p.bubbles && p.bubbles[j] && p.bubbles[j].dialog) ? [{
479
  text: p.bubbles[j].dialog,
480
+ left: (p.bubbles[j].bubble_offset_x || 50) + 'px',
481
+ top: (p.bubbles[j].bubble_offset_y || 20) + 'px',
482
  type: 'speech',
483
  tailPos: '50%',
484
  colors: { text: '#ffffff', fill: '#4ECDC4' }
 
489
  saveLocal();
490
  });
491
  }
492
+
493
  // --- BUBBLE CREATION ---
494
  function createBubbleHTML(data) {
495
  const b = document.createElement('div');
496
+ b.dataset.type = data.type || 'speech';
497
+
498
+ // Apply type-specific classes
499
+ applyBubbleType(b, data.type || 'speech', data.classes);
500
+
501
  b.style.left = data.left;
502
  b.style.top = data.top;
503
  if(data.width) b.style.width = data.width;
504
  if(data.height) b.style.height = data.height;
505
+ if(data.font) b.style.fontFamily = data.font;
 
506
 
507
  if(data.colors) {
508
+ b.style.setProperty('--bubble-fill-color', data.colors.fill || '#4ECDC4');
509
+ b.style.setProperty('--bubble-text-color', data.colors.text || '#ffffff');
510
  }
511
  if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
512
+
513
+ const textSpan = document.createElement('span');
514
+ textSpan.className = 'bubble-text';
515
+ textSpan.textContent = data.text || '';
516
+ b.appendChild(textSpan);
517
+
518
+ // Add resize handles
519
+ ['nw', 'ne', 'sw', 'se'].forEach(dir => {
520
+ const handle = document.createElement('div');
521
+ handle.className = `resize-handle ${dir}`;
522
+ handle.onmousedown = (e) => startResize(e, dir);
523
+ b.appendChild(handle);
524
+ });
525
+
526
  // Drag
527
  b.onmousedown = (e) => {
528
  if(e.target.classList.contains('resize-handle')) return;
529
  e.stopPropagation(); selectBubble(b);
530
+ isDragging = true; startX = e.clientX; startY = e.clientY;
531
+ initX = b.offsetLeft; initY = b.offsetTop;
532
  };
533
 
 
 
 
 
 
 
 
534
  // Edit Text
535
  b.ondblclick = (e) => {
536
  e.stopPropagation();
537
+ editBubbleText(b);
 
 
 
 
538
  };
539
+
540
+ b.onclick = (e) => { e.stopPropagation(); selectBubble(b); };
541
+
542
  return b;
543
  }
544
+
545
+ function applyBubbleType(bubble, type, existingClasses) {
546
+ // Remove thought dots if any
547
+ bubble.querySelectorAll('.thought-dot').forEach(el => el.remove());
548
+
549
+ let baseClasses = 'speech-bubble ' + type;
550
+ if (type === 'speech') baseClasses += ' tail-bottom';
551
+ if (existingClasses && existingClasses.includes('selected')) baseClasses += ' selected';
552
+
553
+ bubble.className = baseClasses;
554
+ bubble.dataset.type = type;
555
+
556
+ if (type === 'thought') {
557
+ for (let i = 1; i <= 2; i++) {
558
+ const dot = document.createElement('div');
559
+ dot.className = `thought-dot thought-dot-${i}`;
560
+ bubble.appendChild(dot);
561
+ }
562
+ }
563
+ }
564
+
565
+ function editBubbleText(bubble) {
566
+ if (currentlyEditing) return;
567
+ currentlyEditing = bubble;
568
+ const textSpan = bubble.querySelector('.bubble-text');
569
+ const textarea = document.createElement('textarea');
570
+ textarea.value = textSpan.textContent;
571
+ bubble.appendChild(textarea);
572
+ textSpan.style.display = 'none';
573
+ textarea.focus();
574
+ const finishEditing = () => {
575
+ textSpan.textContent = textarea.value;
576
+ textarea.remove();
577
+ textSpan.style.display = '';
578
+ currentlyEditing = null;
579
+ saveLocal();
580
+ };
581
+ textarea.addEventListener('blur', finishEditing, { once: true });
582
+ textarea.addEventListener('keydown', e => {
583
+ if (e.key === 'Enter' && !e.shiftKey) {
584
+ e.preventDefault();
585
+ textarea.blur();
586
+ }
587
+ });
588
+ }
589
+
590
  // --- GLOBAL EVENTS ---
591
  document.addEventListener('mousemove', (e) => {
592
  if(isDragging && selectedBubble) {
 
594
  selectedBubble.style.top = (initY + e.clientY - startY) + 'px';
595
  }
596
  if(isResizing && selectedBubble) {
597
+ resizeBubble(e);
 
598
  }
599
  if(isPanning && selectedPanel) {
600
+ panImage(e);
 
 
 
601
  }
602
  });
603
+
604
  document.addEventListener('mouseup', () => {
605
  if(isDragging || isResizing || isPanning) saveLocal();
606
  isDragging = false; isResizing = false; isPanning = false;
607
  });
608
+
609
+ // --- RESIZE LOGIC ---
610
+ function startResize(e, dir) {
611
+ e.preventDefault();
612
+ e.stopPropagation();
613
+ isResizing = true;
614
+ resizeHandle = dir;
615
+ const rect = selectedBubble.getBoundingClientRect();
616
+ originalWidth = rect.width;
617
+ originalHeight = rect.height;
618
+ originalX = selectedBubble.offsetLeft;
619
+ originalY = selectedBubble.offsetTop;
620
+ originalMouseX = e.clientX;
621
+ originalMouseY = e.clientY;
622
+ }
623
+
624
+ function resizeBubble(e) {
625
+ if (!isResizing || !selectedBubble) return;
626
+ const dx = e.clientX - originalMouseX;
627
+ const dy = e.clientY - originalMouseY;
628
+
629
+ if (resizeHandle.includes('e')) selectedBubble.style.width = `${originalWidth + dx}px`;
630
+ if (resizeHandle.includes('w')) {
631
+ selectedBubble.style.width = `${originalWidth - dx}px`;
632
+ selectedBubble.style.left = `${originalX + dx}px`;
633
+ }
634
+ if (resizeHandle.includes('s')) selectedBubble.style.height = `${originalHeight + dy}px`;
635
+ if (resizeHandle.includes('n')) {
636
+ selectedBubble.style.height = `${originalHeight - dy}px`;
637
+ selectedBubble.style.top = `${originalY + dy}px`;
638
+ }
639
+ }
640
+
641
+ // --- SELECTION ---
642
  function selectBubble(el) {
643
  if(selectedBubble) selectedBubble.classList.remove('selected');
644
+ if(selectedPanel) { selectedPanel.classList.remove('selected'); selectedPanel = null; }
645
  selectedBubble = el;
646
  el.classList.add('selected');
 
647
 
648
  // Sync Controls
649
+ const bubbleType = el.dataset.type || 'speech';
650
+ document.getElementById('tail-controls').style.display = (bubbleType === 'speech' || bubbleType === 'thought') ? 'block' : 'none';
651
+
652
+ ['bubble-text-color','bubble-fill-color','bubble-type-select','font-select'].forEach(i => document.getElementById(i).disabled = false);
653
  document.getElementById('zoom-slider').disabled = true;
654
+ document.getElementById('bubble-type-select').value = bubbleType;
655
+
656
+ // Sync colors
657
+ const styles = window.getComputedStyle(el);
658
+ const textColor = styles.getPropertyValue('--bubble-text-color').trim() || rgbToHex(styles.color);
659
+ const fillColor = styles.getPropertyValue('--bubble-fill-color').trim() || rgbToHex(styles.backgroundColor);
660
+ document.getElementById('bubble-text-color').value = textColor;
661
+ document.getElementById('bubble-fill-color').value = fillColor;
662
+
663
+ // Sync tail slider
664
+ const tailPos = styles.getPropertyValue('--tail-pos').trim();
665
+ document.getElementById('tail-slider').value = tailPos ? parseInt(tailPos) : 50;
666
  }
667
+
668
  function selectPanel(el) {
669
  if(selectedPanel) selectedPanel.classList.remove('selected');
670
+ if(selectedBubble) { selectedBubble.classList.remove('selected'); selectedBubble = null; }
671
  selectedPanel = el;
672
  el.classList.add('selected');
673
 
 
674
  const img = el.querySelector('img');
675
  document.getElementById('zoom-slider').disabled = false;
676
  document.getElementById('zoom-slider').value = img.dataset.zoom || 100;
677
 
678
+ ['bubble-text-color','bubble-fill-color','bubble-type-select','font-select'].forEach(i => document.getElementById(i).disabled = true);
679
  document.getElementById('tail-controls').style.display = 'none';
680
  }
681
+
682
+ // --- BUBBLE ACTIONS ---
683
  function addBubble() {
684
  if(!selectedPanel) return alert("Select a panel first");
685
  const b = createBubbleHTML({
686
+ text: "New Text", left: "50px", top: "30px",
687
+ type: 'speech',
688
+ colors: { text: '#ffffff', fill: '#4ECDC4' }
689
  });
690
  selectedPanel.appendChild(b);
691
  selectBubble(b);
692
  saveLocal();
693
  }
694
+
695
+ function deleteBubble() {
696
+ if(!selectedBubble) return alert("Select a bubble first");
697
+ if(confirm("Delete this bubble?")) {
698
+ selectedBubble.remove();
699
+ selectedBubble = null;
700
+ saveLocal();
701
+ }
702
+ }
703
+
704
+ function changeBubbleType(type) {
705
+ if(!selectedBubble) return;
706
+ applyBubbleType(selectedBubble, type);
707
+ selectedBubble.classList.add('selected');
708
+ document.getElementById('tail-controls').style.display = (type === 'speech' || type === 'thought') ? 'block' : 'none';
709
+ saveLocal();
710
+ }
711
+
712
+ function changeFont(font) {
713
+ if(!selectedBubble) return;
714
+ selectedBubble.style.fontFamily = font;
715
+ saveLocal();
716
+ }
717
+
718
  function rotateTail() {
719
  if(!selectedBubble) return;
720
+ const type = selectedBubble.dataset.type;
721
+
722
+ if(type === 'speech') {
723
+ const positions = ['tail-bottom', 'tail-right', 'tail-top', 'tail-left'];
724
+ let current = 0;
725
+ positions.forEach((pos, i) => { if(selectedBubble.classList.contains(pos)) current = i; });
726
+ selectedBubble.classList.remove(positions[current]);
727
+ selectedBubble.classList.add(positions[(current + 1) % 4]);
728
+ } else if(type === 'thought') {
729
+ const isFlippedH = selectedBubble.classList.contains('flipped');
730
+ const isFlippedV = selectedBubble.classList.contains('flipped-vertical');
731
+ if (!isFlippedH && !isFlippedV) selectedBubble.classList.add('flipped');
732
+ else if (isFlippedH && !isFlippedV) selectedBubble.classList.add('flipped-vertical');
733
+ else if (isFlippedH && isFlippedV) selectedBubble.classList.remove('flipped');
734
+ else selectedBubble.classList.remove('flipped-vertical');
735
+ }
736
  saveLocal();
737
  }
738
+
739
+ function slideTail(v) {
740
+ if(selectedBubble) {
741
+ selectedBubble.style.setProperty('--tail-pos', v+'%');
742
+ saveLocal();
743
+ }
744
+ }
745
+
746
+ // Color handlers
747
  document.getElementById('bubble-text-color').addEventListener('input', (e) => {
748
+ if(selectedBubble) {
749
+ selectedBubble.style.setProperty('--bubble-text-color', e.target.value);
750
+ saveLocal();
751
+ }
752
  });
753
  document.getElementById('bubble-fill-color').addEventListener('input', (e) => {
 
 
 
754
  if(selectedBubble) {
755
+ selectedBubble.style.setProperty('--bubble-fill-color', e.target.value);
 
 
756
  saveLocal();
757
  }
758
  });
759
+
760
  // --- PAN & ZOOM ---
761
  function handleZoom(el) {
762
  if(!selectedPanel) return;
763
  const img = selectedPanel.querySelector('img');
764
  img.dataset.zoom = el.value;
765
+ updateImageTransform(img);
766
+ saveLocal();
767
  }
768
+
769
  function startPan(e, img) {
770
+ if(parseFloat(img.dataset.zoom || 100) <= 100) return;
771
+ e.preventDefault();
772
+ isPanning = true;
773
+ selectedPanel = img.closest('.panel');
774
+ panStartX = e.clientX;
775
+ panStartY = e.clientY;
776
+ panStartTx = parseFloat(img.dataset.translateX || 0);
777
+ panStartTy = parseFloat(img.dataset.translateY || 0);
778
+ img.classList.add('panning');
779
  }
780
+
781
+ function panImage(e) {
782
+ if(!isPanning || !selectedPanel) return;
783
+ const img = selectedPanel.querySelector('img');
784
+ img.dataset.translateX = panStartTx + (e.clientX - panStartX);
785
+ img.dataset.translateY = panStartTy + (e.clientY - panStartY);
786
+ updateImageTransform(img);
787
+ }
788
+
789
+ function updateImageTransform(img) {
790
  const z = (img.dataset.zoom || 100) / 100;
791
+ const x = img.dataset.translateX || 0;
792
+ const y = img.dataset.translateY || 0;
793
+ img.style.transform = `translateX(${x}px) translateY(${y}px) scale(${z})`;
794
+ img.classList.toggle('pannable', z > 1);
795
  }
796
+
797
  function resetPanelTransform() {
798
+ if(!selectedPanel) return alert("Select a panel first");
799
  const img = selectedPanel.querySelector('img');
800
+ img.dataset.zoom = 100;
801
+ img.dataset.translateX = 0;
802
+ img.dataset.translateY = 0;
803
  document.getElementById('zoom-slider').value = 100;
804
+ updateImageTransform(img);
805
  saveLocal();
806
  }
807
+
808
  // --- BACKEND ACTIONS ---
809
  function replacePanelImage() {
810
+ if(!selectedPanel) return alert("Select a panel first");
811
  const inp = document.getElementById('image-uploader');
812
  inp.onchange = async (e) => {
813
+ const fd = new FormData();
814
+ fd.append('image', e.target.files[0]);
815
+ const img = selectedPanel.querySelector('img');
816
+ img.style.opacity = '0.5';
817
  const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd});
818
  const d = await r.json();
819
+ if(d.success) {
820
+ img.src = `/frames/${d.new_filename}?sid=${sid}`;
821
+ resetPanelTransform();
822
+ saveLocal();
823
+ } else {
824
+ alert('Error: ' + d.error);
825
+ }
826
+ img.style.opacity = '1';
827
  inp.value = '';
828
  };
829
  inp.click();
830
  }
831
 
832
  async function adjustFrame(dir) {
833
+ if(!selectedPanel) return alert("Select a panel first");
834
  const img = selectedPanel.querySelector('img');
835
  let fname = img.src.split('/').pop().split('?')[0];
836
+ img.style.opacity = '0.5';
837
+ const r = await fetch(`/regenerate_frame?sid=${sid}`, {
838
  method:'POST', headers:{'Content-Type':'application/json'},
839
  body:JSON.stringify({filename:fname, direction:dir})
840
  });
841
+ const d = await r.json();
842
+ if(d.success) {
843
+ img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
844
+ } else {
845
+ alert('Error: ' + d.message);
846
+ }
847
+ img.style.opacity = '1';
848
  saveLocal();
849
  }
850
 
851
  async function gotoTimestamp() {
852
+ if(!selectedPanel) return alert("Select a panel first");
853
+ let v = document.getElementById('timestamp-input').value.trim();
854
+ if(!v) return;
855
+
856
+ if(v.includes(':')) {
857
+ let p = v.split(':');
858
+ v = parseInt(p[0]) * 60 + parseFloat(p[1]);
859
+ } else {
860
+ v = parseFloat(v);
861
+ }
862
+
863
+ if(isNaN(v)) return alert("Invalid time format");
864
+
865
  const img = selectedPanel.querySelector('img');
866
  let fname = img.src.split('/').pop().split('?')[0];
867
+ img.style.opacity = '0.5';
868
+ const r = await fetch(`/goto_timestamp?sid=${sid}`, {
869
  method:'POST', headers:{'Content-Type':'application/json'},
870
  body:JSON.stringify({filename:fname, timestamp:v})
871
  });
872
+ const d = await r.json();
873
+ if(d.success) {
874
+ img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
875
+ document.getElementById('timestamp-input').value = '';
876
+ resetPanelTransform();
877
+ } else {
878
+ alert('Error: ' + d.message);
879
+ }
880
+ img.style.opacity = '1';
881
  saveLocal();
882
  }
883
+
884
  // --- EXPORT ---
885
  async function exportComic() {
886
  const pgs = document.querySelectorAll('.comic-page');
887
+ if(pgs.length === 0) return alert("No pages found");
888
+
889
+ // Freeze dimensions
890
+ const bubbles = document.querySelectorAll('.speech-bubble');
891
+ bubbles.forEach(b => {
892
+ const rect = b.getBoundingClientRect();
893
+ b.style.width = rect.width + 'px';
894
+ b.style.height = rect.height + 'px';
895
+ });
896
+
897
+ alert(`Exporting ${pgs.length} page(s)...`);
898
+ for(let i = 0; i < pgs.length; i++) {
899
+ try {
900
+ const u = await htmlToImage.toPng(pgs[i], {pixelRatio: 3});
901
+ const a = document.createElement('a');
902
+ a.href = u;
903
+ a.download = `Comic-Page-${i+1}.png`;
904
+ a.click();
905
+ } catch(err) {
906
+ console.error(err);
907
+ alert(`Failed to export page ${i+1}`);
908
+ }
909
  }
910
+
911
+ // Unfreeze
912
+ bubbles.forEach(b => {
913
+ b.style.width = '';
914
+ b.style.height = '';
915
+ });
916
  }
917
+
918
+ // --- SAVE & RESET ---
919
  function saveLocal() {
920
  const pages = [];
921
  document.querySelectorAll('.comic-page').forEach(p => {
 
924
  const img = pan.querySelector('img');
925
  const bubbles = [];
926
  pan.querySelectorAll('.speech-bubble').forEach(b => {
927
+ const textEl = b.querySelector('.bubble-text');
928
  bubbles.push({
929
+ text: textEl ? textEl.textContent : '',
930
+ left: b.style.left,
931
+ top: b.style.top,
932
+ width: b.style.width,
933
+ height: b.style.height,
934
  classes: b.className,
935
+ type: b.dataset.type,
936
+ font: b.style.fontFamily,
937
  tailPos: b.style.getPropertyValue('--tail-pos'),
938
  colors: {
939
  fill: b.style.getPropertyValue('--bubble-fill-color'),
 
943
  });
944
  panels.push({
945
  src: img.src,
946
+ zoom: img.dataset.zoom,
947
+ tx: img.dataset.translateX,
948
+ ty: img.dataset.translateY,
949
  bubbles: bubbles
950
  });
951
  });
952
  pages.push({ panels: panels });
953
  });
954
+ localStorage.setItem('comic_autosave_'+sid, JSON.stringify(pages));
955
+ }
956
+
957
+ function clearAndReset() {
958
+ if(confirm("Clear all edits and reset?")) {
959
+ localStorage.removeItem('comic_autosave_'+sid);
960
+ location.reload();
961
+ }
962
+ }
963
+
964
+ // Helper
965
+ function rgbToHex(rgb) {
966
+ if (!rgb || !rgb.startsWith('rgb')) return '#ffffff';
967
+ let sep = rgb.indexOf(",") > -1 ? "," : " ";
968
+ rgb = rgb.substr(4).split(")")[0].split(sep);
969
+ let r = (+rgb[0]).toString(16), g = (+rgb[1]).toString(16), b = (+rgb[2]).toString(16);
970
+ if (r.length == 1) r = "0" + r;
971
+ if (g.length == 1) g = "0" + g;
972
+ if (b.length == 1) b = "0" + b;
973
+ return "#" + r + g + b;
974
  }
975
  </script>
976
  </body>
977
  </html>
978
  '''
979
 
980
+ # --- 3. ENHANCED COMIC GENERATOR CLASS ---
981
  class EnhancedComicGenerator:
982
  def __init__(self, sid):
983
  self.sid = sid
 
986
  self.frames_dir = os.path.join(self.user_dir, 'frames')
987
  self.output_dir = os.path.join(self.user_dir, 'output')
988
  self.status_file = os.path.join(self.output_dir, 'status.json')
989
+ self.metadata_path = os.path.join(self.frames_dir, 'frame_metadata.json')
990
  os.makedirs(self.frames_dir, exist_ok=True)
991
  os.makedirs(self.output_dir, exist_ok=True)
992
  self.video_fps = None
993
  self.frame_metadata = {}
994
+ self.apply_comic_style = False
995
 
996
  def update_status(self, message, progress):
997
  try:
998
+ with open(self.status_file, 'w') as f:
999
+ json.dump({'message': message, 'progress': progress}, f)
1000
+ except:
1001
+ pass
1002
 
1003
  def cleanup_previous_run(self):
1004
+ print(f"🧹 Cleaning up for session {self.sid}...")
1005
  if os.path.exists(self.frames_dir):
1006
  for f in os.listdir(self.frames_dir):
1007
+ try:
1008
+ os.remove(os.path.join(self.frames_dir, f))
1009
+ except:
1010
+ pass
1011
  if os.path.exists(self.output_dir):
1012
  for f in os.listdir(self.output_dir):
1013
  if f != 'status.json':
1014
+ try:
1015
+ os.remove(os.path.join(self.output_dir, f))
1016
+ except:
1017
+ pass
1018
+ # Also clean temp srt
1019
+ user_srt = os.path.join(self.user_dir, 'subs.srt')
1020
+ if os.path.exists(user_srt):
1021
+ os.remove(user_srt)
1022
+ print("✅ Cleanup complete.")
1023
+
1024
+ def generate_keyframes_from_moments(self, key_moments, max_frames=48):
1025
+ """Extract keyframes based on subtitle moments"""
1026
+ try:
1027
+ cap = cv2.VideoCapture(self.video_path)
1028
+ if not cap.isOpened():
1029
+ raise Exception("Cannot open video for keyframe extraction")
1030
+
1031
+ fps = self.video_fps
1032
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
1033
+ duration = total_frames / fps
1034
+
1035
+ key_moments.sort(key=lambda x: x['start'])
1036
+ frame_metadata = {}
1037
+ frame_count = 0
1038
+
1039
+ for i, moment in enumerate(key_moments[:max_frames]):
1040
+ self.update_status(f"Extracting frame {i+1}/{min(len(key_moments), max_frames)}...",
1041
+ 25 + int(20 * (i / min(len(key_moments), max_frames))))
1042
+
1043
+ frame_time = (moment['start'] + moment['end']) / 2
1044
+ if frame_time > duration:
1045
+ continue
1046
+
1047
+ frame_number = int(frame_time * fps)
1048
+ cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
1049
+ ret, frame = cap.read()
1050
+
1051
+ if ret:
1052
+ frame_filename = f"frame_{frame_count:04d}.png"
1053
+ frame_path = os.path.join(self.frames_dir, frame_filename)
1054
+ cv2.imwrite(frame_path, frame)
1055
+ frame_metadata[frame_filename] = {
1056
+ 'time': frame_time,
1057
+ 'dialogue': moment['text'],
1058
+ 'start': moment['start'],
1059
+ 'end': moment['end']
1060
+ }
1061
+ frame_count += 1
1062
+
1063
+ cap.release()
1064
+
1065
+ with open(self.metadata_path, 'w') as f:
1066
+ json.dump(frame_metadata, f, indent=2)
1067
+
1068
+ print(f"✅ Extracted {frame_count} keyframes from video")
1069
+ return True
1070
+ except Exception as e:
1071
+ print(f"❌ Error extracting keyframes: {e}")
1072
+ traceback.print_exc()
1073
+ return False
1074
+
1075
+ def _enhance_all_images(self, single_image_path=None):
1076
+ """Apply simple color enhancement"""
1077
+ try:
1078
+ enhancer = SimpleColorEnhancer()
1079
+ if single_image_path:
1080
+ enhancer.enhance_single(single_image_path)
1081
+ else:
1082
+ frame_paths = [os.path.join(self.frames_dir, f)
1083
+ for f in os.listdir(self.frames_dir) if f.endswith('.png')]
1084
+ with ThreadPoolExecutor() as executor:
1085
+ list(executor.map(enhancer.enhance_single, frame_paths))
1086
+ print("✅ Simple color enhancement complete")
1087
+ except Exception as e:
1088
+ print(f"⚠️ Simple enhancement failed: {e}")
1089
+
1090
+ def _enhance_quality_colors(self, single_image_path=None):
1091
+ """Apply quality color enhancement"""
1092
+ try:
1093
+ enhancer = QualityColorEnhancer()
1094
+ if single_image_path:
1095
+ enhancer.enhance_single(single_image_path)
1096
+ else:
1097
+ frame_paths = [os.path.join(self.frames_dir, f)
1098
+ for f in os.listdir(self.frames_dir) if f.endswith('.png')]
1099
+ with ThreadPoolExecutor() as executor:
1100
+ list(executor.map(enhancer.enhance_single, frame_paths))
1101
+ print("✅ Quality color enhancement complete")
1102
+ except Exception as e:
1103
+ print(f"⚠️ Quality enhancement failed: {e}")
1104
+
1105
+ def _process_bubble_for_frame(self, frame_file):
1106
+ """Process AI bubble placement for a single frame"""
1107
+ frame_path = os.path.join(self.frames_dir, frame_file)
1108
+ meta = self.frame_metadata.get(frame_file, {})
1109
+ dialogue = meta.get('dialogue', '') if isinstance(meta, dict) else ''
1110
+
1111
+ try:
1112
+ faces = face_detector.detect_faces(frame_path)
1113
+ if faces:
1114
+ lip_x, lip_y = face_detector.get_lip_position(frame_path, faces[0])
1115
+ else:
1116
+ lip_x, lip_y = -1, -1
1117
+ bubble_x, bubble_y = ai_bubble_placer.place_bubble_ai(frame_path, (lip_x, lip_y))
1118
+ return bubble(
1119
+ bubble_offset_x=bubble_x,
1120
+ bubble_offset_y=bubble_y,
1121
+ lip_x=lip_x,
1122
+ lip_y=lip_y,
1123
+ dialog=dialogue,
1124
+ emotion='normal'
1125
+ )
1126
+ except Exception as e:
1127
+ print(f"-> Could not place bubble for {frame_file}: {e}. Using default.")
1128
+ return bubble(
1129
+ bubble_offset_x=50,
1130
+ bubble_offset_y=20,
1131
+ lip_x=-1,
1132
+ lip_y=-1,
1133
+ dialog=dialogue,
1134
+ emotion='normal'
1135
+ )
1136
+
1137
+ def _create_ai_bubbles_from_moments(self):
1138
+ """Create AI-placed bubbles for all frames"""
1139
+ frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
1140
+
1141
+ if not os.path.exists(self.metadata_path):
1142
+ return [bubble(dialog="") for _ in frame_files]
1143
+
1144
+ with open(self.metadata_path, 'r') as f:
1145
+ self.frame_metadata = json.load(f)
1146
+
1147
+ with ThreadPoolExecutor() as executor:
1148
+ bubbles = list(executor.map(self._process_bubble_for_frame, frame_files))
1149
+
1150
+ return bubbles
1151
+
1152
+ def _generate_pages(self, bubbles_list):
1153
+ """Generate comic pages from frames and bubbles"""
1154
+ try:
1155
+ from backend.fixed_12_pages_800x1080 import generate_12_pages_800x1080
1156
+ frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
1157
+ return generate_12_pages_800x1080(frame_files, bubbles_list)
1158
+ except ImportError:
1159
+ # Fallback: simple 4-panel pages
1160
+ pages = []
1161
+ frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
1162
+ num_pages = (len(frame_files) + 3) // 4
1163
+
1164
+ for i in range(num_pages):
1165
+ start, end = i * 4, (i + 1) * 4
1166
+ page_panels = [panel(image=f) for f in frame_files[start:end]]
1167
+ page_bubbles = bubbles_list[start:end]
1168
+ if page_panels:
1169
+ pages.append(Page(panels=page_panels, bubbles=page_bubbles))
1170
+
1171
+ return pages
1172
 
1173
  def generate_comic(self):
1174
+ """Main comic generation pipeline"""
1175
+ start_time = time.time()
1176
  try:
1177
+ if cv2 is None:
1178
+ raise Exception("OpenCV not installed")
1179
+
1180
+ self.update_status("Cleaning up previous run...", 0)
1181
+ self.cleanup_previous_run()
1182
+
1183
+ self.update_status("Analyzing video...", 5)
1184
  cap = cv2.VideoCapture(self.video_path)
1185
+ if not cap.isOpened():
1186
+ raise Exception("Cannot open video")
1187
  self.video_fps = cap.get(cv2.CAP_PROP_FPS) or 25
1188
  cap.release()
1189
+ print(f"✅ Video FPS detected: {self.video_fps:.2f}")
1190
+
1191
+ self.update_status("Generating subtitles (this may take a while)...", 10)
1192
  user_srt = os.path.join(self.user_dir, 'subs.srt')
1193
  try:
1194
  get_real_subtitles(self.video_path)
1195
+ if os.path.exists('test1.srt'):
1196
+ shutil.move('test1.srt', user_srt)
1197
+ except Exception as e:
1198
+ print(f"⚠️ Subtitle generation failed: {e}. Creating fallback.")
1199
+ with open(user_srt, 'w') as f:
1200
+ f.write("1\n00:00:01,000 --> 00:00:04,000\nHello\n")
1201
 
1202
+ self.update_status("Parsing subtitles...", 20)
1203
+ with open(user_srt, 'r', encoding='utf-8') as f:
1204
+ all_subs = list(srt.parse(f.read()))
1205
 
1206
+ key_moments = [{
1207
+ 'index': s.index,
1208
+ 'text': s.content,
1209
+ 'start': s.start.total_seconds(),
1210
+ 'end': s.end.total_seconds()
1211
+ } for s in all_subs]
 
 
 
 
 
 
 
 
 
1212
 
1213
+ self.update_status("Extracting keyframes...", 25)
1214
+ if not self.generate_keyframes_from_moments(key_moments, max_frames=48):
1215
+ raise Exception("Keyframe extraction failed")
1216
+
1217
+ self.update_status("Cropping black bars...", 45)
1218
+ try:
1219
+ black_x, black_y, _, _ = black_bar_crop()
1220
+ except:
1221
+ black_x, black_y = 0, 0
1222
+
1223
+ self.update_status("Enhancing images...", 50)
1224
+ self._enhance_all_images()
1225
+
1226
+ self.update_status("Applying quality color enhancement...", 60)
1227
+ self._enhance_quality_colors()
1228
+
1229
+ self.update_status("Placing speech bubbles...", 75)
1230
+ bubbles = self._create_ai_bubbles_from_moments()
1231
+
1232
+ self.update_status("Assembling comic pages...", 90)
1233
+ pages = self._generate_pages(bubbles)
1234
+
1235
+ self.update_status("Saving results...", 95)
1236
+ self._save_results(pages)
1237
+
1238
+ execution_time = (time.time() - start_time) / 60
1239
+ print(f"✅ Comic generation completed in {execution_time:.2f} minutes")
1240
+ self.update_status("Complete!", 100)
1241
+ return True
1242
 
 
1243
  except Exception as e:
1244
+ print(f"❌ Comic generation failed: {e}")
1245
  traceback.print_exc()
1246
  self.update_status(f"Error: {str(e)}", -1)
1247
+ return False
1248
+
1249
+ def _save_results(self, pages):
1250
+ """Save comic pages data to JSON"""
1251
+ try:
1252
+ pages_data = []
1253
+ for page in pages:
1254
+ panels = [p.__dict__ if hasattr(p, '__dict__') else p for p in page.panels]
1255
+ bubbles_data = [b.__dict__ if hasattr(b, '__dict__') else b for b in page.bubbles]
1256
+ pages_data.append({'panels': panels, 'bubbles': bubbles_data})
1257
+
1258
+ with open(os.path.join(self.output_dir, 'pages.json'), 'w', encoding='utf-8') as f:
1259
+ json.dump(pages_data, f, indent=2)
1260
+
1261
+ print("✅ Results saved successfully!")
1262
+ except Exception as e:
1263
+ print(f"❌ Save results failed: {e}")
1264
 
1265
  def regenerate_frame(self, fname, direction):
1266
+ """Regenerate a frame by stepping forward/backward"""
1267
  try:
1268
+ if not os.path.exists(self.metadata_path):
1269
+ return {"success": False, "message": "Frame metadata missing."}
1270
+
1271
+ with open(self.metadata_path, 'r') as f:
1272
+ meta = json.load(f)
1273
 
1274
+ if fname not in meta:
1275
+ return {"success": False, "message": "Panel not linked to video."}
1276
+
1277
+ # Get current time
1278
+ current_data = meta[fname]
1279
+ if isinstance(current_data, dict):
1280
+ curr_time = current_data['time']
1281
+ else:
1282
+ curr_time = current_data
1283
+
1284
+ # Get video FPS
1285
  if not self.video_fps:
1286
+ cap = cv2.VideoCapture(self.video_path)
1287
+ self.video_fps = cap.get(cv2.CAP_PROP_FPS) or 25
1288
+ cap.release()
1289
 
1290
+ # Calculate new time
1291
+ offset = (1.0 / self.video_fps) * (1 if direction == 'forward' else -1)
1292
  new_time = max(0, curr_time + offset)
1293
 
1294
+ # Extract new frame
1295
  cap = cv2.VideoCapture(self.video_path)
1296
+ cap.set(cv2.CAP_PROP_POS_MSEC, new_time * 1000)
1297
  ret, frame = cap.read()
1298
  cap.release()
1299
 
1300
  if ret:
1301
+ frame_path = os.path.join(self.frames_dir, fname)
1302
+ cv2.imwrite(frame_path, frame)
1303
+
1304
+ # Apply enhancements
1305
+ print(f"🎨 Applying enhancements to new frame: {fname}")
1306
+ self._enhance_all_images(single_image_path=frame_path)
1307
+ self._enhance_quality_colors(single_image_path=frame_path)
1308
+
1309
+ # Update metadata
1310
+ if isinstance(meta[fname], dict):
1311
+ meta[fname]['time'] = new_time
1312
+ else:
1313
+ meta[fname] = new_time
1314
+ with open(self.metadata_path, 'w') as f:
1315
+ json.dump(meta, f, indent=2)
1316
+
1317
+ message = f"Adjusted {direction} to {new_time:.3f}s"
1318
+ print(f"✅ {message}")
1319
+ return {"success": True, "message": message}
1320
+
1321
+ return {"success": False, "message": "End of video"}
1322
+
1323
+ except Exception as e:
1324
+ traceback.print_exc()
1325
+ return {"success": False, "message": str(e)}
1326
 
1327
  def get_frame_at_timestamp(self, fname, ts):
1328
+ """Get a specific frame at a given timestamp"""
1329
  try:
1330
  cap = cv2.VideoCapture(self.video_path)
1331
+ if not cap.isOpened():
1332
+ return {"success": False, "message": "Cannot open video."}
1333
+
1334
+ fps = cap.get(cv2.CAP_PROP_FPS) or 25
1335
+ duration = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) / fps
1336
+
1337
+ if ts < 0 or ts > duration:
1338
+ cap.release()
1339
+ return {"success": False, "message": f"Timestamp must be between 0 and {duration:.2f}s."}
1340
+
1341
+ cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
1342
  ret, frame = cap.read()
1343
  cap.release()
1344
+
1345
  if ret:
1346
+ frame_path = os.path.join(self.frames_dir, fname)
1347
+ cv2.imwrite(frame_path, frame)
1348
+
1349
+ # Apply enhancements
1350
+ print(f"🎨 Applying enhancements to frame from timestamp: {fname}")
1351
+ self._enhance_all_images(single_image_path=frame_path)
1352
+ self._enhance_quality_colors(single_image_path=frame_path)
1353
+
1354
+ # Update metadata
1355
+ if os.path.exists(self.metadata_path):
1356
+ with open(self.metadata_path, 'r') as f:
1357
+ meta = json.load(f)
1358
+ if fname in meta:
1359
+ if isinstance(meta[fname], dict):
1360
+ meta[fname]['time'] = float(ts)
1361
+ else:
1362
+ meta[fname] = float(ts)
1363
+ with open(self.metadata_path, 'w') as f:
1364
+ json.dump(meta, f, indent=2)
1365
+
1366
+ message = f"Jumped to timestamp {ts:.3f}s"
1367
+ print(f"✅ {message}")
1368
+ return {"success": True, "message": message}
1369
+
1370
+ return {"success": False, "message": "Invalid time"}
1371
+
1372
+ except Exception as e:
1373
+ traceback.print_exc()
1374
+ return {"success": False, "message": str(e)}
1375
+
1376
 
1377
  # --- ROUTES ---
1378
  @app.route('/')
1379
+ def index():
1380
+ return INDEX_HTML
1381
 
1382
  @app.route('/uploader', methods=['POST'])
1383
  def upload():
1384
  sid = request.args.get('sid')
1385
+ if not sid:
1386
+ return jsonify({'success': False, 'message': 'Missing session ID'}), 400
1387
+
1388
+ if 'file' not in request.files or not request.files['file'].filename:
1389
+ return jsonify({'success': False, 'message': 'No file selected'}), 400
1390
+
1391
  f = request.files['file']
1392
  gen = EnhancedComicGenerator(sid)
1393
  gen.cleanup_previous_run()
1394
  f.save(gen.video_path)
1395
  gen.update_status("Starting...", 5)
1396
+
1397
  threading.Thread(target=gen.generate_comic).start()
1398
+ return jsonify({'success': True, 'message': 'Generation started.'})
1399
 
1400
  @app.route('/status')
1401
  def get_status():
1402
  sid = request.args.get('sid')
1403
+ if not sid:
1404
+ return jsonify({'progress': 0, 'message': 'Missing session ID'})
1405
+
1406
  path = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
1407
+ if os.path.exists(path):
1408
+ return send_file(path)
1409
  return jsonify({'progress': 0, 'message': "Waiting..."})
1410
 
1411
  @app.route('/output/<path:filename>')
1412
  def get_output(filename):
1413
+ sid = request.args.get('sid')
1414
+ if not sid:
1415
+ return "Missing session ID", 400
1416
+ return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
1417
 
1418
  @app.route('/frames/<path:filename>')
1419
  def get_frame(filename):
1420
+ sid = request.args.get('sid')
1421
+ if not sid:
1422
+ return "Missing session ID", 400
1423
+ return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
1424
 
1425
  @app.route('/regenerate_frame', methods=['POST'])
1426
  def regen():
1427
+ sid = request.args.get('sid')
1428
+ if not sid:
1429
+ return jsonify({'success': False, 'message': 'Missing session ID'})
1430
+
1431
  d = request.get_json()
1432
+ gen = EnhancedComicGenerator(sid)
1433
+ return jsonify(gen.regenerate_frame(d['filename'], d['direction']))
1434
 
1435
  @app.route('/goto_timestamp', methods=['POST'])
1436
  def go_time():
1437
+ sid = request.args.get('sid')
1438
+ if not sid:
1439
+ return jsonify({'success': False, 'message': 'Missing session ID'})
1440
+
1441
  d = request.get_json()
1442
+ gen = EnhancedComicGenerator(sid)
1443
+ return jsonify(gen.get_frame_at_timestamp(d['filename'], float(d['timestamp'])))
1444
 
1445
  @app.route('/replace_panel', methods=['POST'])
1446
  def rep_panel():
1447
  sid = request.args.get('sid')
1448
+ if not sid:
1449
+ return jsonify({'success': False, 'error': 'Missing session ID'})
1450
+
1451
+ if 'image' not in request.files:
1452
+ return jsonify({'success': False, 'error': 'No image provided.'})
1453
+
1454
  f = request.files['image']
1455
+ fname = f"replaced_{int(time.time() * 1000)}.png"
1456
  f.save(os.path.join(BASE_USER_DIR, sid, 'frames', fname))
1457
+ return jsonify({'success': True, 'new_filename': fname})
1458
+
1459
 
1460
  if __name__ == '__main__':
1461
  os.makedirs(BASE_USER_DIR, exist_ok=True)
1462
+ port = int(os.getenv("PORT", 7860))
1463
+ print(f"🚀 Starting Enhanced Comic Generator on host 0.0.0.0, port {port}")
1464
+ app.run(host='0.0.0.0', port=port, debug=False)