jhh6576 commited on
Commit
942aaf2
·
verified ·
1 Parent(s): 34b0d80

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +118 -45
app_enhanced.py CHANGED
@@ -14,6 +14,7 @@ from typing import List
14
  import traceback
15
 
16
  # --- ROBUST IMPORTS WITH FALLBACKS ---
 
17
  try:
18
  from backend.keyframes.keyframes import black_bar_crop
19
  print("✅ Black bar cropping module loaded.")
@@ -28,9 +29,8 @@ try:
28
  except Exception as e:
29
  print(f"⚠️ Could not load SimpleColorEnhancer: {e}. This feature will be SKIPPED.")
30
  class SimpleColorEnhancer:
31
- def enhance_batch(self, *args, **kwargs):
32
- print("-> Skipping simple color enhancement (module not loaded).")
33
- pass
34
 
35
  try:
36
  from backend.quality_color_enhancer import QualityColorEnhancer
@@ -38,9 +38,9 @@ try:
38
  except Exception as e:
39
  print(f"⚠️ Could not load QualityColorEnhancer: {e}. This feature will be SKIPPED.")
40
  class QualityColorEnhancer:
41
- def batch_enhance(self, *args, **kwargs):
42
- print("-> Skipping quality color enhancement (module not loaded).")
43
- pass
44
 
45
  try:
46
  from backend.class_def import bubble, panel, Page
@@ -306,14 +306,14 @@ class EnhancedComicGenerator:
306
  cap.release()
307
  if not ret or frame is None:
308
  return {"success": False, "message": f"No frame at {target_time:.2f}s."}
 
309
  new_path = os.path.join(self.frames_dir, frame_filename)
310
  cv2.imwrite(new_path, frame)
311
 
312
- # <<< MODIFICATION START: Apply enhancements to the new frame for consistency >>>
313
  print(f"🎨 Applying enhancements to the new frame: {frame_filename}")
314
  self._enhance_all_images(single_image_path=new_path)
315
  self._enhance_quality_colors(single_image_path=new_path)
316
- # <<< MODIFICATION END >>>
317
 
318
  if isinstance(frame_to_time[frame_filename], dict):
319
  frame_to_time[frame_filename]['time'] = target_time
@@ -343,14 +343,14 @@ class EnhancedComicGenerator:
343
  ret, frame = cap.read()
344
  cap.release()
345
  if not ret or frame is None: return {"success": False, "message": f"Could not retrieve frame at {timestamp_seconds:.2f}s."}
 
346
  new_path = os.path.join(self.frames_dir, frame_filename)
347
  cv2.imwrite(new_path, frame)
348
-
349
- # <<< MODIFICATION START: Apply enhancements to the new frame for consistency >>>
350
  print(f"🎨 Applying enhancements to the new frame from timestamp: {frame_filename}")
351
  self._enhance_all_images(single_image_path=new_path)
352
  self._enhance_quality_colors(single_image_path=new_path)
353
- # <<< MODIFICATION END >>>
354
 
355
  with open(metadata_path, 'r') as f: frame_to_time = json.load(f)
356
  if frame_filename in frame_to_time:
@@ -437,20 +437,42 @@ class EnhancedComicGenerator:
437
  update_status(f"Error: {e}", -1)
438
  return False
439
 
 
440
  def _enhance_all_images(self, single_image_path=None):
441
- target_dir = self.frames_dir
442
- if single_image_path: target_dir = os.path.dirname(single_image_path)
443
- if not os.path.exists(target_dir): return
444
  try:
445
- SimpleColorEnhancer().enhance_batch(target_dir)
446
- except Exception as e: print(f"❌ Simple enhancement failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
447
 
448
  def _enhance_quality_colors(self, single_image_path=None):
449
- target_dir = self.frames_dir
450
- if single_image_path: target_dir = os.path.dirname(single_image_path)
451
  try:
452
- QualityColorEnhancer().batch_enhance(target_dir)
453
- except Exception as e: print(f"⚠️ Quality enhancement failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
454
 
455
  def _create_ai_bubbles_from_moments(self, black_x, black_y):
456
  bubbles, frame_files = [], sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
@@ -502,7 +524,6 @@ class EnhancedComicGenerator:
502
 
503
  def _copy_template_files(self):
504
  try:
505
- # <<< MODIFICATION START: Updated CSS rules for a longer bubble tail >>>
506
  template_html = '''<!DOCTYPE html>
507
  <html lang="en">
508
  <head>
@@ -524,30 +545,50 @@ class EnhancedComicGenerator:
524
  .panel img { width: 100%; height: 100%; object-fit: cover; object-position: center; transition: transform 0.1s ease-out; }
525
  .panel img.pannable { cursor: grab; }
526
  .panel img.panning { cursor: grabbing; }
527
- .speech-bubble { font-family: 'Comic Neue', cursive; position: absolute; display: flex; justify-content: center; align-items: center; width: 150px; height: 80px; min-width: 50px; min-height: 30px; box-sizing: border-box; padding: 8px; box-shadow: 2px 2px 5px rgba(0,0,0,0.3); z-index: 10; cursor: move; overflow: visible; font-size: 13px; font-weight: bold; text-align: center; }
528
  .bubble-text { padding: 2px; word-wrap: break-word; }
529
  .speech-bubble.selected { outline: 2px dashed #4CAF50; }
530
  .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); font: inherit; text-align: center; resize: none; padding: 8px; z-index: 102; }
531
- .speech-bubble.speech { background: white; border: 2px solid #333; color: #333; border-radius: 15px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
  .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
533
  .speech-bubble.reaction { background: #FFD700; border: 3px solid #E53935; color: #D32F2F; font-weight: 900; text-transform: uppercase; width: 180px; 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%); }
534
  .speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; }
535
  .speech-bubble.idea { background: linear-gradient(180deg,#FFFDD0 0%, #FFF8B5 100%); border: 2px solid #FFA500; color: #6a4b00; border-radius: 40% 60% 40% 60% / 60% 40% 60% 40%; }
536
- .speech-bubble.speech::after, .speech-bubble.idea::after { content: ''; position: absolute; width: 0; height: 0; border-left: 10px solid transparent; border-right: 10px solid transparent; }
537
- /* <<< CSS MODIFICATION: Made bubble tail longer >>> */
538
- .speech-bubble.speech::after { border-top: 20px solid #333; bottom: -19px; left: 20px; }
539
- .speech-bubble.idea::after { border-top: 20px solid #FFA500; bottom: -19px; left: 20px; }
540
  .speech-bubble.thought::after { display: none; }
541
  .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
542
  .thought-dot-1 { width: 20px; height: 20px; bottom: -20px; left: 15px; }
543
  .thought-dot-2 { width: 12px; height: 12px; bottom: -32px; left: 5px; }
544
- .speech-bubble.flipped.speech::after, .speech-bubble.flipped.idea::after { left: auto; right: 20px; }
545
- .speech-bubble.flipped.thought .thought-dot-1 { left: auto; right: 15px; }
546
- .speech-bubble.flipped.thought .thought-dot-2 { left: auto; right: 5px; }
547
- /* <<< CSS MODIFICATION: Adjusted vertically flipped tail position >>> */
548
- .speech-bubble.flipped-vertical.speech::after, .speech-bubble.flipped-vertical.idea::after { bottom: auto; top: -19px; transform: rotate(180deg); }
549
- .speech-bubble.flipped-vertical.thought .thought-dot-1 { bottom: auto; top: -20px; }
550
- .speech-bubble.flipped-vertical.thought .thought-dot-2 { bottom: auto; top: -32px; }
551
  .resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; z-index: 11; }
552
  .speech-bubble.selected .resize-handle { display: block; }
553
  .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
@@ -691,7 +732,12 @@ class EnhancedComicGenerator:
691
  if(currentlySelectedBubble) currentlySelectedBubble.style.color = e.target.value;
692
  });
693
  document.getElementById('bubble-fill-color').addEventListener('input', (e) => {
694
- if(currentlySelectedBubble) currentlySelectedBubble.style.backgroundColor = e.target.value;
 
 
 
 
 
695
  });
696
 
697
  document.addEventListener('mousemove', e => { if (isPanning) panImage(e); if (draggedBubble) drag(e); if(isResizing) resizeBubble(e); });
@@ -737,13 +783,19 @@ class EnhancedComicGenerator:
737
 
738
  function applyBubbleType(bubble, type) {
739
  bubble.querySelectorAll('.thought-dot').forEach(el => el.remove());
 
740
  let classesToKeep = 'speech-bubble';
741
  if (bubble.classList.contains('selected')) classesToKeep += ' selected';
742
- if (bubble.classList.contains('flipped')) classesToKeep += ' flipped';
743
- if (bubble.classList.contains('flipped-vertical')) classesToKeep += ' flipped-vertical';
744
  bubble.className = classesToKeep;
745
  bubble.classList.add(type);
746
  bubble.dataset.type = type;
 
 
 
 
 
 
747
  if (type === 'thought') {
748
  for (let i = 1; i <= 2; i++) {
749
  const dot = document.createElement('div');
@@ -763,15 +815,29 @@ class EnhancedComicGenerator:
763
  currentlySelectedBubble.style.fontFamily = font;
764
  }
765
 
 
766
  function rotateBubbleTail() {
767
  if (!currentlySelectedBubble) { alert("Please select a bubble first."); return; }
768
- const isFlippedH = currentlySelectedBubble.classList.contains('flipped');
769
- const isFlippedV = currentlySelectedBubble.classList.contains('flipped-vertical');
770
- if (!isFlippedH && !isFlippedV) { currentlySelectedBubble.classList.add('flipped'); }
771
- else if (isFlippedH && !isFlippedV) { currentlySelectedBubble.classList.add('flipped-vertical'); }
772
- else if (isFlippedH && isFlippedV) { currentlySelectedBubble.classList.remove('flipped'); }
773
- else { currentlySelectedBubble.classList.remove('flipped-vertical'); }
 
 
 
 
 
 
 
 
 
 
 
 
774
  }
 
775
 
776
  function selectPanel(panel) {
777
  document.querySelectorAll('.panel.selected').forEach(p => p.classList.remove('selected'));
@@ -792,7 +858,15 @@ class EnhancedComicGenerator:
792
 
793
  const styles = window.getComputedStyle(currentlySelectedBubble);
794
  document.getElementById('bubble-text-color').value = rgbToHex(styles.color);
795
- document.getElementById('bubble-fill-color').value = rgbToHex(styles.backgroundColor);
 
 
 
 
 
 
 
 
796
  document.getElementById('bubble-type-select').value = currentlySelectedBubble.dataset.type || 'speech';
797
  document.getElementById('font-select').value = styles.fontFamily.split(',')[0].replace(/"/g, "").replace(/'/g, "").trim();
798
 
@@ -1069,7 +1143,6 @@ class EnhancedComicGenerator:
1069
  </script>
1070
  </body>
1071
  </html>'''
1072
- # <<< MODIFICATION END >>>
1073
  with open(os.path.join(self.output_dir, 'page.html'), 'w', encoding='utf-8') as f:
1074
  f.write(template_html)
1075
  print("📄 Template files copied successfully!")
 
14
  import traceback
15
 
16
  # --- ROBUST IMPORTS WITH FALLBACKS ---
17
+ # (Assuming these modules have a method to process a single image, e.g., enhance_single)
18
  try:
19
  from backend.keyframes.keyframes import black_bar_crop
20
  print("✅ Black bar cropping module loaded.")
 
29
  except Exception as e:
30
  print(f"⚠️ Could not load SimpleColorEnhancer: {e}. This feature will be SKIPPED.")
31
  class SimpleColorEnhancer:
32
+ def enhance_batch(self, *args, **kwargs): print("-> Skipping simple color enhancement (module not loaded).")
33
+ def enhance_single(self, *args, **kwargs): print("-> Skipping simple color enhancement (module not loaded).")
 
34
 
35
  try:
36
  from backend.quality_color_enhancer import QualityColorEnhancer
 
38
  except Exception as e:
39
  print(f"⚠️ Could not load QualityColorEnhancer: {e}. This feature will be SKIPPED.")
40
  class QualityColorEnhancer:
41
+ def batch_enhance(self, *args, **kwargs): print("-> Skipping quality color enhancement (module not loaded).")
42
+ def enhance_single(self, *args, **kwargs): print("-> Skipping quality color enhancement (module not loaded).")
43
+
44
 
45
  try:
46
  from backend.class_def import bubble, panel, Page
 
306
  cap.release()
307
  if not ret or frame is None:
308
  return {"success": False, "message": f"No frame at {target_time:.2f}s."}
309
+
310
  new_path = os.path.join(self.frames_dir, frame_filename)
311
  cv2.imwrite(new_path, frame)
312
 
313
+ # Apply the same enhancements to the new frame to maintain style consistency
314
  print(f"🎨 Applying enhancements to the new frame: {frame_filename}")
315
  self._enhance_all_images(single_image_path=new_path)
316
  self._enhance_quality_colors(single_image_path=new_path)
 
317
 
318
  if isinstance(frame_to_time[frame_filename], dict):
319
  frame_to_time[frame_filename]['time'] = target_time
 
343
  ret, frame = cap.read()
344
  cap.release()
345
  if not ret or frame is None: return {"success": False, "message": f"Could not retrieve frame at {timestamp_seconds:.2f}s."}
346
+
347
  new_path = os.path.join(self.frames_dir, frame_filename)
348
  cv2.imwrite(new_path, frame)
349
+
350
+ # Apply enhancements to the new frame from the specific timestamp
351
  print(f"🎨 Applying enhancements to the new frame from timestamp: {frame_filename}")
352
  self._enhance_all_images(single_image_path=new_path)
353
  self._enhance_quality_colors(single_image_path=new_path)
 
354
 
355
  with open(metadata_path, 'r') as f: frame_to_time = json.load(f)
356
  if frame_filename in frame_to_time:
 
437
  update_status(f"Error: {e}", -1)
438
  return False
439
 
440
+ # <<< MODIFICATION START: Made enhancement functions process single files efficiently >>>
441
  def _enhance_all_images(self, single_image_path=None):
442
+ """Enhances images. If single_image_path is provided, only enhances that file."""
 
 
443
  try:
444
+ enhancer = SimpleColorEnhancer()
445
+ if single_image_path and os.path.exists(single_image_path):
446
+ # Assumes the enhancer class has a method for single images.
447
+ # If it doesn't, this will gracefully fail or you can implement a fallback.
448
+ enhancer.enhance_single(single_image_path)
449
+ elif not single_image_path:
450
+ enhancer.enhance_batch(self.frames_dir)
451
+ except Exception as e:
452
+ print(f"❌ Simple enhancement failed: {e}. Falling back to batch processing.")
453
+ # Fallback for safety if enhance_single doesn't exist
454
+ try:
455
+ SimpleColorEnhancer().enhance_batch(self.frames_dir)
456
+ except Exception as e2:
457
+ print(f"❌ Fallback simple enhancement also failed: {e2}")
458
 
459
  def _enhance_quality_colors(self, single_image_path=None):
460
+ """Enhances images with a quality model. If single_image_path is provided, only enhances that file."""
 
461
  try:
462
+ enhancer = QualityColorEnhancer()
463
+ if single_image_path and os.path.exists(single_image_path):
464
+ # Assumes the enhancer class has a method for single images.
465
+ enhancer.enhance_single(single_image_path)
466
+ elif not single_image_path:
467
+ enhancer.batch_enhance(self.frames_dir)
468
+ except Exception as e:
469
+ print(f"⚠️ Quality enhancement failed: {e}. Falling back to batch processing.")
470
+ # Fallback for safety if enhance_single doesn't exist
471
+ try:
472
+ QualityColorEnhancer().batch_enhance(self.frames_dir)
473
+ except Exception as e2:
474
+ print(f"⚠️ Fallback quality enhancement also failed: {e2}")
475
+ # <<< MODIFICATION END >>>
476
 
477
  def _create_ai_bubbles_from_moments(self, black_x, black_y):
478
  bubbles, frame_files = [], sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
 
524
 
525
  def _copy_template_files(self):
526
  try:
 
527
  template_html = '''<!DOCTYPE html>
528
  <html lang="en">
529
  <head>
 
545
  .panel img { width: 100%; height: 100%; object-fit: cover; object-position: center; transition: transform 0.1s ease-out; }
546
  .panel img.pannable { cursor: grab; }
547
  .panel img.panning { cursor: grabbing; }
548
+ .speech-bubble { position: absolute; display: flex; justify-content: center; align-items: center; width: 150px; height: 80px; min-width: 50px; min-height: 30px; box-sizing: border-box; padding: 8px; box-shadow: 2px 2px 5px rgba(0,0,0,0.3); z-index: 10; cursor: move; overflow: visible; font-size: 13px; font-weight: bold; text-align: center; font-family: 'Comic Neue', cursive; }
549
  .bubble-text { padding: 2px; word-wrap: break-word; }
550
  .speech-bubble.selected { outline: 2px dashed #4CAF50; }
551
  .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); font: inherit; text-align: center; resize: none; padding: 8px; z-index: 102; }
552
+
553
+ /* <<< MODIFICATION START: New CSS for 'speech' bubble type >>> */
554
+ .speech-bubble.speech {
555
+ color: #fff;
556
+ font-size: 16px;
557
+ text-align: center;
558
+ padding: 1em;
559
+ /* Remove standard border/background to allow gradient to show */
560
+ border: none;
561
+ background: none;
562
+ border-radius: 0;
563
+ /* Gradient background applied via border-image */
564
+ border-image: fill 0 linear-gradient(30deg, #4ECDC4, #6A4A3C);
565
+ }
566
+
567
+ /* Generic tail for all bubbles */
568
+ .speech-bubble::after, .speech-bubble.idea::after { content: ''; position: absolute; width: 0; height: 0; border-left: 10px solid transparent; border-right: 10px solid transparent; }
569
+
570
+ /* Specific tail for the new 'speech' bubble style */
571
+ .speech-bubble.speech::after {
572
+ border-top: 20px solid #6A4A3C; /* Long tail, color matches one end of the gradient */
573
+ }
574
+
575
+ /* 4-WAY TAIL POSITIONING CLASSES for .speech bubbles */
576
+ .speech-bubble.speech.tail-bl::after { bottom: -19px; left: 20px; transform: rotate(0deg); }
577
+ .speech-bubble.speech.tail-br::after { bottom: -19px; right: 20px; transform: rotate(0deg); }
578
+ .speech-bubble.speech.tail-tr::after { top: -19px; right: 20px; transform: rotate(180deg); }
579
+ .speech-bubble.speech.tail-tl::after { top: -19px; left: 20px; transform: rotate(180deg); }
580
+ /* <<< MODIFICATION END >>> */
581
+
582
  .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
583
  .speech-bubble.reaction { background: #FFD700; border: 3px solid #E53935; color: #D32F2F; font-weight: 900; text-transform: uppercase; width: 180px; 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%); }
584
  .speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; }
585
  .speech-bubble.idea { background: linear-gradient(180deg,#FFFDD0 0%, #FFF8B5 100%); border: 2px solid #FFA500; color: #6a4b00; border-radius: 40% 60% 40% 60% / 60% 40% 60% 40%; }
586
+ .speech-bubble.idea::after { border-top: 10px solid #FFA500; bottom: -9px; left: 20px; }
587
+
 
 
588
  .speech-bubble.thought::after { display: none; }
589
  .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
590
  .thought-dot-1 { width: 20px; height: 20px; bottom: -20px; left: 15px; }
591
  .thought-dot-2 { width: 12px; height: 12px; bottom: -32px; left: 5px; }
 
 
 
 
 
 
 
592
  .resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; z-index: 11; }
593
  .speech-bubble.selected .resize-handle { display: block; }
594
  .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
 
732
  if(currentlySelectedBubble) currentlySelectedBubble.style.color = e.target.value;
733
  });
734
  document.getElementById('bubble-fill-color').addEventListener('input', (e) => {
735
+ if(currentlySelectedBubble) {
736
+ // The new speech bubble uses a gradient, so standard fill color is disabled for it.
737
+ if (currentlySelectedBubble.dataset.type !== 'speech') {
738
+ currentlySelectedBubble.style.backgroundColor = e.target.value;
739
+ }
740
+ }
741
  });
742
 
743
  document.addEventListener('mousemove', e => { if (isPanning) panImage(e); if (draggedBubble) drag(e); if(isResizing) resizeBubble(e); });
 
783
 
784
  function applyBubbleType(bubble, type) {
785
  bubble.querySelectorAll('.thought-dot').forEach(el => el.remove());
786
+ // Preserve essential classes
787
  let classesToKeep = 'speech-bubble';
788
  if (bubble.classList.contains('selected')) classesToKeep += ' selected';
789
+
 
790
  bubble.className = classesToKeep;
791
  bubble.classList.add(type);
792
  bubble.dataset.type = type;
793
+
794
+ if (type === 'speech') {
795
+ // Set initial tail position for speech bubbles
796
+ bubble.classList.add('tail-bl');
797
+ bubble.dataset.tailPos = '0';
798
+ }
799
  if (type === 'thought') {
800
  for (let i = 1; i <= 2; i++) {
801
  const dot = document.createElement('div');
 
815
  currentlySelectedBubble.style.fontFamily = font;
816
  }
817
 
818
+ // <<< MODIFICATION START: Rewritten 4-way tail rotation function >>>
819
  function rotateBubbleTail() {
820
  if (!currentlySelectedBubble) { alert("Please select a bubble first."); return; }
821
+ // This function now only works for the 'speech' type which has the new tail logic
822
+ if (currentlySelectedBubble.dataset.type !== 'speech') {
823
+ alert("Tail rotation is only available for the 'Speech' bubble type.");
824
+ return;
825
+ }
826
+
827
+ const positions = ['tail-bl', 'tail-br', 'tail-tr', 'tail-tl'];
828
+ let currentPos = parseInt(currentlySelectedBubble.dataset.tailPos || 0);
829
+
830
+ // Remove current position class
831
+ currentlySelectedBubble.classList.remove(positions[currentPos]);
832
+
833
+ // Get next position, cycling back to 0
834
+ let nextPos = (currentPos + 1) % positions.length;
835
+
836
+ // Add new position class and update data attribute
837
+ currentlySelectedBubble.classList.add(positions[nextPos]);
838
+ currentlySelectedBubble.dataset.tailPos = nextPos;
839
  }
840
+ // <<< MODIFICATION END >>>
841
 
842
  function selectPanel(panel) {
843
  document.querySelectorAll('.panel.selected').forEach(p => p.classList.remove('selected'));
 
858
 
859
  const styles = window.getComputedStyle(currentlySelectedBubble);
860
  document.getElementById('bubble-text-color').value = rgbToHex(styles.color);
861
+
862
+ const fillColorPicker = document.getElementById('bubble-fill-color');
863
+ if (currentlySelectedBubble.dataset.type === 'speech') {
864
+ fillColorPicker.disabled = true; // Disable fill for gradient bubble
865
+ } else {
866
+ fillColorPicker.disabled = false;
867
+ fillColorPicker.value = rgbToHex(styles.backgroundColor);
868
+ }
869
+
870
  document.getElementById('bubble-type-select').value = currentlySelectedBubble.dataset.type || 'speech';
871
  document.getElementById('font-select').value = styles.fontFamily.split(',')[0].replace(/"/g, "").replace(/'/g, "").trim();
872
 
 
1143
  </script>
1144
  </body>
1145
  </html>'''
 
1146
  with open(os.path.join(self.output_dir, 'page.html'), 'w', encoding='utf-8') as f:
1147
  f.write(template_html)
1148
  print("📄 Template files copied successfully!")