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

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +311 -244
app_enhanced.py CHANGED
@@ -9,7 +9,7 @@ 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
 
@@ -21,12 +21,13 @@ try:
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
 
@@ -52,8 +53,17 @@ except Exception:
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
@@ -77,7 +87,7 @@ except Exception:
77
  app = Flask(__name__)
78
  BASE_USER_DIR = "userdata"
79
 
80
- # --- HTML INTERFACE ---
81
  INDEX_HTML = '''
82
  <!DOCTYPE html>
83
  <html lang="en">
@@ -88,35 +98,41 @@ 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;
@@ -124,15 +140,15 @@ INDEX_HTML = '''
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
 
@@ -140,84 +156,108 @@ INDEX_HTML = '''
140
  color: var(--bubble-text-color, #fff);
141
  padding: 1em;
142
  position: absolute;
 
 
143
  border-radius: var(--r) var(--r) min(var(--r), calc(100% - var(--p) - (1 - var(--t)) * var(--b) / 2)) min(var(--r), calc(var(--p) - (1 - var(--t)) * var(--b) / 2)) / var(--r);
144
  }
145
 
146
- /* Tail using Radial Gradient (Export Safe) */
147
  .speech-bubble.speech:before {
148
  content: ""; position: absolute; width: var(--b); height: var(--h);
 
149
  background: radial-gradient(100% 100% at 100% 0, transparent calc(var(--t) * 100% - 1px), var(--c) calc(var(--t) * 100%));
150
  border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
151
  }
152
 
153
- /* Rotation Logic */
 
154
  .speech-bubble.speech.tail-bottom:before { top: 99%; left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); }
155
 
156
- .speech-bubble.speech.tail-top { border-radius: min(var(--r), calc(var(--p) - (1 - var(--t)) * var(--b) / 2)) min(var(--r), calc(100% - var(--p) - (1 - var(--t)) * var(--b) / 2)) var(--r) var(--r) / var(--r); }
157
- .speech-bubble.speech.tail-top:before { bottom: 99%; left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); transform: scaleY(-1); }
 
 
 
 
 
 
158
 
 
159
  .speech-bubble.speech.tail-left { border-radius: var(--r); }
160
- .speech-bubble.speech.tail-left:before { right: 99%; top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); transform: rotate(90deg); transform-origin: top right; }
 
 
 
161
 
 
162
  .speech-bubble.speech.tail-right { border-radius: var(--r); }
163
- .speech-bubble.speech.tail-right:before { left: 99%; top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); transform: rotate(-90deg); transform-origin: top left; }
 
 
 
164
 
 
165
  .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
166
  .speech-bubble.thought::after { display:none; }
167
  .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
168
  .thought-dot-1 { width: 20px; height: 20px; bottom: -20px; left: 15px; }
169
  .thought-dot-2 { width: 12px; height: 12px; bottom: -32px; left: 5px; }
170
 
 
171
  .resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; }
172
  .speech-bubble.selected .resize-handle { display: block; }
173
  .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
174
 
175
- /* CONTROLS */
176
- .edit-controls { position: fixed; bottom: 20px; right: 20px; width: 220px; background: white; padding: 15px; border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 100; }
 
 
 
 
177
  .edit-controls h4 { margin: 0 0 10px 0; color: #4ECDC4; text-align: center; }
178
- .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
179
- button, input, select { width: 100%; margin-top: 5px; padding: 5px; }
 
180
  .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
181
- .slider-container { margin-top: 10px; }
182
  </style>
183
  </head>
184
  <body>
185
- <!-- UPLOAD SCREEN -->
 
186
  <div id="upload-container">
187
  <div class="upload-box">
188
  <h1>🎬 Comic Generator</h1>
189
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
190
  <label for="file-upload" class="file-label">Choose Video</label>
191
- <span id="fn">No file selected</span>
192
  <button class="submit-btn" onclick="upload()">Generate Comic</button>
193
  <button id="restore-btn" class="restore-btn" onclick="restoreSession()">📂 Restore Unsaved Session</button>
194
 
195
- <div class="loading-view" id="loading-view" style="display:none;">
196
- <div class="loader"></div>
197
- <p id="status-text">Starting...</p>
198
  </div>
199
  </div>
200
  </div>
201
 
202
- <!-- EDITOR SCREEN -->
203
  <div id="editor-container">
204
- <div class="comic-container-wrapper">
205
- <h1 style="text-align:center;">Comic Editor</h1>
206
- <div id="comic-pages"></div>
207
- </div>
208
 
209
  <!-- Hidden inputs -->
210
  <input type="file" id="image-uploader" style="display: none;" accept="image/*">
211
 
212
  <div class="edit-controls">
213
- <h4>Tools</h4>
214
  <div class="control-group">
215
- <button onclick="addBubble()">+ Bubble</button>
216
- <button onclick="deleteBubble()" style="background:#ffcccc">Delete</button>
217
  </div>
 
218
  <div class="control-group">
219
- <label>Colors:</label>
220
- <div style="display:grid; grid-template-columns: 1fr 1fr; gap:5px;">
221
  <input type="color" id="bubble-text-color" disabled title="Text Color">
222
  <input type="color" id="bubble-fill-color" disabled title="Fill Color">
223
  </div>
@@ -226,47 +266,55 @@ INDEX_HTML = '''
226
  <option value="thought">Thought</option>
227
  </select>
228
  </div>
 
229
  <div class="control-group" id="tail-controls" style="display:none;">
230
- <label>Tail:</label>
231
- <button onclick="rotateTail()">Rotate</button>
 
 
232
  <input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)">
233
  </div>
 
234
  <div class="control-group">
235
- <label>Panel:</label>
236
- <button onclick="replacePanelImage()">🖼️ Replace</button>
 
 
 
 
237
  <div class="button-grid">
238
- <button onclick="adjustFrame('backward')">⬅️</button>
239
- <button onclick="adjustFrame('forward')">➡️</button>
240
  </div>
241
- <input type="text" id="timestamp-input" placeholder="mm:ss">
242
- <button onclick="gotoTimestamp()">Go to Time</button>
243
- <label>Zoom:</label>
244
  <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled oninput="handleZoom(this)">
245
  </div>
 
246
  <hr>
247
- <button onclick="saveLocal()">💾 Force Save</button>
248
- <button onclick="exportComic()" style="background:#ccffcc; font-weight:bold;">📥 Download PNG</button>
249
- <button onclick="location.reload()" style="color:red; margin-top:10px;">↺ Start Over</button>
250
  </div>
251
  </div>
