tester343 commited on
Commit
717369e
·
verified ·
1 Parent(s): 3ace58f

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +280 -460
app_enhanced.py CHANGED
@@ -292,7 +292,7 @@ class EnhancedComicGenerator:
292
  # 🌐 ROUTES & FULL UI
293
  # ======================================================
294
  INDEX_HTML = '''
295
- <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>🎬 Enhanced Comic Generator</title> <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script> <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Gloria+Hallelujah&family=Lato&display=swap" rel="stylesheet"> <style> * { box-sizing: border-box; } body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
296
 
297
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
298
  .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; }
@@ -310,7 +310,6 @@ INDEX_HTML = '''
310
 
311
  .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; }
312
  .submit-btn:hover { background: #d35400; }
313
- .restore-btn { margin-top: 10px; background: #27ae60; color: white; padding: 12px; width: 100%; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
314
 
315
  .load-section { margin-top: 30px; padding-top: 20px; border-top: 2px solid #eee; }
316
  .load-input-group { display: flex; gap: 10px; margin-top: 10px; }
@@ -321,170 +320,106 @@ INDEX_HTML = '''
321
  @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
322
 
323
  /* ========================================= */
324
- /* 🎨 POLYGON TEMPLATE LAYOUT CSS */
325
  /* ========================================= */
326
 
327
  .comic-wrapper { max-width: 1050px; margin: 0 auto; }
328
- .page-wrapper { margin: 30px auto; display: flex; flex-direction: column; align-items: center; }
329
  .page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
330
 
331
  .comic-page {
332
  background: white;
333
  width: 1000px;
334
- height: 710px;
335
- box-shadow: 0 4px 10px rgba(0,0,0,0.1);
336
  position: relative;
337
  z-index: 1;
 
 
338
  }
339
 
340
- .speech-bubble {
341
- position: absolute; display: flex; justify-content: center; align-items: center;
342
- width: auto; height: auto;
343
- min-width: 80px; min-height: 50px;
344
- max-width: 250px;
345
- box-sizing: border-box;
346
- z-index: 200;
347
- cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold;
348
- font-size: 13px; text-align: center;
349
- line-height: 1.2;
350
- --tail-pos: 50%;
351
- padding: 5px;
352
- }
353
-
 
354
  .panel {
355
  position: absolute;
356
  background: #eee;
357
- cursor: pointer;
358
  overflow: hidden;
 
359
  z-index: 10;
360
- border: none;
361
  }
362
- .panel.selected { filter: brightness(0.9) sepia(0.2); outline: 3px solid #2196F3; z-index: 15; }
363
 
364
- /*
365
- IMAGE FIT FIX:
366
- Removed object-fit. Position is absolute.
367
- Size and position controlled completely via JS transform to allow
368
- full freedom to show any part of the image.
369
- */
370
  .panel img {
371
  position: absolute;
372
- top: 0;
373
- left: 0;
374
  transform-origin: 0 0;
375
- /* No fixed width/height - we calculate natural AR in JS */
376
  cursor: grab;
 
377
  }
378
-
379
- .panel img.pannable { cursor: grab; }
380
  .panel img.panning { cursor: grabbing; }
381
 
382
- /* COORDINATES (10px Gutter) */
383
- .panel-0 { top: 0; left: 0; width: 631px; height: 350px; clip-path: polygon(0% 0%, 630.2px 0%, 583.2px 100%, 0% 100%); }
384
- .panel-1 { top: 0; left: 593px; width: 407px; height: 350px; clip-path: polygon(47.2px 0%, 100% 0%, 100% 100%, 0.2px 100%); }
385
- .panel-2 { top: 360px; left: 0; width: 322px; height: 350px; clip-path: polygon(0% 0%, 288.2px 0%, 321.2px 100%, 0% 100%); }
386
- .panel-3 { top: 360px; left: 298px; width: 364px; height: 350px; clip-path: polygon(0.2px 0%, 314.2px 0%, 363.2px 100%, 33.2px 100%); }
387
- .panel-4 { top: 360px; left: 622px; width: 378px; height: 350px; clip-path: polygon(0.2px 0%, 100% 0%, 100% 100%, 49.2px 100%); }
388
-
389
- .bubble-text {
390
- padding: 0.5em;
391
- overflow-wrap: break-word;
392
- word-wrap: break-word;
393
- white-space: pre-wrap;
394
- position: relative;
395
- z-index: 5;
396
- pointer-events: none;
397
- user-select: none;
398
- width: 100%;
399
- height: 100%;
400
- display: flex;
401
- align-items: center;
402
- justify-content: center;
403
- border-radius: inherit;
404
- }
405
-
406
- .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 201; }
407
- .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.95); text-align:center; padding:8px; z-index:102; resize:none; font: inherit; white-space: pre-wrap; }
408
-
409
- .speech-bubble.speech {
410
- --b: 3em; --h: 1.8em; --t: 0.6; --p: var(--tail-pos, 50%); --r: 1.2em;
411
- background: var(--bubble-fill-color, #4ECDC4);
412
- color: var(--bubble-text-color, #fff);
413
- padding: 0;
414
- 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);
415
- }
416
- .speech-bubble.speech:before {
417
- content: ""; position: absolute; width: var(--b); height: var(--h);
418
- background: inherit; border-bottom-left-radius: 100%; pointer-events: none; z-index: 1;
419
- -webkit-mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
420
- mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
421
- }
422
-
423
- .speech-bubble.speech.tail-bottom:before { top: 100%; left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b))); }
424
- .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); }
425
- .speech-bubble.speech.tail-left { border-radius: var(--r); }
426
- .speech-bubble.speech.tail-left:before { right: 100%; 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; }
427
- .speech-bubble.speech.tail-right { border-radius: var(--r); }
428
- .speech-bubble.speech.tail-right:before { left: 100%; 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; }
429
-
430
- .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
431
- .speech-bubble.thought::before { display:none; }
432
- .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
433
- .thought-dot-1 { width: 20px; height: 20px; }
434
- .thought-dot-2 { width: 12px; height: 12px; }
435
-
436
- .speech-bubble.thought.pos-bl .thought-dot-1 { left: 20px; bottom: -20px; }
437
- .speech-bubble.thought.pos-bl .thought-dot-2 { left: 10px; bottom: -32px; }
438
- .speech-bubble.thought.pos-br .thought-dot-1 { right: 20px; bottom: -20px; }
439
- .speech-bubble.thought.pos-br .thought-dot-2 { right: 10px; bottom: -32px; }
440
- .speech-bubble.thought.pos-tr .thought-dot-1 { right: 20px; top: -20px; }
441
- .speech-bubble.thought.pos-tr .thought-dot-2 { right: 10px; top: -32px; }
442
- .speech-bubble.thought.pos-tl .thought-dot-1 { left: 20px; top: -20px; }
443
- .speech-bubble.thought.pos-tl .thought-dot-2 { left: 10px; top: -32px; }
444
-
445
- .speech-bubble.reaction { background: #FFD700; border: 3px solid #E53935; color: #D32F2F; font-weight: 900; text-transform: uppercase; clip-path: polygon(0% 25%, 17% 21%, 17% 0%, 31% 16%, 50% 4%, 69% 16%, 83% 0%, 83% 21%, 100% 25%, 85% 45%, 95% 62%, 82% 79%, 100% 97%, 79% 89%, 60% 98%, 46% 82%, 27% 95%, 15% 78%, 5% 62%, 15% 45%); }
446
- .speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; }
447
-
448
- .resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; z-index: 11; }
449
- .speech-bubble.selected .resize-handle { display: block; }
450
- .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
451
- .resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
452
- .resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
453
- .resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
454
-
455
  /* CONTROLS */
456
- .edit-controls { position: fixed; bottom: 20px; right: 20px; width: 260px; background: rgba(44, 62, 80, 0.95); color: white; padding: 15px; border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 900; font-size: 13px; max-height: 90vh; overflow-y: auto; }
457
- .edit-controls h4 { margin: 0 0 10px 0; color: #4ECDC4; text-align: center; }
458
- .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
459
- .control-group label { font-size: 11px; font-weight: bold; display: block; margin-bottom: 3px; }
460
- 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; }
461
- .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
462
- .color-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
463
- .slider-container { display: flex; align-items: center; gap: 5px; margin-top: 5px; }
464
- .slider-container label { min-width: 40px; font-size: 11px; }
465
- .action-btn { background: #4CAF50; color: white; }
466
- .reset-btn { background: #e74c3c; color: white; }
467
- .secondary-btn { background: #f39c12; color: white; }
468
- .export-btn { background: #2196F3; color: white; }
469
- .save-btn { background: #9b59b6; color: white; }
470
- .undo-btn { background: #7f8c8d; color: white; margin-bottom: 5px; }
471
 
472
  /* MODAL */
473
- .modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); display: none; justify-content: center; align-items: center; z-index: 9999; }
474
- .modal-content { background: white; padding: 30px; border-radius: 12px; max-width: 400px; width: 90%; text-align: center; }
475
- .modal-content .code { font-size: 32px; font-weight: bold; letter-spacing: 4px; background: #f0f0f0; padding: 15px 25px; border-radius: 8px; display: inline-block; margin: 15px 0; font-family: monospace; user-select: all; }
476
- .modal-content button { background: #3498db; color: white; border: none; padding: 12px 30px; border-radius: 8px; cursor: pointer; font-weight: bold; margin: 5px; }
477
  </style>
478
- </head> <body> <div id="upload-container"> <div class="upload-box"> <h1>🎬 Enhanced Comic Generator</h1> <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name"> <label for="file-upload" class="file-label">📁 Choose Video File</label> <span id="fn" style="margin-bottom:10px; display:block; color:#666;">No file selected</span>
479
  <div class="page-input-group">
480
  <label>📚 Total Comic Pages:</label>
481
- <input type="number" id="page-count" value="3" min="1" max="15" placeholder="e.g. 3 (Video will be divided evenly)">
482
- <small style="color:#666; font-size:11px; display:block; margin-top:5px;">System calculates 5 panels per page (Polygon Layout).</small>
483
  </div>
484
-
485
  <button class="submit-btn" onclick="upload()">🚀 Generate Comic</button>
486
  <button id="restore-draft-btn" class="restore-btn" style="display:none; margin-top:15px;" onclick="restoreDraft()">📂 Restore Unsaved Draft</button>
487
-
488
  <div class="load-section">
489
  <h3>📥 Load Saved Comic</h3>
490
  <div class="load-input-group">
@@ -502,10 +437,20 @@ INDEX_HTML = '''
502
  <div class="comic-wrapper" id="comic-container"></div>
503
  <input type="file" id="image-uploader" style="display: none;" accept="image/*">
504
  <div class="edit-controls">
505
- <h4>✏️ Interactive Editor</h4>
506
 
507
  <button onclick="undoLastAction()" class="undo-btn">↩️ Undo</button>
508
 
 
 
 
 
 
 
 
 
 
 
509
  <div class="control-group">
510
  <label>💾 Save & Load:</label>
511
  <button onclick="saveComic()" class="save-btn">💾 Save Comic</button>
@@ -523,41 +468,22 @@ INDEX_HTML = '''
523
  <option value="reaction">Reaction (Shout)</option>
524
  <option value="narration">Narration (Box)</option>
525
  </select>
526
- <select id="font-select" onchange="changeFont(this.value)" disabled>
527
- <option value="'Comic Neue', cursive">Comic Neue</option>
528
- <option value="'Bangers', cursive">Bangers</option>
529
- <option value="'Gloria Hallelujah', cursive">Gloria</option>
530
- <option value="'Lato', sans-serif">Lato</option>
531
- </select>
532
  <div class="color-grid">
533
  <div><label>Text</label><input type="color" id="bubble-text-color" value="#ffffff" disabled></div>
534
  <div><label>Fill</label><input type="color" id="bubble-fill-color" value="#4ECDC4" disabled></div>
535
  </div>
536
  <div class="button-grid">
537
- <button onclick="addBubble()" class="action-btn">Add</button>
538
  <button onclick="deleteBubble()" class="reset-btn">Delete</button>
539
  </div>
540
  </div>
541
 
542
- <div class="control-group" id="tail-controls" style="display:none;">
543
- <label>📐 Tail Adjustment:</label>
544
- <button onclick="rotateTail()" class="secondary-btn">🔄 Rotate Side</button>
545
- <div class="slider-container">
546
- <label>Pos:</label>
547
- <input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)">
548
- </div>
549
- </div>
550
-
551
  <div class="control-group">
552
  <label>🖼️ Panel Tools:</label>
553
  <button onclick="replacePanelImage()" class="action-btn">🖼️ Replace Image</button>
554
  <div class="button-grid">
555
- <button onclick="adjustFrame('backward')" class="secondary-btn" id="prev-btn">⬅️ Prev</button>
556
- <button onclick="adjustFrame('forward')" class="action-btn" id="next-btn">Next ➡️</button>
557
- </div>
558
- <div class="timestamp-controls">
559
- <input type="text" id="timestamp-input" placeholder="mm:ss or secs">
560
- <button onclick="gotoTimestamp()" class="action-btn" id="go-btn">Go</button>
561
  </div>
562
  </div>
563
 
@@ -593,32 +519,30 @@ INDEX_HTML = '''
593
  let interval, selectedBubble = null, selectedPanel = null;
594
  let isDragging = false, isResizing = false, isPanning = false;
595
  let startX, startY, initX, initY, panStartX, panStartY, panStartTx, panStartTy;
596
- let resizeHandle, originalWidth, originalHeight, originalMouseX, originalMouseY;
597
- let currentlyEditing = null;
 
 
 
 
 
 
 
598
 
599
- // UNDO SYSTEM
600
  let historyStack = [];
601
  let historyIndex = -1;
602
  function addToHistory() {
603
- if (historyIndex < historyStack.length - 1) {
604
- historyStack = historyStack.slice(0, historyIndex + 1);
605
- }
606
  const state = JSON.stringify(getCurrentState());
607
  if (historyStack.length > 0 && historyStack[historyStack.length - 1] === state) return;
608
-
609
  historyStack.push(state);
610
  historyIndex++;
611
-
612
- if (historyStack.length > 30) {
613
- historyStack.shift();
614
- historyIndex--;
615
- }
616
  }
617
  function undoLastAction() {
618
  if (historyIndex > 0) {
619
  historyIndex--;
620
- const previousState = JSON.parse(historyStack[historyIndex]);
621
- renderFromState(previousState);
622
  saveDraft(false);
623
  }
624
  }
@@ -630,17 +554,8 @@ INDEX_HTML = '''
630
  function copyModalCode() { navigator.clipboard.writeText(document.getElementById('modal-save-code').textContent).then(() => alert('Code copied!')); }
631
  function copyCode() { if(currentSaveCode) navigator.clipboard.writeText(currentSaveCode).then(() => alert('Code copied!')); }
632
 
633
- function setProcessing(busy) {
634
- isProcessing = busy;
635
- const btns = ['prev-btn', 'next-btn', 'go-btn'];
636
- btns.forEach(id => {
637
- const el = document.getElementById(id);
638
- if(el) { el.disabled = busy; el.style.opacity = busy ? '0.5' : '1'; el.innerText = busy ? '⏳' : el.getAttribute('data-txt') || el.innerText; }
639
- });
640
- }
641
  async function saveComic() {
642
  const state = getCurrentState();
643
- if(!state || state.length === 0) { alert('No comic to save!'); return; }
644
  try {
645
  const r = await fetch(`/save_comic?sid=${sid}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ pages: state, savedAt: new Date().toISOString() }) });
646
  const d = await r.json();
@@ -651,7 +566,6 @@ INDEX_HTML = '''
651
 
652
  async function loadSavedComic() {
653
  const code = document.getElementById('load-code-input').value.trim().toUpperCase();
654
- if(!code || code.length < 4) { alert('Invalid code'); return; }
655
  try {
656
  const r = await fetch(`/load_comic/${code}`);
657
  const d = await r.json();
@@ -671,28 +585,21 @@ INDEX_HTML = '''
671
  } catch(e) { console.error(e); alert("Failed to restore."); }
672
  }
673
 
674
- // UPDATED STATE RETRIEVAL
675
  function getCurrentState() {
676
  const pages = [];
677
- document.querySelectorAll('.comic-page').forEach(p => {
 
678
  const panels = [];
679
- const bubbles = []; // Page-level bubbles
680
 
681
  p.querySelectorAll('.panel').forEach(pan => {
682
  const img = pan.querySelector('img');
683
  panels.push({
684
  src: img.src,
685
- // Save the raw transform applied to the image, not zoom level
686
- // But we still track zoom in dataset for the slider
687
- zoom: img.dataset.zoom,
688
- tx: img.dataset.translateX,
689
- ty: img.dataset.translateY,
690
- // Save base scale calculated by JS on load
691
- baseScale: img.dataset.baseScale,
692
  bubbles: []
693
  });
694
  });
695
-
696
  p.querySelectorAll('.speech-bubble').forEach(b => {
697
  const textEl = b.querySelector('.bubble-text');
698
  bubbles.push({
@@ -704,8 +611,7 @@ INDEX_HTML = '''
704
  colors: { fill: b.style.getPropertyValue('--bubble-fill-color'), text: b.style.getPropertyValue('--bubble-text-color') }
705
  });
706
  });
707
-
708
- pages.push({ panels: panels, pageBubbles: bubbles });
709
  });
