tester343 commited on
Commit
af06759
·
verified ·
1 Parent(s): 3097bc9

Update app_enhanced.py

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