252
 
253
  <script>
254
- // --- SESSION ---
255
  function genUUID(){ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,c=>{var r=Math.random()*16|0,v=c=='x'?r:(r&0x3|0x8);return v.toString(16);}); }
256
  let sid = localStorage.getItem('comic_sid') || genUUID();
257
  localStorage.setItem('comic_sid', sid);
258
- console.log("SID:", sid);
259
 
260
- // Check for existing save on load
261
  if(localStorage.getItem('comic_autosave')) {
262
  document.getElementById('restore-btn').style.display = 'block';
263
  }
264
 
265
  let interval, selectedBubble, selectedPanel;
266
- let isResizing=false, resizeHandle, startX, startY, startW, startH, startL, startT;
267
- let isPanning=false, panStartX, panStartY, panStartTx, panStartTy;
 
268
 
269
- // --- RESTORE LOGIC ---
270
  function restoreSession() {
271
  const savedData = localStorage.getItem('comic_autosave');
272
  if(!savedData) return alert("No saved session found.");
@@ -275,11 +323,11 @@ INDEX_HTML = '''
275
  renderFromState(state);
276
  document.getElementById('upload-container').style.display = 'none';
277
  document.getElementById('editor-container').style.display = 'block';
278
- } catch(e) { alert("Failed to restore"); }
279
  }
280
 
281
  function renderFromState(pagesData) {
282
- const con = document.getElementById('comic-pages');
283
  con.innerHTML = '';
284
  pagesData.forEach((page, i) => {
285
  const div = document.createElement('div');
@@ -293,17 +341,17 @@ INDEX_HTML = '''
293
  pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
294
 
295
  const img = document.createElement('img');
296
- img.src = pan.src;
297
  img.dataset.zoom = pan.zoom || 100;
298
- img.dataset.translateX = pan.tx || 0;
299
- img.dataset.translateY = pan.ty || 0;
300
  updateImageTransform(img);
301
- img.onmousedown = startPan;
302
  pDiv.appendChild(img);
303
 
 
304
  pan.bubbles.forEach(bData => {
305
- const b = createBubble(bData.text, bData.left, bData.top, bData.type, bData.tailPos, bData.width, bData.height, bData.colors);
306
- if(bData.classes) b.className = bData.classes;
307
  pDiv.appendChild(b);
308
  });
309
  grid.appendChild(pDiv);
@@ -313,42 +361,7 @@ INDEX_HTML = '''
313
  });
314
  }
315
 
316
- // --- SAVE LOGIC (Auto-Save) ---
317
- function saveLocal() {
318
- const pages = [];
319
- document.querySelectorAll('.comic-page').forEach(page => {
320
- const panels = [];
321
- page.querySelectorAll('.panel').forEach(pDiv => {
322
- const img = pDiv.querySelector('img');
323
- const bubbles = [];
324
- pDiv.querySelectorAll('.speech-bubble').forEach(b => {
325
- bubbles.push({
326
- text: b.querySelector('.bubble-text').innerText,
327
- left: b.style.left, top: b.style.top,
328
- width: b.style.width, height: b.style.height,
329
- type: b.dataset.type,
330
- tailPos: b.style.getPropertyValue('--tail-pos'),
331
- classes: b.className,
332
- colors: {
333
- text: b.style.getPropertyValue('--bubble-text-color'),
334
- fill: b.style.getPropertyValue('--bubble-fill-color')
335
- }
336
- });
337
- });
338
- panels.push({
339
- src: img.src,
340
- zoom: img.dataset.zoom,
341
- tx: img.dataset.translateX,
342
- ty: img.dataset.translateY,
343
- bubbles: bubbles
344
- });
345
- });
346
- pages.push({ panels: panels });
347
- });
348
- localStorage.setItem('comic_autosave', JSON.stringify(pages));
349
- }
350
-
351
- // --- UPLOAD ---
352
  async function upload() {
353
  const f = document.getElementById('file-upload').files[0];
354
  if(!f) return alert("Select file");
@@ -361,15 +374,17 @@ INDEX_HTML = '''
361
  }
362
 
363
  async function checkStatus() {
364
- const r = await fetch(`/status?sid=${sid}`);
365
- const d = await r.json();
366
- document.getElementById('status-text').innerText = d.message;
367
- if(d.progress >= 100) {
368
- clearInterval(interval);
369
- document.getElementById('upload-container').style.display='none';
370
- document.getElementById('editor-container').style.display='block';
371
- loadNewComic();
372
- }
 
 
373
  }
374
 
375
  function loadNewComic() {
@@ -382,7 +397,8 @@ INDEX_HTML = '''
382
  left: (p.bubbles[j].bubble_offset_x||50)+'px',
383
  top: (p.bubbles[j].bubble_offset_y||20)+'px',
384
  type: 'speech',
385
- tailPos: '50%'
 
386
  }] : []
387
  }))
388
  }));
@@ -391,77 +407,117 @@ INDEX_HTML = '''
391
  });
392
  }
393
 
394
- // --- INTERACTIVITY ---
395
- function createBubble(text, x, y, type='speech', tailPos='50%', w, h, colors) {
396
  const b = document.createElement('div');
397
- b.className = 'speech-bubble speech tail-bottom';
398
- b.style.left = x; b.style.top = y;
399
- if(w) b.style.width = w; if(h) b.style.height = h;
400
- b.dataset.type = type;
401
- b.style.setProperty('--tail-pos', tailPos || '50%');
402
- if(colors) {
403
- if(colors.text) b.style.setProperty('--bubble-text-color', colors.text);
404
- if(colors.fill) b.style.setProperty('--bubble-fill-color', colors.fill);
405
- }
406
- b.innerHTML = `<span class="bubble-text">${text}</span><div class="resize-handle se"></div>`;
407
 
 
 
 
 
 
 
 
 
 
 
 
408
  b.onmousedown = (e) => {
409
  if(e.target.classList.contains('resize-handle')) return;
410
- e.stopPropagation(); selectedBubble = b;
411
- document.querySelectorAll('.speech-bubble').forEach(el=>el.classList.remove('selected'));
412
- b.classList.add('selected');
413
- selectBubbleUI(b);
414
- const ox = e.clientX - b.offsetLeft, oy = e.clientY - b.offsetTop;
415
- document.onmousemove = (ev) => {
416
- b.style.left = (ev.clientX-ox)+'px'; b.style.top = (ev.clientY-oy)+'px';
417
- };
418
- document.onmouseup = () => { document.onmousemove=null; saveLocal(); };
419
  };
420
-
 
421
  b.querySelector('.resize-handle').onmousedown = (e) => {
422
- e.stopPropagation();
423
- const ox = e.clientX, oy = e.clientY, ow = b.offsetWidth, oh = b.offsetHeight;
424
- document.onmousemove = (ev) => {
425
- b.style.width = (ow + ev.clientX - ox) + 'px';
426
- b.style.height = (oh + ev.clientY - oy) + 'px';
427
- };
428
- document.onmouseup = () => { document.onmousemove=null; saveLocal(); };
429
  };
430
 
 
431
  b.ondblclick = (e) => {
432
  e.stopPropagation();
433
- const span = b.querySelector('.bubble-text');
434
- const txt = document.createElement('textarea');
435
- txt.value = span.innerText;
436
- b.appendChild(txt); span.style.display='none'; txt.focus();
437
- txt.onblur = () => { span.innerText = txt.value; txt.remove(); span.style.display='block'; saveLocal(); };
438
  };
 
439
  return b;
440
  }