710
  return pages;
711
  }
@@ -718,62 +624,174 @@ INDEX_HTML = '''
718
  function renderFromState(pagesData) {
719
  const con = document.getElementById('comic-container'); con.innerHTML = '';
720
  pagesData.forEach((page, pageIdx) => {
 
 
 
721
  const pageWrapper = document.createElement('div'); pageWrapper.className = 'page-wrapper';
722
- const pageTitle = document.createElement('h2'); pageTitle.className = 'page-title'; pageTitle.textContent = `Page ${pageIdx + 1}`;
723
- pageWrapper.appendChild(pageTitle);
724
 
725
  const div = document.createElement('div');
726
  div.className = 'comic-page';
 
 
727
 
728
- // 1. Render Panels
729
- page.panels.forEach((pan, idx) => {
730
  const pDiv = document.createElement('div');
731
- const posClass = `panel-${idx % 5}`;
732
- pDiv.className = `panel ${posClass}`;
733
 
734
- pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
735
  const img = document.createElement('img');
736
- img.onload = () => fitImageToPanel(img, pDiv, pan); // Fit or use saved state
737
- img.src = pan.src.includes('?') ? pan.src : pan.src + `?sid=${sid}`;
738
-
739
  img.onmousedown = (e) => startPan(e, img);
740
- pDiv.appendChild(img);
741
 
742
- (pan.bubbles || []).forEach(bData => {
743
- const b = createBubbleHTML(bData);
744
- div.appendChild(b);
745
- });
746
  div.appendChild(pDiv);
747
- });
748
 
749
- // 2. Render Page-Level Bubbles
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
750
  if(page.pageBubbles) {
751
- page.pageBubbles.forEach(bData => {
752
- div.appendChild(createBubbleHTML(bData));
753
- });
754
  }
755
 
756
- // Bind click to deselect
757
  div.onclick = (e) => {
758
- if(e.target === div) {
759
  if(selectedBubble) selectedBubble.classList.remove('selected'); selectedBubble = null;
760
  if(selectedPanel) selectedPanel.classList.remove('selected'); selectedPanel = null;
761
  }
762
- }
763
 
764
  pageWrapper.appendChild(div);
765
  con.appendChild(pageWrapper);
 
 
 
766
  });
767
- selectedBubble = null;
768
- selectedPanel = null;
769
  document.getElementById('bubble-type-select').disabled = true;
770
  document.getElementById('font-select').disabled = true;
771
  }
772
 
773
- // NEW: Function to fit image to panel optimally without cropping via CSS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
774
  function fitImageToPanel(img, panel, savedState) {
775
  if(savedState && savedState.baseScale) {
776
- // Restore state
777
  img.dataset.zoom = savedState.zoom || 100;
778
  img.dataset.translateX = savedState.tx || 0;
779
  img.dataset.translateY = savedState.ty || 0;
@@ -782,93 +800,48 @@ INDEX_HTML = '''
782
  return;
