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

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +377 -469
app_enhanced.py CHANGED
@@ -9,75 +9,54 @@ import logging
9
  from concurrent.futures import ThreadPoolExecutor
10
  from flask import Flask, render_template, request, jsonify, send_from_directory, send_file
11
 
12
- # --- 0. LOGGING & 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,505 +67,455 @@ INDEX_HTML = '''
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);
495
- let initTy = parseFloat(img.dataset.ty || 0);
496
-
497
- function onMove(ev) {
498
- img.dataset.tx = initTx + (ev.clientX - startX);
499
- img.dataset.ty = initTy + (ev.clientY - startY);
500
- applyTransform(img);
501
- }
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>
@@ -639,15 +568,14 @@ class EnhancedComicGenerator:
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)
653
  ret, frame = cap.read()
@@ -655,16 +583,12 @@ class EnhancedComicGenerator:
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,7 +598,6 @@ class EnhancedComicGenerator:
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()
@@ -684,42 +607,32 @@ class EnhancedComicGenerator:
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,7 +642,7 @@ def index(): return INDEX_HTML
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()
@@ -741,38 +654,33 @@ def upload():
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)
 
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
  <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
  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
580
  cap.set(cv2.CAP_PROP_POS_MSEC, mid*1000)
581
  ret, frame = cap.read()
 
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
  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
  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
  @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
  @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)