441
 
442
- function selectPanel(el) {
443
- document.querySelectorAll('.panel.selected').forEach(e=>e.classList.remove('selected'));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  el.classList.add('selected');
 
 
 
 
 
 
 
 
 
 
 
445
  selectedPanel = el;
446
- selectBubbleUI(null);
447
- resetPanelTransform(true);
 
 
 
 
 
 
 
448
  }
449
 
450
- function selectBubbleUI(el) {
451
- selectedBubble = el;
452
- const inputs = ['bubble-text-color', 'bubble-fill-color', 'bubble-type-select'];
453
- const tail = document.getElementById('tail-controls');
454
- if(el) {
455
- inputs.forEach(i => document.getElementById(i).disabled = false);
456
- tail.style.display = (el.dataset.type === 'speech') ? 'block' : 'none';
457
- } else {
458
- inputs.forEach(i => document.getElementById(i).disabled = true);
459
- tail.style.display = 'none';
460
- }
461
  }
462
 
463
- function slideTail(v) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', v+'%'); saveLocal(); } }
464
-
465
  function rotateTail() {
466
  if(!selectedBubble) return;
467
  const c = selectedBubble.classList;
@@ -471,82 +527,70 @@ INDEX_HTML = '''
471
  else c.replace('tail-right','tail-bottom') || c.add('tail-bottom');
472
  saveLocal();
473
  }
474
-
475
- function changeBubbleType(v) {
476
- if(!selectedBubble) return;
477
- selectedBubble.className = `speech-bubble ${v} tail-bottom selected`;
478
- selectedBubble.dataset.type = v;
479
- selectBubbleUI(selectedBubble);
480
- saveLocal();
481
- }
482
 
483
- function addBubble() {
484
- if(!selectedPanel) return alert("Select a panel");
485
- const b = createBubble("New", "20px", "20px");
486
- selectedPanel.appendChild(b);
487
- saveLocal();
488
- }
489
- function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); saveLocal(); } }
490
 
491
- // Panel Manipulations
492
- function startPan(e) {
493
- if(e.button!==0) return;
494
- const img = e.target;
495
- if((parseFloat(img.dataset.zoom)||100) <= 100) return;
496
- e.preventDefault(); isPanning = true;
497
- img.classList.add('panning');
498
- panStartX = e.clientX; panStartY = e.clientY;
499
- panStartTx = parseFloat(img.dataset.translateX||0);
500
- panStartTy = parseFloat(img.dataset.translateY||0);
501
- }
502
- document.addEventListener('mousemove', (e) => {
503
- if(!isPanning || !selectedPanel) return;
504
- const img = selectedPanel.querySelector('img');
505
- img.dataset.translateX = panStartTx + (e.clientX - panStartX);
506
- img.dataset.translateY = panStartTy + (e.clientY - panStartY);
507
- updateImageTransform(img);
508
  });
509
- document.addEventListener('mouseup', () => {
510
- if(isPanning) { isPanning=false; selectedPanel?.querySelector('img')?.classList.remove('panning'); saveLocal(); }
511
  });
 
 
 
 
 
 
 
 
 
 
512
  function handleZoom(el) {
513
  if(!selectedPanel) return;
514
  const img = selectedPanel.querySelector('img');
515
  img.dataset.zoom = el.value;
516
- updateImageTransform(img);
517
- saveLocal();
518
  }
519
- function updateImageTransform(img) {
520
- const z = (img.dataset.zoom||100)/100;
521
- const x = img.dataset.translateX||0; const y = img.dataset.translateY||0;
522
- img.style.transform = `translateX(${x}px) translateY(${y}px) scale(${z})`;
523
- img.classList.toggle('pannable', z>1);
 
 
 
 
 
 
 
524
  }
525
- function resetPanelTransform(loadOnly=false) {
526
  if(!selectedPanel) return;
527
  const img = selectedPanel.querySelector('img');
528
- if(!loadOnly) { img.dataset.zoom=100; img.dataset.translateX=0; img.dataset.translateY=0; }
529
- document.getElementById('zoom-slider').value = img.dataset.zoom || 100;
530
- updateImageTransform(img);
531
- if(!loadOnly) saveLocal();
532
  }
533
 
534
- // API
535
  function replacePanelImage() {
536
- if(!selectedPanel) return alert("Select panel");
537
- const img = selectedPanel.querySelector('img');
538
  const inp = document.getElementById('image-uploader');
539
- inp.onchange = async(e) => {
540
  const fd = new FormData(); fd.append('image', e.target.files[0]);
541
  const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd});
542
  const d = await r.json();
543
  if(d.success) { selectedPanel.querySelector('img').src = `/frames/${d.new_filename}?sid=${sid}`; saveLocal(); }
544
- inp.value='';
545
  };
546
  inp.click();
547
  }
 
548
  async function adjustFrame(dir) {
549
- if(!selectedPanel) return alert("Select panel");
550
  const img = selectedPanel.querySelector('img');
551
  let fname = img.src.split('/').pop().split('?')[0];
552
  await fetch(`/regenerate_frame?sid=${sid}`, {
@@ -556,8 +600,9 @@ INDEX_HTML = '''
556
  img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
557
  saveLocal();
558
  }
 