783
  }
784
 
785
- // Initial fit
786
- const pW = panel.offsetWidth;
787
- const pH = panel.offsetHeight;
 
788
  const iW = img.naturalWidth || img.width;
789
  const iH = img.naturalHeight || img.height;
790
 
791
- // Calculate scale to COVER
792
  const scale = Math.max(pW / iW, pH / iH);
793
-
794
- // Center the image
795
  const tx = (pW - iW * scale) / 2;
796
  const ty = (pH - iH * scale) / 2;
797
 
798
- // Apply (Note: we use translate for positioning relative to top-left)
799
- // We set translateX/Y as 0 relative to this base fit?
800
- // No, let's make translateX/Y absolute values for simplicity.
801
-
802
- // Actually, let's keep it simple: transform = translate(X, Y) scale(S)
803
- // S = scale * zoom/100
804
-
805
  img.dataset.baseScale = scale;
806
  img.dataset.translateX = tx;
807
  img.dataset.translateY = ty;
808
  img.dataset.zoom = 100;
809
-
810
  updateImageTransform(img);
811
  }
812
-
813
- async function upload() {
814
- const f = document.getElementById('file-upload').files[0];
815
- const pCount = document.getElementById('page-count').value;
816
- if(!f) return alert("Select a video");
817
- sid = genUUID(); localStorage.setItem('comic_sid', sid); currentSaveCode = null;
818
- document.querySelector('.upload-box').style.display='none';
819
- document.getElementById('loading-view').style.display='flex';
820
- const fd = new FormData(); fd.append('file', f); fd.append('target_pages', pCount);
821
- const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
822
- if(r.ok) interval = setInterval(checkStatus, 2000);
823
- else { alert("Upload failed"); location.reload(); }
824
- }
825
-
826
- async function checkStatus() {
827
- try {
828
- const r = await fetch(`/status?sid=${sid}`); const d = await r.json();
829
- document.getElementById('status-text').innerText = d.message;
830
- if(d.progress >= 100) { clearInterval(interval); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='block'; loadNewComic(); }
831
- else if (d.progress < 0) { clearInterval(interval); document.getElementById('status-text').textContent = "Error: " + d.message; document.querySelector('.loader').style.display = 'none'; }
832
- } catch(e) {}
833
  }
834
-
835
- function loadNewComic() {
836
- fetch(`/output/pages.json?sid=${sid}`).then(r=>r.json()).then(data => {
837
- // Convert Backend Data (Bubbles in Panels) to Frontend Data (Page Bubbles)
838
- const cleanData = data.map((p, pi) => {
839
- const panels = [];
840
- const pageBubbles = [];
841
-
842
- // Panel Geometry for Default Bubble Placement
843
- const panelCenters = [
844
- {x: 315, y: 175}, // 0
845
- {x: 790, y: 175}, // 1
846
- {x: 160, y: 535}, // 2
847
- {x: 480, y: 535}, // 3
848
- {x: 800, y: 535} // 4
849
- ];
850
-
851
- p.panels.forEach((pan, j) => {
852
- panels.push({
853
- src: `/frames/${pan.image}?sid=${sid}`,
854
- bubbles: []
855
- });
856
-
857
- if(p.bubbles && p.bubbles[j] && p.bubbles[j].dialog) {
858
- const center = panelCenters[j % 5] || {x:500, y:350};
859
- pageBubbles.push({
860
- text: p.bubbles[j].dialog,
861
- left: (center.x - 75) + 'px',
862
- top: (center.y - 40) + 'px',
863
- type: (p.bubbles[j].type || 'speech'),
864
- classes: `speech-bubble ${p.bubbles[j].type || 'speech'} tail-bottom`
865
- });
866
- }
867
- });
868
- return { panels: panels, pageBubbles: pageBubbles };
869
- });
870
- renderFromState(cleanData); saveDraft(true);
871
- });
872
  }
873
 
874
  function createBubbleHTML(data) {
@@ -876,19 +849,14 @@ INDEX_HTML = '''
876
  const type = data.type || 'speech';
877
  b.className = data.classes || `speech-bubble ${type} tail-bottom`;
878
  if (type === 'thought' && !b.className.includes('pos-')) b.className += ' pos-bl';
879
-
880
  b.dataset.type = type;
881
  b.style.left = data.left; b.style.top = data.top;
882
  if(data.font) b.style.fontFamily = data.font;
883
  if(data.colors) { b.style.setProperty('--bubble-fill-color', data.colors.fill || '#4ECDC4'); b.style.setProperty('--bubble-text-color', data.colors.text || '#ffffff'); }
884
  if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
885
-
886
  const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || ''; b.appendChild(textSpan);
887
-
888
  if(type === 'thought') { for(let i=1; i<=2; i++){ const d = document.createElement('div'); d.className = `thought-dot thought-dot-${i}`; b.appendChild(d); } }
889
-
890
  ['nw', 'ne', 'sw', 'se'].forEach(dir => { const handle = document.createElement('div'); handle.className = `resize-handle ${dir}`; handle.onmousedown = (e) => startResize(e, dir); b.appendChild(handle); });
891
-
892
  b.onmousedown = (e) => {
893
  if(e.target.classList.contains('resize-handle')) return;
894
  e.stopPropagation(); selectBubble(b); isDragging = true; startX = e.clientX; startY = e.clientY; initX = b.offsetLeft; initY = b.offsetTop;
@@ -897,178 +865,30 @@ INDEX_HTML = '''
897
  b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
898
  return b;
899
  }
900
-
901
- function editBubbleText(bubble) {
902
- if (currentlyEditing) return; currentlyEditing = bubble;
903
- const textSpan = bubble.querySelector('.bubble-text'); const textarea = document.createElement('textarea');
904
- textarea.value = textSpan.textContent; bubble.appendChild(textarea); textSpan.style.display = 'none'; textarea.focus();
905
- const finishEditing = () => {
906
- textSpan.textContent = textarea.value; textarea.remove(); textSpan.style.display = ''; currentlyEditing = null;
907
- saveDraft(true);
908
- };
909
- textarea.addEventListener('blur', finishEditing, { once: true });
910
- textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); } });
911
- }
912
-
913
- document.addEventListener('mousemove', (e) => {
914
- if(isDragging && selectedBubble) { selectedBubble.style.left = (initX + e.clientX - startX) + 'px'; selectedBubble.style.top = (initY + e.clientY - startY) + 'px'; }
915
- if(isResizing && selectedBubble) { resizeBubble(e); }
916
- if(isPanning && selectedPanel) { panImage(e); }
917
- });
918
-
919
- document.addEventListener('mouseup', () => {
920
- if(isDragging || isResizing || isPanning) {
921
- saveDraft(true);
922
- }
923
- isDragging = false; isResizing = false; isPanning = false;
924
- });
925
-
926
- function startResize(e, dir) { e.preventDefault(); e.stopPropagation(); isResizing = true; resizeHandle = dir; const rect = selectedBubble.getBoundingClientRect(); originalWidth = rect.width; originalHeight = rect.height; originalMouseX = e.clientX; originalMouseY = e.clientY; }
927
- function resizeBubble(e) { if (!isResizing || !selectedBubble) return; const dx = e.clientX - originalMouseX; const dy = e.clientY - originalMouseY; if(resizeHandle.includes('e')) selectedBubble.style.width = (originalWidth + dx)+'px'; if(resizeHandle.includes('s')) selectedBubble.style.height = (originalHeight + dy)+'px'; }
928
-
929
- function selectBubble(el) {
930
- if(selectedBubble) selectedBubble.classList.remove('selected');
931
- if(selectedPanel) { selectedPanel.classList.remove('selected'); selectedPanel = null; }
932
- selectedBubble = el; el.classList.add('selected');
933
- document.getElementById('bubble-type-select').disabled = false;
934
- document.getElementById('font-select').disabled = false;
935
- document.getElementById('bubble-text-color').disabled = false;
936
- document.getElementById('bubble-fill-color').disabled = false;
937
- document.getElementById('tail-controls').style.display = 'block';
938
- document.getElementById('bubble-type-select').value = el.dataset.type || 'speech';
939
- }
940
-
941
- function selectPanel(el) {
942
- if(selectedPanel) selectedPanel.classList.remove('selected');
943
- if(selectedBubble) { selectedBubble.classList.remove('selected'); selectedBubble = null; }
944
- selectedPanel = el; el.classList.add('selected');
945
- document.getElementById('zoom-slider').disabled = false;
946
- const img = el.querySelector('img');
947
- document.getElementById('zoom-slider').value = img.dataset.zoom || 100;
948
- document.getElementById('bubble-type-select').disabled = true;
949
- document.getElementById('font-select').disabled = true;
950
- document.getElementById('tail-controls').style.display = 'none';
951
- }
952
-
953
- function addBubble() {
954
- if(!selectedPanel) return alert("Select a panel to define which page to add to.");
955
- const pageDiv = selectedPanel.parentElement;
956
- const b = createBubbleHTML({ text: "Text", left: "50px", top: "50px", type: 'speech', classes: "speech-bubble speech tail-bottom" });
957
- pageDiv.appendChild(b); selectBubble(b); saveDraft(true);
958
- }
959
-
960
- function deleteBubble() {
961
- if(!selectedBubble) return alert("Select a bubble");
962
- selectedBubble.remove(); selectedBubble=null; saveDraft(true);
963
- }
964
-
965
- function changeBubbleType(type) {
966
- if(!selectedBubble) return;
967
- selectedBubble.dataset.type = type;
968
- selectedBubble.className = 'speech-bubble ' + type + ' selected';
969
-
970
- if(type === 'thought') selectedBubble.classList.add('pos-bl');
971
- else selectedBubble.classList.add('tail-bottom');
972
-
973
- selectedBubble.querySelectorAll('.thought-dot').forEach(d=>d.remove());
974
- if(type === 'thought') { for(let i=1; i<=2; i++){ const d = document.createElement('div'); d.className = `thought-dot thought-dot-${i}`; selectedBubble.appendChild(d); } }
975
- saveDraft(true);
976
- }
977
-
978
  function changeFont(font) { if(!selectedBubble) return; selectedBubble.style.fontFamily = font; saveDraft(true); }
