tester343 commited on
Commit
6853c41
·
verified ·
1 Parent(s): 4d2724c

Update app_enhanced.py

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