559
  async function gotoTimestamp() {
560
- if(!selectedPanel) return alert("Select panel");
561
  let v = document.getElementById('timestamp-input').value;
562
  if(v.includes(':')) { let p=v.split(':'); v = parseInt(p[0])*60 + parseFloat(p[1]); }
563
  const img = selectedPanel.querySelector('img');
@@ -569,29 +614,54 @@ INDEX_HTML = '''
569
  img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
570
  saveLocal();
571
  }
572
-
 
573
  async function exportComic() {
574
  const pgs = document.querySelectorAll('.comic-page');
575
  for(let i=0; i<pgs.length; i++) {
 
576
  const u = await htmlToImage.toPng(pgs[i], {pixelRatio:3});
577
- const a = document.createElement('a'); a.href=u; a.download=`comic-${i+1}.png`; a.click();
578
  }
579
  }
580
 
581
- // Color Listeners
582
- document.getElementById('bubble-text-color').addEventListener('input', (e) => {
583
- if(selectedBubble) { selectedBubble.style.setProperty('--bubble-text-color', e.target.value); saveLocal(); }
584
- });
585
- document.getElementById('bubble-fill-color').addEventListener('input', (e) => {
586
- if(selectedBubble) { selectedBubble.style.setProperty('--bubble-fill-color', e.target.value); saveLocal(); }
587
- });
588
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
589
  </script>
590
  </body>
591
  </html>
592
  '''
593
 
594
- # --- BACKEND LOGIC ---
595
  class EnhancedComicGenerator:
596
  def __init__(self, sid):
597
  self.sid = sid
@@ -638,14 +708,15 @@ class EnhancedComicGenerator:
638
  except:
639
  with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\nHello\n")
640
 
641
- self.update_status("Generating Panels...", 40)
642
  with open(user_srt, 'r', encoding='utf-8') as f: subs = list(srt.parse(f.read()))
643
 
644
  cap = cv2.VideoCapture(self.video_path)
645
- frame_files = []
646
- bubbles = []
647
 
648
- for i, sub in enumerate(subs[:48]): # 48 Panels = 12 Pages
 
649
  mid = (sub.start.total_seconds() + sub.end.total_seconds())/2
650
  cap.set(cv2.CAP_PROP_POS_MSEC, mid*1000)
651
  ret, frame = cap.read()
@@ -653,16 +724,15 @@ class EnhancedComicGenerator:
653
  fname = f"frame_{i}.png"
654
  cv2.imwrite(os.path.join(self.frames_dir, fname), frame)
655
  frame_files.append(fname)
656
- self.frame_metadata[fname] = mid
657
  bubbles.append(bubble(
658
  dialog=sub.content, bubble_offset_x=50, bubble_offset_y=20,
659
  lip_x=-1, lip_y=-1, emotion='normal'
660
  ))
661
  cap.release()
662
 
663
- with open(os.path.join(self.frames_dir, 'frame_metadata.json'), 'w') as f: json.dump(self.frame_metadata, f)
664
 
665
- self.update_status("Finalizing...", 90)
666
  pages_data = []
667
  for i in range(0, len(frame_files), 4):
668
  batch_f = frame_files[i:i+4]
@@ -752,25 +822,22 @@ def get_frame(filename):
752
  return send_from_directory(os.path.join(BASE_USER_DIR, request.args.get('sid'), 'frames'), filename)
753
 
754
  @app.route('/regenerate_frame', methods=['POST'])
755
- def regen_frame():
756
- sid = request.args.get('sid')
757
  d = request.get_json()
758
- return jsonify(EnhancedComicGenerator(sid).regenerate_frame(d['filename'], d['direction']))
759
 
760
  @app.route('/goto_timestamp', methods=['POST'])
761
  def go_time():
762
- sid = request.args.get('sid')
763
  d = request.get_json()
764
- return jsonify(EnhancedComicGenerator(sid).get_frame_at_timestamp(d['filename'], d['timestamp']))
765
 
766
  @app.route('/replace_panel', methods=['POST'])
767
  def rep_panel():
768
  sid = request.args.get('sid')
769
  f = request.files['image']
770
- gen = EnhancedComicGenerator(sid)
771
- fname = f"replaced_{int(time.time())}.png"
772
- f.save(os.path.join(gen.frames_dir, fname))
773
- return jsonify({'success': True, 'new_filename': fname})
774
 
775
  if __name__ == '__main__':
776
  os.makedirs(BASE_USER_DIR, exist_ok=True)
 
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
  logger = logging.getLogger(__name__)
15
 
 
21
  import srt
22
  except ImportError as e:
23
  print(f"❌ CRITICAL ERROR: Missing python library. {e}")
24
+ # We define placeholders to prevent startup crash, but functionality will fail gracefully
25
  cv2 = None
26
  np = None
27
  Image = None
28
  srt = None
29
 
30
+ # --- 2. BACKEND IMPORTS WITH PROPER INDENTATION & FALLBACKS ---
31
  def dummy_func(*args, **kwargs):
32
  return 0, 0, None, None
33
 
 
53
  try:
54
  from backend.class_def import bubble, panel, Page
55
  except Exception:
56
+ # Fallback class definitions
57
+ def bubble(dialog="", bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, emotion='normal'):
58
+ return {
59
+ 'dialog': dialog,
60
+ 'bubble_offset_x': bubble_offset_x,
61
+ 'bubble_offset_y': bubble_offset_y,
62
+ 'lip_x': lip_x,
63
+ 'lip_y': lip_y,
64
+ 'emotion': emotion
65
+ }
66
+ def panel(image=""): return {'image': image}
67
  class Page:
68
  def __init__(self, panels, bubbles):
69
  self.panels = panels
 
87
  app = Flask(__name__)
88
  BASE_USER_DIR = "userdata"
89
 
90
+ # --- HTML INTERFACE (FULL CSS/JS) ---
91
  INDEX_HTML = '''
92
  <!DOCTYPE html>
93
  <html lang="en">
 
98
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
99
  <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Lato&display=swap" rel="stylesheet">
100
  <style>
101
+ /* --- GLOBAL STYLES --- */
102
  body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
103
 
104
+ /* --- UPLOAD VIEW --- */
105
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; }
106
  .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; }
 
107
 
108
+ /* --- EDITOR VIEW --- */
109
+ #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 100px; }
110
+
111
+ /* --- BUTTONS & INPUTS --- */
112
  h1 { color: #2c3e50; margin-bottom: 30px; font-weight: 600; }
113
  .file-input { display: none; }
114
  .file-label { display: block; padding: 15px; background: #2c3e50; color: white; border-radius: 8px; cursor: pointer; font-weight: bold; margin-bottom: 10px; transition:0.2s; }
115
  .file-label:hover { background: #34495e; }
116
  .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; }
117
  .submit-btn:hover { background: #d35400; }
118
+
119
  .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; }
120
 
121
  .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; }
122
  @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
123
 
124
+ /* --- COMIC PAGE LAYOUT --- */
125
+ .comic-wrapper { max-width: 1000px; margin: 0 auto; }
126
  .comic-page { background: white; width: 600px; height: 400px; box-shadow: 0 4px 10px rgba(0,0,0,0.1); position: relative; overflow: hidden; border: 2px solid #000; margin: 0 auto 30px; }
127
  .comic-grid { display: grid; grid-template-columns: 285px 285px; grid-template-rows: 185px 185px; gap: 10px; width: 100%; height: 100%; padding: 10px; box-sizing: border-box; }
128
+
129
  .panel { position: relative; overflow: hidden; border: 2px solid #000; background: #eee; cursor: pointer; }
130
  .panel.selected { outline: 3px solid #2196F3; outline-offset: -3px; }
131
+ .panel img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.1s ease-out; transform-origin: center center; }
132
  .panel img.pannable { cursor: grab; }
133
  .panel img.panning { cursor: grabbing; }
134
 
135
+ /* --- SPEECH BUBBLE GENERAL --- */
136
  .speech-bubble {
137
  position: absolute; display: flex; justify-content: center; align-items: center;
138
  width: 150px; height: 80px; min-width: 60px; min-height: 40px; box-sizing: border-box;
 
140
  font-size: 14px; text-align: center;
141
  }
142
  .bubble-text { padding: 0.8em; word-wrap: break-word; position: relative; z-index: 5; }
143
+ .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
144
+ .speech-bubble textarea { position: absolute; top:0; left:0; width:100%; height:100%; box-sizing:border-box; border:1px solid #4CAF50; background:rgba(255,255,255,0.9); text-align:center; padding:8px; z-index:102; resize:none; }
145
 
146
+ /* --- EXACT "SHARK FIN" CSS (EXPORT SAFE) --- */
147
  .speech-bubble.speech {
148
  --b: 3em; /* tail base */
149
  --h: 1.8em; /* tail height */
150
  --t: 0.6; /* thickness */
151
+ --p: var(--tail-pos, 50%); /* slider pos */
152
  --r: 1.2em; /* radius */
153
  --c: var(--bubble-fill-color, #4ECDC4);
154
 
 
156
  color: var(--bubble-text-color, #fff);
157
  padding: 1em;
158
  position: absolute;
159
+
160
+ /* Exact border radius formula provided */
161
  border-radius: var(--r) var(--r) min(var(--r), calc(100% - var(--p) - (1 - var(--t)) * var(--b) / 2)) min(var(--r), calc(var(--p) - (1 - var(--t)) * var(--b) / 2)) / var(--r);
162
  }
163
 
164
+ /* TAIL: Using 'radial-gradient' background instead of mask for Export Compatibility */
165
  .speech-bubble.speech:before {
166
  content: ""; position: absolute; width: var(--b); height: var(--h);
167
+ /* Gradient mimics the mask curve exactly */
168
  background: radial-gradient(100% 100% at 100% 0, transparent calc(var(--t) * 100% - 1px), var(--c) calc(var(--t) * 100%));
169
  border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
170
  }
171
 
172
+ /* ROTATION LOGIC */
173
+ /* Bottom (Default) */
174
  .speech-bubble.speech.tail-bottom:before { top: 99%; left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); }
175
 
176
+ /* Top */
177
+ .speech-bubble.speech.tail-top {
178
+ border-radius: min(var(--r), calc(var(--p) - (1 - var(--t)) * var(--b) / 2)) min(var(--r), calc(100% - var(--p) - (1 - var(--t)) * var(--b) / 2)) var(--r) var(--r) / var(--r);
179
+ }
180
+ .speech-bubble.speech.tail-top:before {
181
+ bottom: 99%; left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
182
+ transform: scaleY(-1);
183
+ }
184
 
185
+ /* Left */
186
  .speech-bubble.speech.tail-left { border-radius: var(--r); }
187
+ .speech-bubble.speech.tail-left:before {
188
+ right: 99%; top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
189
+ transform: rotate(90deg); transform-origin: top right;
190
+ }
191
 
192
+ /* Right */
193
  .speech-bubble.speech.tail-right { border-radius: var(--r); }
194
+ .speech-bubble.speech.tail-right:before {
195
+ left: 99%; top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
196
+ transform: rotate(-90deg); transform-origin: top left;
197
+ }
198
 
199
+ /* THOUGHT BUBBLE */
200
  .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
201
  .speech-bubble.thought::after { display:none; }
202
  .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
203
  .thought-dot-1 { width: 20px; height: 20px; bottom: -20px; left: 15px; }
204
  .thought-dot-2 { width: 12px; height: 12px; bottom: -32px; left: 5px; }
205
 
206
+ /* HANDLES */
207
  .resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; }
208
  .speech-bubble.selected .resize-handle { display: block; }
209
  .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
210
 
211
+ /* FLOATING TOOLBAR */
212
+ .edit-controls {
213
+ position: fixed; bottom: 20px; right: 20px; width: 240px;
214
+ background: white; padding: 15px; border-radius: 8px;
215
+ box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 900;
216
+ }
217
  .edit-controls h4 { margin: 0 0 10px 0; color: #4ECDC4; text-align: center; }
218
+ .control-group { margin-top: 10px; border-top: 1px solid #eee; padding-top: 10px; }
219
+ button, input, select { width: 100%; margin-top: 5px; padding: 6px; border-radius: 4px; border: 1px solid #ddd; cursor: pointer; font-weight: bold; font-size: 12px; }
220
+ button:hover { background: #f5f5f5; }
221
  .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
222
+ .color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
223
  </style>
224
  </head>
225
  <body>
226
+
227
+ <!-- 1. UPLOAD SCREEN -->
228
  <div id="upload-container">
229
  <div class="upload-box">
230
  <h1>🎬 Comic Generator</h1>
231
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
232
  <label for="file-upload" class="file-label">Choose Video</label>
233
+ <span id="fn" style="margin-bottom:10px; display:block; color:#666;">No file selected</span>
234
  <button class="submit-btn" onclick="upload()">Generate Comic</button>
235
  <button id="restore-btn" class="restore-btn" onclick="restoreSession()">📂 Restore Unsaved Session</button>
236
 
237
+ <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
238
+ <div class="loader" style="margin:0 auto;"></div>
239
+ <p id="status-text" style="margin-top:10px;">Starting...</p>
240
  </div>
241
  </div>
242
  </div>
243
 
244
+ <!-- 2. EDITOR SCREEN -->
245
  <div id="editor-container">
246
+ <div class="comic-wrapper" id="comic-container"></div>
 
 
 
247
 
248
  <!-- Hidden inputs -->
249
  <input type="file" id="image-uploader" style="display: none;" accept="image/*">
250
 
251
  <div class="edit-controls">
252
+ <h4>Editor Tools</h4>
253
  <div class="control-group">
254
+ <button onclick="addBubble()">+ New Bubble</button>
255
+ <button onclick="deleteBubble()" style="background:#ffcccc">Delete Selected</button>
256
  </div>
257
+
258
  <div class="control-group">
259
+ <label style="font-size:12px; font-weight:bold;">Colors:</label>
260
+ <div class="color-grid">
261
  <input type="color" id="bubble-text-color" disabled title="Text Color">
262
  <input type="color" id="bubble-fill-color" disabled title="Fill Color">
263
  </div>
 
266
  <option value="thought">Thought</option>
267
  </select>
268
  </div>
269
+
270
  <div class="control-group" id="tail-controls" style="display:none;">
271
+ <label style="font-size:12px; font-weight:bold;">Tail:</label>
272
+ <div style="display:flex; gap:5px;">
273
+ <button onclick="rotateTail()">Rotate</button>
274
+ </div>
275
  <input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)">
276
  </div>
277
+
278
  <div class="control-group">
279
+ <label style="font-size:12px; font-weight:bold;">Panel:</label>
280
+ <button onclick="replacePanelImage()">🖼️ Swap Image</button>
281
+ <div class="button-grid">
282
+ <button onclick="adjustFrame('backward')">⬅️ Frame</button>
283
+ <button onclick="adjustFrame('forward')">Frame ➡️</button>
284
+ </div>
285
  <div class="button-grid">
286
+ <input type="text" id="timestamp-input" placeholder="mm:ss" style="width:90%;">
287
+ <button onclick="gotoTimestamp()">Go</button>
288
  </div>
289
+ <label style="font-size:10px;">Zoom:</label>
 
 
290
  <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled oninput="handleZoom(this)">
291
  </div>
292
+
293
  <hr>
294
+ <button onclick="saveLocal()" style="background:#f1c40f; margin-bottom:5px;">⚠️ Force Save Draft</button>
295
+ <button onclick="exportComic()" style="background:#27ae60; color:white; font-weight:bold;">📥 Download PNG</button>
296
+ <button onclick="location.reload()" style="color:red; background:transparent; border:none; margin-top:10px;">❌ Close & Reset</button>
297
  </div>
298
  </div>
299
 
300
  <script>
301
+ // --- SESSION LOGIC ---
302
  function genUUID(){ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,c=>{var r=Math.random()*16|0,v=c=='x'?r:(r&0x3|0x8);return v.toString(16);}); }
303
  let sid = localStorage.getItem('comic_sid') || genUUID();
304
  localStorage.setItem('comic_sid', sid);
305
+ console.log("Session ID:", sid);
306
 
307
+ // Check autosave on load
308
  if(localStorage.getItem('comic_autosave')) {
309
  document.getElementById('restore-btn').style.display = 'block';
310
  }
311
 
312
  let interval, selectedBubble, selectedPanel;
313
+ let isDragging=false, isResizing=false, isPanning=false;
314
+ let startX, startY, initX, initY, initW, initH;
315
+ let panStartX, panStartY, panStartTx, panStartTy;
316
 
317
+ // --- RESTORE FUNCTION ---
318
  function restoreSession() {
319
  const savedData = localStorage.getItem('comic_autosave');
320
  if(!savedData) return alert("No saved session found.");
 
323
  renderFromState(state);
324
  document.getElementById('upload-container').style.display = 'none';
325
  document.getElementById('editor-container').style.display = 'block';
326
+ } catch(e) { console.error(e); alert("Failed to restore session."); }
327
  }
328
 
329
  function renderFromState(pagesData) {
330
+ const con = document.getElementById('comic-container');
331
  con.innerHTML = '';
332
  pagesData.forEach((page, i) => {
333
  const div = document.createElement('div');
 
341
  pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
342
 
343
  const img = document.createElement('img');
344
+ img.src = pan.src; // Use exact saved src
345
  img.dataset.zoom = pan.zoom || 100;
346
+ img.dataset.tx = pan.tx || 0;
347
+ img.dataset.ty = pan.ty || 0;
348
  updateImageTransform(img);
349
+ img.onmousedown = (e) => startPan(e, img);
350
  pDiv.appendChild(img);
351
 
352
+ // Restore bubbles
353
  pan.bubbles.forEach(bData => {
354
+ const b = createBubbleHTML(bData);
 
355
  pDiv.appendChild(b);
356
  });
357
  grid.appendChild(pDiv);
 
361
  });
362
  }
363
 
364
+ // --- UPLOAD LOGIC ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  async function upload() {
366
  const f = document.getElementById('file-upload').files[0];
367
  if(!f) return alert("Select file");
 
374
  }
375
 
376
  async function checkStatus() {
377
+ try {
378
+ const r = await fetch(`/status?sid=${sid}`);
379
+ const d = await r.json();
380
+ document.getElementById('status-text').innerText = d.message;
381
+ if(d.progress >= 100) {
382
+ clearInterval(interval);
383
+ document.getElementById('upload-container').style.display='none';
384
+ document.getElementById('editor-container').style.display='block';
385
+ loadNewComic();
386
+ }
387
+ } catch(e) {}
388
  }
389
 
390
  function loadNewComic() {
 
397
  left: (p.bubbles[j].bubble_offset_x||50)+'px',
398
  top: (p.bubbles[j].bubble_offset_y||20)+'px',
399
  type: 'speech',
400
+ tailPos: '50%',
401
+ colors: { text: '#ffffff', fill: '#4ECDC4' }
402
  }] : []
403
  }))
404
  }));
 
407
  });
408
  }
409
 
410
+ // --- BUBBLE CREATION ---
411
+ function createBubbleHTML(data) {
412
  const b = document.createElement('div');
413
+ b.className = data.classes || 'speech-bubble speech tail-bottom';
414
+ b.style.left = data.left;
415
+ b.style.top = data.top;
416
+ if(data.width) b.style.width = data.width;
417
+ if(data.height) b.style.height = data.height;
 
 
 
 
 
418
 
419
+ b.dataset.type = data.type || 'speech';
420
+
421
+ if(data.colors) {
422
+ b.style.setProperty('--bubble-fill-color', data.colors.fill);
423
+ b.style.setProperty('--bubble-text-color', data.colors.text);
424
+ }
425
+ if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
426
+
427
+ b.innerHTML = `<div class="bubble-text">${data.text}</div><div class="resize-handle se"></div>`;
428
+
429
+ // Drag
430
  b.onmousedown = (e) => {
431
  if(e.target.classList.contains('resize-handle')) return;
432
+ e.stopPropagation(); selectBubble(b);
433
+ isDragging=true; startX=e.clientX; startY=e.clientY;
434
+ initX=b.offsetLeft; initY=b.offsetTop;
 
 
 
 
 
 
435
  };
436
+
437
+ // Resize
438
  b.querySelector('.resize-handle').onmousedown = (e) => {
439
+ e.stopPropagation(); isResizing=true;
440
+ startX=e.clientX; startY=e.clientY;
441
+ initW=b.offsetWidth; initH=b.offsetHeight;
 
 
 
 
442
  };
443
 
444
+ // Edit Text
445
  b.ondblclick = (e) => {
446
  e.stopPropagation();
447
+ const t = b.querySelector('.bubble-text');
448
+ const i = document.createElement('textarea');
449
+ i.value = t.innerText;
450
+ b.appendChild(i); t.style.opacity=0; i.focus();
451
+ i.onblur = () => { t.innerText=i.value; i.remove(); t.style.opacity=1; saveLocal(); };
452
  };
453
+
454
  return b;
455
  }
456
 
457
+ // --- GLOBAL EVENTS ---
458
+ document.addEventListener('mousemove', (e) => {
459
+ if(isDragging && selectedBubble) {
460
+ selectedBubble.style.left = (initX + e.clientX - startX) + 'px';
461
+ selectedBubble.style.top = (initY + e.clientY - startY) + 'px';
462
+ }
463
+ if(isResizing && selectedBubble) {
464
+ selectedBubble.style.width = (initW + e.clientX - startX) + 'px';
465
+ selectedBubble.style.height = (initH + e.clientY - startY) + 'px';
466
+ }
467
+ if(isPanning && selectedPanel) {
468
+ const img = selectedPanel.querySelector('img');
469
+ img.dataset.tx = panStartTx + (e.clientX - panStartX);
470
+ img.dataset.ty = panStartTy + (e.clientY - panStartY);
471
+ applyTransform(img);
472
+ }
473
+ });
474
+
475
+ document.addEventListener('mouseup', () => {
476
+ if(isDragging || isResizing || isPanning) saveLocal();
477
+ isDragging = false; isResizing = false; isPanning = false;
478
+ });
479
+
480
+ // --- INTERACTION LOGIC ---
481
+ function selectBubble(el) {
482
+ if(selectedBubble) selectedBubble.classList.remove('selected');
483
+ selectedBubble = el;
484
  el.classList.add('selected');
485
+ if(selectedPanel) selectedPanel.classList.remove('selected'); selectedPanel=null;
486
+
487
+ // Sync Controls
488
+ document.getElementById('tail-controls').style.display = (el.dataset.type === 'speech') ? 'block' : 'none';
489
+ ['bubble-text-color','bubble-fill-color','bubble-type-select'].forEach(i=>document.getElementById(i).disabled=false);
490
+ document.getElementById('zoom-slider').disabled = true;
491
+ }
492
+
493
+ function selectPanel(el) {
494
+ if(selectedPanel) selectedPanel.classList.remove('selected');
495
+ if(selectedBubble) selectedBubble.classList.remove('selected'); selectedBubble=null;
496
  selectedPanel = el;
497
+ el.classList.add('selected');
498
+
499
+ // Sync Zoom
500
+ const img = el.querySelector('img');
501
+ document.getElementById('zoom-slider').disabled = false;
502
+ document.getElementById('zoom-slider').value = img.dataset.zoom || 100;
503
+
504
+ ['bubble-text-color','bubble-fill-color','bubble-type-select'].forEach(i=>document.getElementById(i).disabled=true);
505
+ document.getElementById('tail-controls').style.display = 'none';
506
  }
507
 
508
+ function addBubble() {
509
+ if(!selectedPanel) return alert("Select a panel first");
510
+ const b = createBubbleHTML({
511
+ text: "New Text", left:"50px", top:"30px",
512
+ colors: {text:'#ffffff', fill:'#4ECDC4'}
513
+ });
514
+ selectedPanel.appendChild(b);
515
+ selectBubble(b);
516
+ saveLocal();
 
 
517
  }
518
 
519
+ function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); saveLocal(); } }
520
+
521
  function rotateTail() {
522
  if(!selectedBubble) return;
523
  const c = selectedBubble.classList;
 
527
  else c.replace('tail-right','tail-bottom') || c.add('tail-bottom');
528
  saveLocal();
529
  }
 
 
 
 
 
 
 
 
530
 
531
+ function slideTail(v) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', v+'%'); saveLocal(); } }
 
 
 
 
 
 
532
 
533
+ // Colors
534
+ document.getElementById('bubble-text-color').addEventListener('input', (e) => {
535
+ if(selectedBubble) { selectedBubble.style.setProperty('--bubble-text-color', e.target.value); saveLocal(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
  });
537
+ document.getElementById('bubble-fill-color').addEventListener('input', (e) => {
538
+ if(selectedBubble) { selectedBubble.style.setProperty('--bubble-fill-color', e.target.value); saveLocal(); }
539
  });
540
+ document.getElementById('bubble-type-select').addEventListener('change', (e) => {
541
+ if(selectedBubble) {
542
+ selectedBubble.dataset.type = e.target.value;
543
+ selectedBubble.className = `speech-bubble ${e.target.value} tail-bottom selected`;
544
+ selectBubble(selectedBubble); // Refresh UI
545
+ saveLocal();
546
+ }
547
+ });
548
+
549
+ // --- PAN & ZOOM ---
550
  function handleZoom(el) {
551
  if(!selectedPanel) return;
552
  const img = selectedPanel.querySelector('img');
553
  img.dataset.zoom = el.value;
554
+ applyTransform(img);
 
555
  }
556
+ function startPan(e, img) {
557
+ if(parseFloat(img.dataset.zoom) <= 100) return;
558
+ e.preventDefault(); isPanning = true;
559
+ panStartX = e.clientX; panStartY = e.clientY;
560
+ panStartTx = parseFloat(img.dataset.tx || 0);
561
+ panStartTy = parseFloat(img.dataset.ty || 0);
562
+ }
563
+ function applyTransform(img) {
564
+ const z = (img.dataset.zoom || 100) / 100;
565
+ const x = img.dataset.tx || 0;
566
+ const y = img.dataset.ty || 0;
567
+ img.style.transform = `scale(${z}) translate(${x/z}px, ${y/z}px)`;
568
  }
569
+ function resetPanelTransform() {
570
  if(!selectedPanel) return;
571
  const img = selectedPanel.querySelector('img');
572
+ img.dataset.zoom = 100; img.dataset.tx = 0; img.dataset.ty = 0;
573
+ document.getElementById('zoom-slider').value = 100;
574
+ applyTransform(img);
575
+ saveLocal();
576
  }
577
 
578
+ // --- BACKEND ACTIONS ---
579
  function replacePanelImage() {
580
+ if(!selectedPanel) return alert("Select Panel");
 
581
  const inp = document.getElementById('image-uploader');
582
+ inp.onchange = async (e) => {
583
  const fd = new FormData(); fd.append('image', e.target.files[0]);
584
  const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd});
585
  const d = await r.json();
586
  if(d.success) { selectedPanel.querySelector('img').src = `/frames/${d.new_filename}?sid=${sid}`; saveLocal(); }
587
+ inp.value = '';
588
  };
589
  inp.click();
590
  }
591
+
592
  async function adjustFrame(dir) {
593
+ if(!selectedPanel) return alert("Select Panel");
594
  const img = selectedPanel.querySelector('img');
595
  let fname = img.src.split('/').pop().split('?')[0];
596
  await fetch(`/regenerate_frame?sid=${sid}`, {
 
600
  img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
601
  saveLocal();
602
  }
603
+
604
  async function gotoTimestamp() {
605
+ if(!selectedPanel) return alert("Select Panel");
606
  let v = document.getElementById('timestamp-input').value;
607
  if(v.includes(':')) { let p=v.split(':'); v = parseInt(p[0])*60 + parseFloat(p[1]); }
608
  const img = selectedPanel.querySelector('img');
 
614
  img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
615
  saveLocal();
616
  }
617
+
618
+ // --- EXPORT ---
619
  async function exportComic() {
620
  const pgs = document.querySelectorAll('.comic-page');
621
  for(let i=0; i<pgs.length; i++) {
622
+ // html-to-image supports gradients!
623
  const u = await htmlToImage.toPng(pgs[i], {pixelRatio:3});
624
+ const a = document.createElement('a'); a.href=u; a.download=`Page-${i+1}.png`; a.click();
625
  }
626
  }
627
 
628
+ // --- SAVE STATE ---
629
+ function saveLocal() {
630
+ const pages = [];
631
+ document.querySelectorAll('.comic-page').forEach(p => {
632
+ const panels = [];
633
+ p.querySelectorAll('.panel').forEach(pan => {
634
+ const img = pan.querySelector('img');
635
+ const bubbles = [];
636
+ pan.querySelectorAll('.speech-bubble').forEach(b => {
637
+ bubbles.push({
638
+ text: b.innerText,
639
+ left: b.style.left, top: b.style.top,
640
+ width: b.style.width, height: b.style.height,
641
+ classes: b.className,
642
+ tailPos: b.style.getPropertyValue('--tail-pos'),
643
+ colors: {
644
+ fill: b.style.getPropertyValue('--bubble-fill-color'),
645
+ text: b.style.getPropertyValue('--bubble-text-color')
646
+ }
647
+ });
648
+ });
649
+ panels.push({
650
+ src: img.src,
651
+ zoom: img.dataset.zoom, tx: img.dataset.tx, ty: img.dataset.ty,
652
+ bubbles: bubbles
653
+ });
654
+ });
655
+ pages.push({ panels: panels });
656
+ });
657
+ localStorage.setItem('comic_autosave', JSON.stringify(pages));
658
+ }
659
  </script>
660
  </body>
661
  </html>
662
  '''
663
 
664
+ # --- 3. BACKEND LOGIC ---
665
  class EnhancedComicGenerator:
666
  def __init__(self, sid):
667
  self.sid = sid
 
708
  except:
709
  with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\nHello\n")
710
 
711
+ self.update_status("Generating 12 Pages...", 40)
712
  with open(user_srt, 'r', encoding='utf-8') as f: subs = list(srt.parse(f.read()))
713
 
714
  cap = cv2.VideoCapture(self.video_path)
715
+ frame_files, bubbles = [], []
716
+ meta = {}
717
 
718
+ # Max 48 Panels (12 Pages x 4 Panels)
719
+ for i, sub in enumerate(subs[:48]):
720
  mid = (sub.start.total_seconds() + sub.end.total_seconds())/2
721
  cap.set(cv2.CAP_PROP_POS_MSEC, mid*1000)
722
  ret, frame = cap.read()
 
724
  fname = f"frame_{i}.png"
725
  cv2.imwrite(os.path.join(self.frames_dir, fname), frame)
726
  frame_files.append(fname)
727
+ meta[fname] = mid
728
  bubbles.append(bubble(
729
  dialog=sub.content, bubble_offset_x=50, bubble_offset_y=20,
730
  lip_x=-1, lip_y=-1, emotion='normal'
731
  ))
732
  cap.release()
733
 
734
+ with open(os.path.join(self.frames_dir, 'frame_metadata.json'), 'w') as f: json.dump(meta, f)
735
 
 
736
  pages_data = []
737
  for i in range(0, len(frame_files), 4):
738
  batch_f = frame_files[i:i+4]
 
822
  return send_from_directory(os.path.join(BASE_USER_DIR, request.args.get('sid'), 'frames'), filename)
823
 
824
  @app.route('/regenerate_frame', methods=['POST'])
825
+ def regen():
 
826
  d = request.get_json()
827
+ return jsonify(EnhancedComicGenerator(request.args.get('sid')).regenerate_frame(d['filename'], d['direction']))
828
 
829
  @app.route('/goto_timestamp', methods=['POST'])
830
  def go_time():
 
831
  d = request.get_json()
832
+ return jsonify(EnhancedComicGenerator(request.args.get('sid')).get_frame_at_timestamp(d['filename'], d['timestamp']))
833
 
834
  @app.route('/replace_panel', methods=['POST'])
835
  def rep_panel():
836
  sid = request.args.get('sid')
837
  f = request.files['image']
838
+ fname = f"rep_{int(time.time())}.png"
839
+ f.save(os.path.join(BASE_USER_DIR, sid, 'frames', fname))
840
+ return jsonify({'success':True, 'new_filename':fname})
 
841
 
842
  if __name__ == '__main__':
843
  os.makedirs(BASE_USER_DIR, exist_ok=True)