979
-
980
- function rotateTail() {
981
- if(!selectedBubble) return;
982
- const type = selectedBubble.dataset.type;
983
-
984
- if(type === 'speech') {
985
- const positions = ['tail-bottom', 'tail-right', 'tail-top', 'tail-left'];
986
- let current = 0;
987
- positions.forEach((pos, i) => { if(selectedBubble.classList.contains(pos)) current = i; });
988
- selectedBubble.classList.remove(positions[current]);
989
- selectedBubble.classList.add(positions[(current + 1) % 4]);
990
- }
991
- else if (type === 'thought') {
992
- const positions = ['pos-bl', 'pos-br', 'pos-tr', 'pos-tl'];
993
- let current = 0;
994
- positions.forEach((pos, i) => { if(selectedBubble.classList.contains(pos)) current = i; });
995
- selectedBubble.classList.remove(positions[current]);
996
- selectedBubble.classList.add(positions[(current + 1) % 4]);
997
- }
998
- saveDraft(true);
999
- }
1000
-
1001
  function slideTail(v) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', v+'%'); saveDraft(true); } }
1002
-
1003
  document.getElementById('bubble-text-color').addEventListener('change', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-text-color', e.target.value); saveDraft(true); } });
1004
  document.getElementById('bubble-fill-color').addEventListener('change', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-fill-color', e.target.value); saveDraft(true); } });
1005
-
1006
- function handleZoom(el) { if(!selectedPanel) return; const img = selectedPanel.querySelector('img'); img.dataset.zoom = el.value; updateImageTransform(img); }
1007
- document.getElementById('zoom-slider').addEventListener('change', () => saveDraft(true));
1008
- function startPan(e, img) {
1009
- e.preventDefault();
1010
- isPanning = true;
1011
- selectedPanel = img.closest('.panel');
1012
- panStartX = e.clientX;
1013
- panStartY = e.clientY;
1014
- panStartTx = parseFloat(img.dataset.translateX || 0);
1015
- panStartTy = parseFloat(img.dataset.translateY || 0);
1016
- img.classList.add('panning');
1017
- }
1018
- function panImage(e) { if(!isPanning || !selectedPanel) return; const img = selectedPanel.querySelector('img'); img.dataset.translateX = panStartTx + (e.clientX - panStartX); img.dataset.translateY = panStartTy + (e.clientY - panStartY); updateImageTransform(img); }
1019
-
1020
- // UPDATED TRANSFORM LOGIC FOR JS FITTING
1021
- function updateImageTransform(img) {
1022
- const z = (img.dataset.zoom || 100) / 100;
1023
- const tx = parseFloat(img.dataset.translateX || 0);
1024
- const ty = parseFloat(img.dataset.translateY || 0);
1025
- const baseScale = parseFloat(img.dataset.baseScale || 1);
1026
-
1027
- // Final scale = baseScale (to cover panel) * zoom slider
1028
- const finalScale = baseScale * z;
1029
-
1030
- img.style.transform = `translate(${tx}px, ${ty}px) scale(${finalScale})`;
1031
- img.classList.toggle('pannable', true); // Always pannable now
1032
- }
1033
-
1034
- function resetPanelTransform() {
1035
- if(!selectedPanel) return alert("Select a panel");
1036
- const img = selectedPanel.querySelector('img');
1037
- // Re-run fitting logic
1038
- fitImageToPanel(img, selectedPanel);
1039
- saveDraft(true);
1040
- }
1041
-
1042
  function replacePanelImage() { if(!selectedPanel) return alert("Select a panel"); const inp = document.getElementById('image-uploader'); inp.onchange = async (e) => { const fd = new FormData(); fd.append('image', e.target.files[0]); const img = selectedPanel.querySelector('img'); const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd}); const d = await r.json(); if(d.success) { img.src = `/frames/${d.new_filename}?sid=${sid}`; saveDraft(true); } inp.value = ''; }; inp.click(); }
1043
  async function adjustFrame(dir) { if(isProcessing) return; if(!selectedPanel) return alert("Select a panel"); const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0]; setProcessing(true); img.style.opacity = '0.5'; try { const r = await fetch(`/regenerate_frame?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, direction:dir}) }); const d = await r.json(); if(d.success) { img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`; } else { alert('Error: ' + d.message); } } catch(e) { console.error(e); } img.style.opacity = '1'; setProcessing(false); saveDraft(true); }
1044
  async function gotoTimestamp() { if(isProcessing) return; if(!selectedPanel) return alert("Select a panel"); let v = document.getElementById('timestamp-input').value.trim(); if(!v) return; if(v.includes(':')) { let p = v.split(':'); v = parseInt(p[0]) * 60 + parseFloat(p[1]); } else { v = parseFloat(v); } if(isNaN(v)) return alert("Invalid time"); const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0]; setProcessing(true); img.style.opacity = '0.5'; try { const r = await fetch(`/goto_timestamp?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, timestamp:v}) }); const d = await r.json(); if(d.success) { img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`; document.getElementById('timestamp-input').value = ''; } else { alert('Error: ' + d.message); } } catch(e) { console.error(e); } img.style.opacity = '1'; setProcessing(false); saveDraft(true); }
1045
-
1046
- async function exportComic() {
1047
- const pgs = document.querySelectorAll('.comic-page');
1048
- if(pgs.length === 0) return alert("No pages found");
1049
-
1050
- // Remove selection highlights
1051
- if(selectedBubble) selectedBubble.classList.remove('selected');
1052
- if(selectedPanel) selectedPanel.classList.remove('selected');
1053
- alert(`Exporting ${pgs.length} page(s)...`);
1054
-
1055
- for(let i = 0; i < pgs.length; i++) {
1056
- try {
1057
- const u = await htmlToImage.toPng(pgs[i], {
1058
- pixelRatio: 2, // High quality
1059
- style: { transform: 'none' }
1060
- });
1061
- const a = document.createElement('a');
1062
- a.href = u;
1063
- a.download = `Comic-Page-${i+1}.png`;
1064
- a.click();
1065
- } catch(err) {
1066
- console.error(err);
1067
- alert(`Failed to export page ${i+1}`);
1068
- }
1069
- }
1070
  }
1071
-
1072
  function goBackToUpload() { if(confirm('Go home? Unsaved changes will be lost.')) { document.getElementById('editor-container').style.display = 'none'; document.getElementById('upload-container').style.display = 'flex'; document.getElementById('loading-view').style.display = 'none'; } }
1073
  </script>
1074
  </body> </html> '''
 
292
  # 🌐 ROUTES & FULL UI
293
  # ======================================================
294
  INDEX_HTML = '''
295
+ <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>🎬 Manual Drag Comic Architect</title> <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script> <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Gloria+Hallelujah&family=Lato&display=swap" rel="stylesheet"> <style> * { box-sizing: border-box; } body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
296
 
297
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
298
  .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; }
 
310
 
311
  .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; }
