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

Update app_enhanced.py

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