312
  .submit-btn:hover { background: #d35400; }
 
313
 
314
  .load-section { margin-top: 30px; padding-top: 20px; border-top: 2px solid #eee; }
315
  .load-input-group { display: flex; gap: 10px; margin-top: 10px; }
 
320
  @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
321
 
322
  /* ========================================= */
323
+ /* 🎨 DYNAMIC DRAG LAYOUT CSS */
324
  /* ========================================= */
325
 
326
  .comic-wrapper { max-width: 1050px; margin: 0 auto; }
327
+ .page-wrapper { margin: 30px auto; display: flex; flex-direction: column; align-items: center; position: relative; }
328
  .page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
329
 
330
  .comic-page {
331
  background: white;
332
  width: 1000px;
333
+ height: 700px;
334
+ box-shadow: 0 0 50px rgba(0,0,0,0.2);
335
  position: relative;
336
  z-index: 1;
337
+ overflow: hidden; /* Contains handles? No, let handles spill if needed, but we keep frames inside */
338
+ border: 2px solid black;
339
  }
340
 
341
+ /* HANDLES FOR DRAGGING */
342
+ .handle {
343
+ position: absolute;
344
+ width: 16px; height: 16px;
345
+ background: #ff4757;
346
+ border: 2px solid white;
347
+ border-radius: 50%;
348
+ cursor: ew-resize;
349
+ z-index: 999; /* Highest priority */
350
+ transform: translate(-50%, -50%);
351
+ box-shadow: 0 2px 5px rgba(0,0,0,0.3);
352
+ }
353
+ .handle.horiz { cursor: ns-resize; width: 40px; height: 12px; border-radius: 6px; }
354
+
355
+ /* PANELS */
356
  .panel {
357
  position: absolute;
358
  background: #eee;
 
359
  overflow: hidden;
360
+ transition: clip-path 0.05s linear;
361
  z-index: 10;
 
362
  }
363
+ .panel.selected { z-index: 15; outline: 3px solid #2196F3; outline-offset: -3px; }
364
 
365
+ /* IMAGE INSIDE PANEL */
 
 
 
 
 
366
  .panel img {
367
  position: absolute;
368
+ top: 0; left: 0;
 
369
  transform-origin: 0 0;
 
370
  cursor: grab;
371
+ /* No object-fit: we use transforms for freedom */
372
  }
 
 
373
  .panel img.panning { cursor: grabbing; }
374
 
375
+ /* BUBBLES */
376
+ .speech-bubble {
377
+ position: absolute; display: flex; justify-content: center; align-items: center;
378
+ width: auto; height: auto; min-width: 80px; min-height: 50px; max-width: 250px;
379
+ box-sizing: border-box;
380
+ z-index: 500;
381
+ cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold;
382
+ font-size: 13px; text-align: center;
383
+ line-height: 1.2; --tail-pos: 50%; padding: 5px;
384
+ }
385
+ .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 501; }
386
+
387
+ /* BUBBLE STYLES ... (Same as before) */
388
+ .bubble-text { padding:0.5em; overflow-wrap:break-word; word-wrap:break-word; white-space:pre-wrap; position:relative; z-index:5; pointer-events:none; user-select:none; width:100%; height:100%; display:flex; align-items:center; justify-content:center; border-radius:inherit; }
389
+ .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.95); text-align:center; padding:8px; z-index:102; resize:none; font:inherit; white-space:pre-wrap; }
390
+ .speech-bubble.speech { --b:3em; --h:1.8em; --t:0.6; --p:var(--tail-pos,50%); --r:1.2em; background:var(--bubble-fill-color,#4ECDC4); color:var(--bubble-text-color,#fff); padding:0; 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); }
391
+ .speech-bubble.speech:before { content:""; position:absolute; width:var(--b); height:var(--h); background:inherit; border-bottom-left-radius:100%; pointer-events:none; z-index:1; -webkit-mask:radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%); mask:radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%); }
392
+ .speech-bubble.speech.tail-bottom:before { top:100%; left:clamp(0%,calc(var(--p) - (1 - var(--t))*var(--b)/2),calc(100% - (1 - var(--t))*var(--b))); }
393
+ .speech-bubble.thought { background:white; border:2px dashed #555; color:#333; border-radius:50%; }
394
+ .thought-dot { position:absolute; background-color:white; border:2px solid #555; border-radius:50%; z-index:-1; }
395
+ .thought-dot-1 { width:20px; height:20px; left:20px; bottom:-20px; } .thought-dot-2 { width:12px; height:12px; left:10px; bottom:-32px; }
396
+ .resize-handle { position:absolute; width:10px; height:10px; background:#2196F3; border:1px solid white; border-radius:50%; display:none; z-index:11; }
397
+ .speech-bubble.selected .resize-handle { display:block; }
398
+ .resize-handle.se { bottom:-5px; right:-5px; cursor:se-resize; } .resize-handle.sw { bottom:-5px; left:-5px; cursor:sw-resize; } .resize-handle.ne { top:-5px; right:-5px; cursor:ne-resize; } .resize-handle.nw { top:-5px; left:-5px; cursor:nw-resize; }
399
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  /* CONTROLS */
401
+ .edit-controls { position:fixed; bottom:20px; right:20px; width:260px; background:rgba(44,62,80,0.95); color:white; padding:15px; border-radius:8px; box-shadow:0 5px 15px rgba(0,0,0,0.3); z-index:9000; font-size:13px; max-height:90vh; overflow-y:auto; }
402
+ .edit-controls h4 { margin:0 0 10px 0; color:#4ECDC4; text-align:center; }
403
+ .control-group { margin-top:10px; border-top:1px solid #555; padding-top:10px; }
404
+ .control-group label { font-size:11px; font-weight:bold; display:block; margin-bottom:3px; }
405
+ 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; }
406
+ .button-grid { display:grid; grid-template-columns:1fr 1fr; gap:5px; }
407
+ .color-grid { display:grid; grid-template-columns:1fr 1fr; gap:5px; }
408
+ .slider-container { display:flex; align-items:center; gap:5px; margin-top:5px; }
409
+ .action-btn { background:#4CAF50; color:white; } .reset-btn { background:#e74c3c; color:white; } .secondary-btn { background:#f39c12; color:white; } .export-btn { background:#2196F3; color:white; } .save-btn { background:#9b59b6; color:white; }
 
 
 
 
 
 
410
 
411
  /* MODAL */
412
+ .modal-overlay { position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.7); display:none; justify-content:center; align-items:center; z-index:9999; }
413
+ .modal-content { background:white; padding:30px; border-radius:12px; max-width:400px; width:90%; text-align:center; }
414
+ .modal-content .code { font-size:32px; font-weight:bold; letter-spacing:4px; background:#f0f0f0; padding:15px 25px; border-radius:8px; display:inline-block; margin:15px 0; font-family:monospace; user-select:all; }
 
415
  </style>
416
+ </head> <body> <div id="upload-container"> <div class="upload-box"> <h1>🎬 Manual Drag Comic Architect</h1> <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name"> <label for="file-upload" class="file-label">📁 Choose Video File</label> <span id="fn" style="margin-bottom:10px; display:block; color:#666;">No file selected</span>
417
  <div class="page-input-group">
418
  <label>📚 Total Comic Pages:</label>
419
+ <input type="number" id="page-count" value="3" min="1" max="15" placeholder="e.g. 3">
 
420
  </div>
 
421
  <button class="submit-btn" onclick="upload()">🚀 Generate Comic</button>
422
  <button id="restore-draft-btn" class="restore-btn" style="display:none; margin-top:15px;" onclick="restoreDraft()">📂 Restore Unsaved Draft</button>
 
423
  <div class="load-section">
424
  <h3>📥 Load Saved Comic</h3>
425
  <div class="load-input-group">
 
437
  <div class="comic-wrapper" id="comic-container"></div>
438
  <input type="file" id="image-uploader" style="display: none;" accept="image/*">
439
  <div class="edit-controls">
440
+ <h4>✏️ Architect Editor</h4>
441
 
442
  <button onclick="undoLastAction()" class="undo-btn">↩️ Undo</button>
443
 
444
+ <div class="control-group">
445
+ <label>📐 Layout Settings:</label>
446
+ <div class="slider-container">
447
+ <label>Gutter:</label>
448
+ <input type="range" id="gutter-slider" min="0" max="20" value="10" oninput="updateGutter(this.value)">
449
+ <span id="gutter-val">10px</span>
450
+ </div>
451
+ <small style="color:#aaa; font-size:10px;">Drag red dots on panels to resize.</small>
452
+ </div>
453
+
454
  <div class="control-group">
455
  <label>💾 Save & Load:</label>
456
  <button onclick="saveComic()" class="save-btn">💾 Save Comic</button>
 
468
  <option value="reaction">Reaction (Shout)</option>
469
  <option value="narration">Narration (Box)</option>
470
  </select>
 
 
 
 
 
 
471
  <div class="color-grid">
472
  <div><label>Text</label><input type="color" id="bubble-text-color" value="#ffffff" disabled></div>
473
  <div><label>Fill</label><input type="color" id="bubble-fill-color" value="#4ECDC4" disabled></div>
474
  </div>
475
  <div class="button-grid">
476
+ <button onclick="addBubble()" class="action-btn">Add Bubble</button>
477
  <button onclick="deleteBubble()" class="reset-btn">Delete</button>
478
  </div>
479
  </div>
480
 
 
 
 
 
 
 
 
 
 
481
  <div class="control-group">
482
  <label>🖼️ Panel Tools:</label>
483
  <button onclick="replacePanelImage()" class="action-btn">🖼️ Replace Image</button>
484
  <div class="button-grid">
485
+ <button onclick="adjustFrame('backward')" class="secondary-btn">⬅️ Prev Frame</button>
486
+ <button onclick="adjustFrame('forward')" class="action-btn">Next Frame ➡️</button>
 
 
 
 
487
  </div>
488
  </div>
489
 
 
519
  let interval, selectedBubble = null, selectedPanel = null;
520
  let isDragging = false, isResizing = false, isPanning = false;
521
  let startX, startY, initX, initY, panStartX, panStartY, panStartTx, panStartTy;
522
+ let activeLayoutHandle = null, activePageId = null;
523
+
524
+ // Page Layout Data Store (Default)
525
+ const DEFAULT_LAYOUT = {
526
+ width: 1000, height: 700, gutter: 10, tierY: 350,
527
+ r1: { topX: 635, botX: 588 },
528
+ r2L: { topX: 293, botX: 326 },
529
+ r2R: { topX: 617, botX: 666 }
530
+ };
531
 
 
532
  let historyStack = [];
533
  let historyIndex = -1;
534
  function addToHistory() {
535
+ if (historyIndex < historyStack.length - 1) historyStack = historyStack.slice(0, historyIndex + 1);
 
 
536
  const state = JSON.stringify(getCurrentState());
537
  if (historyStack.length > 0 && historyStack[historyStack.length - 1] === state) return;
 
538
  historyStack.push(state);
539
  historyIndex++;
540
+ if (historyStack.length > 30) { historyStack.shift(); historyIndex--; }
 
 
 
 
541
  }
542
  function undoLastAction() {
543
  if (historyIndex > 0) {
544
  historyIndex--;
545
+ renderFromState(JSON.parse(historyStack[historyIndex]));
 
546
  saveDraft(false);
547
  }
548
  }
 
554
  function copyModalCode() { navigator.clipboard.writeText(document.getElementById('modal-save-code').textContent).then(() => alert('Code copied!')); }
555
  function copyCode() { if(currentSaveCode) navigator.clipboard.writeText(currentSaveCode).then(() => alert('Code copied!')); }
556
 
 
 
 
 
 
 
 
 
557
  async function saveComic() {
558
  const state = getCurrentState();
 
559
  try {
560
  const r = await fetch(`/save_comic?sid=${sid}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ pages: state, savedAt: new Date().toISOString() }) });
561
  const d = await r.json();
 
566
 
567
  async function loadSavedComic() {
568
  const code = document.getElementById('load-code-input').value.trim().toUpperCase();
 
569
  try {
570
  const r = await fetch(`/load_comic/${code}`);
571
  const d = await r.json();
 
585
  } catch(e) { console.error(e); alert("Failed to restore."); }
586
  }
587
 
 
588
  function getCurrentState() {
589
  const pages = [];
590
+ document.querySelectorAll('.comic-page').forEach((p, idx) => {
591
+ const layout = JSON.parse(p.dataset.layout || JSON.stringify(DEFAULT_LAYOUT));
592
  const panels = [];
593
+ const bubbles = [];
594
 
595
  p.querySelectorAll('.panel').forEach(pan => {
596
  const img = pan.querySelector('img');
597
  panels.push({
598
  src: img.src,
599
+ zoom: img.dataset.zoom, tx: img.dataset.translateX, ty: img.dataset.translateY, baseScale: img.dataset.baseScale,
 
 
 
 
 
 
600
  bubbles: []
601
  });
602
  });
 
603
  p.querySelectorAll('.speech-bubble').forEach(b => {
604
  const textEl = b.querySelector('.bubble-text');
605
  bubbles.push({
 
611
  colors: { fill: b.style.getPropertyValue('--bubble-fill-color'), text: b.style.getPropertyValue('--bubble-text-color') }
612
  });
613
  });
614
+ pages.push({ panels: panels, pageBubbles: bubbles, layout: layout });
 
615
  });
616
  return pages;
617
  }
 
624
  function renderFromState(pagesData) {
625
  const con = document.getElementById('comic-container'); con.innerHTML = '';
626
  pagesData.forEach((page, pageIdx) => {
627
+ const pageId = `page-${pageIdx}`;
628
+ const layout = page.layout || JSON.parse(JSON.stringify(DEFAULT_LAYOUT));
629
+
630
  const pageWrapper = document.createElement('div'); pageWrapper.className = 'page-wrapper';
631
+ pageWrapper.innerHTML = `<h2 class='page-title'>Page ${pageIdx + 1}</h2>`;
 
632
 
633
  const div = document.createElement('div');
634
  div.className = 'comic-page';
635
+ div.id = pageId;
636
+ div.dataset.layout = JSON.stringify(layout);
637
 
638
+ // Generate Panels
639
+ for(let i=0; i<5; i++) {
640
  const pDiv = document.createElement('div');
641
+ pDiv.className = 'panel'; pDiv.id = `${pageId}-p${i}`;
 
642
 
643
+ const panData = page.panels[i] || { src: '' };
644
  const img = document.createElement('img');
645
+ img.onload = () => fitImageToPanel(img, pDiv, panData);
646
+ img.src = panData.src.includes('?') ? panData.src : panData.src + `?sid=${sid}`;
 
647
  img.onmousedown = (e) => startPan(e, img);
 
648
 
649
+ pDiv.appendChild(img);
650
+ pDiv.onclick = (e) => { e.stopPropagation(); selectPanel(pDiv); };
 
 
651
  div.appendChild(pDiv);
652
+ }
653
 
654
+ // Generate Handles
655
+ const handles = [
656
+ {id: 'h1-top', t:0, l:0}, {id: 'h1-bot', t:0, l:0},
657
+ {id: 'h2-l-top', t:0, l:0}, {id: 'h2-l-bot', t:0, l:0},
658
+ {id: 'h2-r-top', t:0, l:0}, {id: 'h2-r-bot', t:0, l:0},
659
+ {id: 'h-tier', t:0, l:0, cls: 'horiz'}
660
+ ];
661
+ handles.forEach(h => {
662
+ const hDiv = document.createElement('div');
663
+ hDiv.className = `handle ${h.cls||''}`; hDiv.id = `${pageId}-${h.id}`;
664
+ hDiv.dataset.role = h.id;
665
+ hDiv.onmousedown = (e) => startDragHandle(e, pageId, h.id);
666
+ div.appendChild(hDiv);
667
+ });
668
+
669
+ // Bubbles
670
  if(page.pageBubbles) {
671
+ page.pageBubbles.forEach(bData => div.appendChild(createBubbleHTML(bData)));
 
 
672
  }
673
 
 
674
  div.onclick = (e) => {
675
+ if(e.target === div || e.target.classList.contains('comic-page')) {
676
  if(selectedBubble) selectedBubble.classList.remove('selected'); selectedBubble = null;
677
  if(selectedPanel) selectedPanel.classList.remove('selected'); selectedPanel = null;
678
  }
679
+ };
680
 
681
  pageWrapper.appendChild(div);
682
  con.appendChild(pageWrapper);
683
+
684
+ // Draw Initial Layout
685
+ drawLayout(pageId);
686
  });
687
+
 
688
  document.getElementById('bubble-type-select').disabled = true;
689
  document.getElementById('font-select').disabled = true;
690
  }
691
 
692
+ // --- LAYOUT ENGINE ---
693
+ function drawLayout(pageId) {
694
+ const pageEl = document.getElementById(pageId);
695
+ if(!pageEl) return;
696
+ const state = JSON.parse(pageEl.dataset.layout);
697
+ const g = state.gutter / 2;
698
+ const ty = state.tierY;
699
+ const w = state.width;
700
+ const h = state.height;
701
+
702
+ // Draw Panels (Using simple absolute positioning + clip-path)
703
+ // P1: Top Left
704
+ const p1 = document.getElementById(`${pageId}-p0`);
705
+ p1.style.top='0px'; p1.style.left='0px'; p1.style.width='100%'; p1.style.height = ty + 'px';
706
+ p1.style.clipPath = `polygon(0 0, ${state.r1.topX - g}px 0, ${state.r1.botX - g}px ${ty}px, 0 ${ty}px)`;
707
+
708
+ // P2: Top Right
709
+ const p2 = document.getElementById(`${pageId}-p1`);
710
+ p2.style.top='0px'; p2.style.left='0px'; p2.style.width='100%'; p2.style.height = ty + 'px';
711
+ p2.style.clipPath = `polygon(${state.r1.topX + g}px 0, ${w}px 0, ${w}px ${ty}px, ${state.r1.botX + g}px ${ty}px)`;
712
+
713
+ // P3: Bottom Left
714
+ const p3 = document.getElementById(`${pageId}-p2`);
715
+ p3.style.top = ty + 'px'; p3.style.left='0px'; p3.style.width='100%'; p3.style.height = (h - ty) + 'px';
716
+ p3.style.clipPath = `polygon(0 ${g}px, ${state.r2L.topX - g}px ${g}px, ${state.r2L.botX - g}px ${h-ty}px, 0 ${h-ty}px)`;
717
+
718
+ // P4: Bottom Center
719
+ const p4 = document.getElementById(`${pageId}-p3`);
720
+ p4.style.top = ty + 'px'; p4.style.left='0px'; p4.style.width='100%'; p4.style.height = (h - ty) + 'px';
721
+ p4.style.clipPath = `polygon(${state.r2L.topX + g}px ${g}px, ${state.r2R.topX - g}px ${g}px, ${state.r2R.botX - g}px ${h-ty}px, ${state.r2L.botX + g}px ${h-ty}px)`;
722
+
723
+ // P5: Bottom Right
724
+ const p5 = document.getElementById(`${pageId}-p4`);
725
+ p5.style.top = ty + 'px'; p5.style.left='0px'; p5.style.width='100%'; p5.style.height = (h - ty) + 'px';
726
+ p5.style.clipPath = `polygon(${state.r2R.topX + g}px ${g}px, ${w}px ${g}px, ${w}px ${h-ty}px, ${state.r2R.botX + g}px ${h-ty}px)`;
727
+
728
+ // Update Handles Position
729
+ const setH = (id, l, t) => { const el = document.getElementById(`${pageId}-${id}`); if(el) { el.style.left=l+'px'; el.style.top=t+'px'; } };
730
+
731
+ setH('h1-top', state.r1.topX, 0);
732
+ setH('h1-bot', state.r1.botX, ty);
733
+ setH('h2-l-top', state.r2L.topX, ty);
734
+ setH('h2-l-bot', state.r2L.botX, h);
735
+ setH('h2-r-top', state.r2R.topX, ty);
736
+ setH('h2-r-bot', state.r2R.botX, h);
737
+ setH('h-tier', w/2, ty);
738
+ }
739
+
740
+ function startDragHandle(e, pageId, handleRole) {
741
+ e.stopPropagation(); e.preventDefault();
742
+ activeLayoutHandle = handleRole;
743
+ activePageId = pageId;
744
+ isDragging = true; // Use global drag flag to prevent conflicting events
745
+ }
746
+
747
+ // --- GLOBAL MOUSE EVENTS ---
748
+ document.addEventListener('mousemove', (e) => {
749
+ if(isDragging && activeLayoutHandle && activePageId) {
750
+ const pageEl = document.getElementById(activePageId);
751
+ const rect = pageEl.getBoundingClientRect();
752
+ const state = JSON.parse(pageEl.dataset.layout);
753
+ const x = Math.max(0, Math.min(1000, (e.clientX - rect.left) * (1000 / rect.width))); // Scale to 1000px base
754
+ const y = Math.max(50, Math.min(650, (e.clientY - rect.top) * (700 / rect.height)));
755
+
756
+ if(activeLayoutHandle === 'h1-top') state.r1.topX = x;
757
+ else if(activeLayoutHandle === 'h1-bot') state.r1.botX = x;
758
+ else if(activeLayoutHandle === 'h2-l-top') state.r2L.topX = x;
759
+ else if(activeLayoutHandle === 'h2-l-bot') state.r2L.botX = x;
760
+ else if(activeLayoutHandle === 'h2-r-top') state.r2R.topX = x;
761
+ else if(activeLayoutHandle === 'h2-r-bot') state.r2R.botX = x;
762
+ else if(activeLayoutHandle === 'h-tier') state.tierY = y;
763
+
764
+ pageEl.dataset.layout = JSON.stringify(state);
765
+ drawLayout(activePageId);
766
+ }
767
+ else if(isDragging && selectedBubble) {
768
+ // Bubble Drag
769
+ selectedBubble.style.left = (initX + e.clientX - startX) + 'px';
770
+ selectedBubble.style.top = (initY + e.clientY - startY) + 'px';
771
+ }
772
+ else if(isResizing && selectedBubble) { resizeBubble(e); }
773
+ else if(isPanning && selectedPanel) { panImage(e); }
774
+ });
775
+
776
+ document.addEventListener('mouseup', () => {
777
+ if(activeLayoutHandle) { activeLayoutHandle = null; activePageId = null; saveDraft(true); }
778
+ if(isDragging || isResizing || isPanning) { saveDraft(true); }
779
+ isDragging = false; isResizing = false; isPanning = false;
780
+ });
781
+
782
+ function updateGutter(val) {
783
+ document.getElementById('gutter-val').innerText = val + 'px';
784
+ document.querySelectorAll('.comic-page').forEach(p => {
785
+ const s = JSON.parse(p.dataset.layout);
786
+ s.gutter = parseInt(val);
787
+ p.dataset.layout = JSON.stringify(s);
788
+ drawLayout(p.id);
789
+ });
790
+ }
791
+
792
+ // --- IMAGE FIT & TRANSFORM ---
793
  function fitImageToPanel(img, panel, savedState) {
794
  if(savedState && savedState.baseScale) {
 
795
  img.dataset.zoom = savedState.zoom || 100;
796
  img.dataset.translateX = savedState.tx || 0;
797
  img.dataset.translateY = savedState.ty || 0;
 
800
  return;
801
  }
802
 
803
+ // Smart Fit: Fill container but allow freedom
804
+ // Since panels are now width: 100%, we calculate against the container size
805
+ const pW = 1000; // Fixed Base Width
806
+ const pH = panel.offsetHeight; // Row Height
807
  const iW = img.naturalWidth || img.width;
808
  const iH = img.naturalHeight || img.height;
809
 
 
810
  const scale = Math.max(pW / iW, pH / iH);
 
 
811
  const tx = (pW - iW * scale) / 2;
812
  const ty = (pH - iH * scale) / 2;
813
 
 
 
 
 
 
 
 
814
  img.dataset.baseScale = scale;
815
  img.dataset.translateX = tx;
816
  img.dataset.translateY = ty;
817
  img.dataset.zoom = 100;
 
818
  updateImageTransform(img);
819
  }
820
+
821
+ function updateImageTransform(img) {
822
+ const z = (img.dataset.zoom || 100) / 100;
823
+ const tx = parseFloat(img.dataset.translateX || 0);
824
+ const ty = parseFloat(img.dataset.translateY || 0);
825
+ const baseScale = parseFloat(img.dataset.baseScale || 1);
826
+ const finalScale = baseScale * z;
827
+ img.style.transform = `translate(${tx}px, ${ty}px) scale(${finalScale})`;
828
+ img.classList.toggle('pannable', true);
 
 
 
 
 
 
 
 
 
 
 
 
829
  }
830
+
831
+ function startPan(e, img) { e.preventDefault(); isPanning = true; selectedPanel = img.closest('.panel'); panStartX = e.clientX; panStartY = e.clientY; panStartTx = parseFloat(img.dataset.translateX || 0); panStartTy = parseFloat(img.dataset.translateY || 0); img.classList.add('panning'); }
832
+ function panImage(e) { if(!isPanning || !selectedPanel) return; const img = selectedPanel.querySelector('img'); img.dataset.translateX = panStartTx + (e.clientX - panStartX); img.dataset.translateY = panStartTy + (e.clientY - panStartY); updateImageTransform(img); }
833
+ function handleZoom(el) { if(!selectedPanel) return; const img = selectedPanel.querySelector('img'); img.dataset.zoom = el.value; updateImageTransform(img); }
834
+ function resetPanelTransform() { if(!selectedPanel) return alert("Select a panel"); const img = selectedPanel.querySelector('img'); fitImageToPanel(img, selectedPanel, null); saveDraft(true); }
835
+
836
+ // --- STANDARD FUNCTIONS (Bubbles, selection, upload etc) ---
837
+ function selectPanel(el) {
838
+ if(selectedPanel) selectedPanel.classList.remove('selected');
839
+ if(selectedBubble) { selectedBubble.classList.remove('selected'); selectedBubble = null; }
840
+ selectedPanel = el; el.classList.add('selected');
841
+ document.getElementById('zoom-slider').disabled = false;
842
+ const img = el.querySelector('img');
843
+ document.getElementById('zoom-slider').value = img.dataset.zoom || 100;
844
+ document.getElementById('bubble-type-select').disabled = true; document.getElementById('font-select').disabled = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
845
  }
846
 
847
  function createBubbleHTML(data) {
 
849
  const type = data.type || 'speech';
850
  b.className = data.classes || `speech-bubble ${type} tail-bottom`;
851
  if (type === 'thought' && !b.className.includes('pos-')) b.className += ' pos-bl';
 
852
  b.dataset.type = type;
853
  b.style.left = data.left; b.style.top = data.top;
854
  if(data.font) b.style.fontFamily = data.font;
855
  if(data.colors) { b.style.setProperty('--bubble-fill-color', data.colors.fill || '#4ECDC4'); b.style.setProperty('--bubble-text-color', data.colors.text || '#ffffff'); }
856
  if(data.tailPos) b.style.setProperty('--tail-pos', data.tailPos);
 
857
  const textSpan = document.createElement('span'); textSpan.className = 'bubble-text'; textSpan.textContent = data.text || ''; b.appendChild(textSpan);
 
858
  if(type === 'thought') { for(let i=1; i<=2; i++){ const d = document.createElement('div'); d.className = `thought-dot thought-dot-${i}`; b.appendChild(d); } }
 
859
  ['nw', 'ne', 'sw', 'se'].forEach(dir => { const handle = document.createElement('div'); handle.className = `resize-handle ${dir}`; handle.onmousedown = (e) => startResize(e, dir); b.appendChild(handle); });
 
860
  b.onmousedown = (e) => {
861
  if(e.target.classList.contains('resize-handle')) return;
862
  e.stopPropagation(); selectBubble(b); isDragging = true; startX = e.clientX; startY = e.clientY; initX = b.offsetLeft; initY = b.offsetTop;
 
865
  b.ondblclick = (e) => { e.stopPropagation(); editBubbleText(b); };
866
  return b;
867
  }
868
+ function selectBubble(el) { if(selectedBubble) selectedBubble.classList.remove('selected'); if(selectedPanel) { selectedPanel.classList.remove('selected'); selectedPanel = null; } selectedBubble = el; el.classList.add('selected'); document.getElementById('bubble-type-select').disabled = false; document.getElementById('font-select').disabled = false; document.getElementById('bubble-text-color').disabled = false; document.getElementById('bubble-fill-color').disabled = false; document.getElementById('bubble-type-select').value = el.dataset.type || 'speech'; }
869
+ function addBubble() { if(!selectedPanel) return alert("Select a panel (click an image) to add bubble to that page."); const pageDiv = selectedPanel.closest('.comic-page'); const b = createBubbleHTML({ text: "Text", left: "100px", top: "100px", type: 'speech', classes: "speech-bubble speech tail-bottom" }); pageDiv.appendChild(b); selectBubble(b); saveDraft(true); }
870
+ function deleteBubble() { if(!selectedBubble) return alert("Select a bubble"); selectedBubble.remove(); selectedBubble=null; saveDraft(true); }
871
+ function changeBubbleType(type) { if(!selectedBubble) return; selectedBubble.dataset.type = type; selectedBubble.className = 'speech-bubble ' + type + ' selected'; if(type === 'thought') selectedBubble.classList.add('pos-bl'); else selectedBubble.classList.add('tail-bottom'); selectedBubble.querySelectorAll('.thought-dot').forEach(d=>d.remove()); if(type === 'thought') { for(let i=1; i<=2; i++){ const d = document.createElement('div'); d.className = `thought-dot thought-dot-${i}`; selectedBubble.appendChild(d); } } saveDraft(true); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
872
  function changeFont(font) { if(!selectedBubble) return; selectedBubble.style.fontFamily = font; saveDraft(true); }
873
+ function rotateTail() { if(!selectedBubble) return; const type = selectedBubble.dataset.type; if(type === 'speech') { const positions = ['tail-bottom', 'tail-right', 'tail-top', 'tail-left']; let current = 0; positions.forEach((pos, i) => { if(selectedBubble.classList.contains(pos)) current = i; }); selectedBubble.classList.remove(positions[current]); selectedBubble.classList.add(positions[(current + 1) % 4]); } else if (type === 'thought') { const positions = ['pos-bl', 'pos-br', 'pos-tr', 'pos-tl']; let current = 0; positions.forEach((pos, i) => { if(selectedBubble.classList.contains(pos)) current = i; }); selectedBubble.classList.remove(positions[current]); selectedBubble.classList.add(positions[(current + 1) % 4]); } saveDraft(true); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
874
  function slideTail(v) { if(selectedBubble) { selectedBubble.style.setProperty('--tail-pos', v+'%'); saveDraft(true); } }
 
875
  document.getElementById('bubble-text-color').addEventListener('change', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-text-color', e.target.value); saveDraft(true); } });
876
  document.getElementById('bubble-fill-color').addEventListener('change', (e) => { if(selectedBubble) { selectedBubble.style.setProperty('--bubble-fill-color', e.target.value); saveDraft(true); } });
877
+ function startResize(e, dir) { e.preventDefault(); e.stopPropagation(); isResizing = true; resizeHandle = dir; const rect = selectedBubble.getBoundingClientRect(); originalWidth = rect.width; originalHeight = rect.height; originalMouseX = e.clientX; originalMouseY = e.clientY; }
878
+ function resizeBubble(e) { if (!isResizing || !selectedBubble) return; const dx = e.clientX - originalMouseX; const dy = e.clientY - originalMouseY; if(resizeHandle.includes('e')) selectedBubble.style.width = (originalWidth + dx)+'px'; if(resizeHandle.includes('s')) selectedBubble.style.height = (originalHeight + dy)+'px'; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
879
  function replacePanelImage() { if(!selectedPanel) return alert("Select a panel"); const inp = document.getElementById('image-uploader'); inp.onchange = async (e) => { const fd = new FormData(); fd.append('image', e.target.files[0]); const img = selectedPanel.querySelector('img'); const r = await fetch(`/replace_panel?sid=${sid}`, {method:'POST', body:fd}); const d = await r.json(); if(d.success) { img.src = `/frames/${d.new_filename}?sid=${sid}`; saveDraft(true); } inp.value = ''; }; inp.click(); }
880
  async function adjustFrame(dir) { if(isProcessing) return; if(!selectedPanel) return alert("Select a panel"); const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0]; setProcessing(true); img.style.opacity = '0.5'; try { const r = await fetch(`/regenerate_frame?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, direction:dir}) }); const d = await r.json(); if(d.success) { img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`; } else { alert('Error: ' + d.message); } } catch(e) { console.error(e); } img.style.opacity = '1'; setProcessing(false); saveDraft(true); }
881
  async function gotoTimestamp() { if(isProcessing) return; if(!selectedPanel) return alert("Select a panel"); let v = document.getElementById('timestamp-input').value.trim(); if(!v) return; if(v.includes(':')) { let p = v.split(':'); v = parseInt(p[0]) * 60 + parseFloat(p[1]); } else { v = parseFloat(v); } if(isNaN(v)) return alert("Invalid time"); const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0]; setProcessing(true); img.style.opacity = '0.5'; try { const r = await fetch(`/goto_timestamp?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, timestamp:v}) }); const d = await r.json(); if(d.success) { img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`; document.getElementById('timestamp-input').value = ''; } else { alert('Error: ' + d.message); } } catch(e) { console.error(e); } img.style.opacity = '1'; setProcessing(false); saveDraft(true); }
882
+ async function upload() { const f = document.getElementById('file-upload').files[0]; const pCount = document.getElementById('page-count').value; if(!f) return alert("Select a video"); sid = genUUID(); localStorage.setItem('comic_sid', sid); currentSaveCode = null; document.querySelector('.upload-box').style.display='none'; document.getElementById('loading-view').style.display='flex'; const fd = new FormData(); fd.append('file', f); fd.append('target_pages', pCount); const r = await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd}); if(r.ok) interval = setInterval(checkStatus, 2000); else { alert("Upload failed"); location.reload(); } }
883
+ async function checkStatus() { try { const r = await fetch(`/status?sid=${sid}`); const d = await r.json(); document.getElementById('status-text').innerText = d.message; if(d.progress >= 100) { clearInterval(interval); document.getElementById('upload-container').style.display='none'; document.getElementById('editor-container').style.display='block'; loadNewComic(); } else if (d.progress < 0) { clearInterval(interval); document.getElementById('status-text').textContent = "Error: " + d.message; document.querySelector('.loader').style.display = 'none'; } } catch(e) {} }
884
+ function loadNewComic() { fetch(`/output/pages.json?sid=${sid}`).then(r=>r.json()).then(data => { const cleanData = data.map((p, pi) => { const panels = []; const pageBubbles = []; const panelCenters = [{x: 315, y: 175}, {x: 790, y: 175}, {x: 160, y: 535}, {x: 480, y: 535}, {x: 800, y: 535}]; p.panels.forEach((pan, j) => { panels.push({ src: `/frames/${pan.image}?sid=${sid}`, bubbles: [] }); if(p.bubbles && p.bubbles[j] && p.bubbles[j].dialog) { const center = panelCenters[j % 5] || {x:500, y:350}; pageBubbles.push({ text: p.bubbles[j].dialog, left: (center.x - 75) + 'px', top: (center.y - 40) + 'px', type: (p.bubbles[j].type || 'speech'), classes: `speech-bubble ${p.bubbles[j].type || 'speech'} tail-bottom` }); } }); return { panels: panels, pageBubbles: pageBubbles }; }); renderFromState(cleanData); saveDraft(true); }); }
885
+ function editBubbleText(bubble) { if (currentlyEditing) return; currentlyEditing = bubble; const textSpan = bubble.querySelector('.bubble-text'); const textarea = document.createElement('textarea'); textarea.value = textSpan.textContent; bubble.appendChild(textarea); textSpan.style.display = 'none'; textarea.focus(); const finishEditing = () => { textSpan.textContent = textarea.value; textarea.remove(); textSpan.style.display = ''; currentlyEditing = null; saveDraft(true); }; textarea.addEventListener('blur', finishEditing, { once: true }); textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); } }); }
886
+ async function exportComic() { const pgs = document.querySelectorAll('.comic-page'); if(pgs.length === 0) return alert("No pages found"); if(selectedBubble) selectedBubble.classList.remove('selected'); if(selectedPanel) selectedPanel.classList.remove('selected'); alert(`Exporting ${pgs.length} page(s)...`);
887
+ // Hide handles for export
888
+ const handles = document.querySelectorAll('.handle'); handles.forEach(h=>h.style.display='none');
889
+ for(let i = 0; i < pgs.length; i++) { try { const u = await htmlToImage.toPng(pgs[i], { pixelRatio: 2, style: { transform: 'none' } }); const a = document.createElement('a'); a.href = u; a.download = `Comic-Page-${i+1}.png`; a.click(); } catch(err) { console.error(err); alert(`Failed to export page ${i+1}`); } }
890
+ handles.forEach(h=>h.style.display='block');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
891
  }
 
892
  function goBackToUpload() { if(confirm('Go home? Unsaved changes will be lost.')) { document.getElementById('editor-container').style.display = 'none'; document.getElementById('upload-container').style.display = 'flex'; document.getElementById('loading-view').style.display = 'none'; } }
893
  </script>
894
  </body> </html> '''