jhh6576 commited on
Commit
21b5267
·
verified ·
1 Parent(s): 52d0958

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +83 -497
app_enhanced.py CHANGED
@@ -77,459 +77,6 @@ INDEX_HTML = '''
77
  <link rel="preconnect" href="https://fonts.googleapis.com">
78
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
79
  <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Gloria+Hallelujah&family=Lato&display=swap" rel="stylesheet">
80
- <style>
81
- body {
82
- background-color: #fdf6e3;
83
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
84
- color: #3d3d3d;
85
- display: flex;
86
- justify-content: center;
87
- align-items: center;
88
- min-height: 100vh;
89
- margin: 0;
90
- }
91
- .container {
92
- max-width: 500px;
93
- width: 100%;
94
- padding: 40px;
95
- background-color: #ffffff;
96
- border-radius: 12px;
97
- box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
98
- text-align: center;
99
- }
100
- h1 {
101
- color: #2c3e50;
102
- margin-bottom: 30px;
103
- font-weight: 600;
104
- }
105
- .file-input { display: none; }
106
- .file-label {
107
- display: block;
108
- padding: 15px 25px;
109
- background-color: #2c3e50;
110
- color: white;
111
- border-radius: 8px;
112
- cursor: pointer;
113
- font-size: 16px;
114
- font-weight: 500;
115
- transition: background-color 0.3s ease, transform 0.2s ease;
116
- }
117
- .file-label:hover {
118
- background-color: #34495e;
119
- transform: translateY(-2px);
120
- }
121
- #file-name {
122
- display: block;
123
- margin-top: 15px;
124
- font-style: italic;
125
- color: #7f8c8d;
126
- }
127
- .submit-btn {
128
- width: 100%;
129
- padding: 15px;
130
- border: none;
131
- border-radius: 8px;
132
- background-color: #e67e22;
133
- color: white;
134
- font-size: 18px;
135
- font-weight: bold;
136
- cursor: pointer;
137
- transition: background-color 0.3s ease;
138
- margin-top: 20px;
139
- }
140
- .submit-btn:hover { background-color: #d35400; }
141
- .loading-container {
142
- display: none;
143
- flex-direction: column;
144
- align-items: center;
145
- justify-content: center;
146
- }
147
- .loader {
148
- width: 120px;
149
- height: 20px;
150
- border-radius: 20px;
151
- background:
152
- radial-gradient(circle 10px, #e67e22 100%, transparent 0),
153
- radial-gradient(circle 10px, #e67e22 100%, transparent 0),
154
- radial-gradient(circle 10px, #e67e22 100%, transparent 0);
155
- background-size: 20px 20px;
156
- background-position: 0px 50%, 50px 50%, 100px 50%;
157
- background-repeat: no-repeat;
158
- animation:-ball 2s infinite linear;
159
- }
160
- @keyframes -ball {
161
- 0% {background-position: 0px 50% ,50px 50% ,100px 50%}
162
- 16% {background-position: 0px 0% ,50px 50% ,100px 50%}
163
- 33% {background-position: 0px 100% ,50px 0% ,100px 50%}
164
- 50% {background-position: 0px 50% ,50px 100% ,100px 0%}
165
- 66% {background-position: 0px 50% ,50px 50% ,100px 100%}
166
- 83% {background-position: 0px 50% ,50px 50% ,100px 50%}
167
- 100% {background-position: 0px 50% ,50px 50% ,100px 50%}
168
- }
169
- #status-text {
170
- margin-top: 25px;
171
- color: #34495e;
172
- font-weight: 500;
173
- font-size: 18px;
174
- }
175
- #final-message {
176
- display: none;
177
- font-size: 20px;
178
- font-weight: bold;
179
- color: #27ae60;
180
- }
181
- </style>
182
- </head>
183
- <body>
184
- <div class="container" id="main-container">
185
- <div id="upload-view">
186
- <h1>🎬 Movie to Comic Generator</h1>
187
- <form id="upload-form">
188
- <label for="file-upload" class="file-label">Choose Video File</label>
189
- <input id="file-upload" class="file-input" type="file" name="file" onchange="updateFileName(this)">
190
- <span id="file-name">No file selected</span>
191
- <button class="submit-btn" type="submit">Generate Comic</button>
192
- </form>
193
- </div>
194
- <div class="loading-container" id="loading-view">
195
- <div class="loader"></div>
196
- <p id="status-text">Starting...</p>
197
- <p id="final-message">✅ Generation Complete! Opening your comic...</p>
198
- </div>
199
- </div>
200
- <script>
201
- let statusInterval;
202
- function updateFileName(input) {
203
- const fileNameSpan = document.getElementById('file-name');
204
- if (input.files && input.files.length > 0) {
205
- fileNameSpan.textContent = input.files[0].name;
206
- } else {
207
- fileNameSpan.textContent = 'No file selected';
208
- }
209
- }
210
- async function checkStatus() {
211
- try {
212
- const response = await fetch('/status');
213
- const data = await response.json();
214
- const statusText = document.getElementById('status-text');
215
- statusText.textContent = data.message;
216
- if (data.progress >= 100) {
217
- clearInterval(statusInterval);
218
- document.querySelector('.loader').style.display = 'none';
219
- statusText.style.display = 'none';
220
- document.getElementById('final-message').style.display = 'block';
221
- setTimeout(() => {
222
- window.open('/comic', '_blank');
223
- }, 1500);
224
- } else if (data.progress < 0) {
225
- clearInterval(statusInterval);
226
- statusText.textContent = "An error occurred. Please check the logs.";
227
- statusText.style.color = '#e74c3c';
228
- document.querySelector('.loader').style.display = 'none';
229
- }
230
- } catch (error) {
231
- console.error("Error fetching status:", error);
232
- document.getElementById('status-text').textContent = "Error checking status. Retrying...";
233
- }
234
- }
235
- document.getElementById('upload-form').addEventListener('submit', async function(event) {
236
- event.preventDefault();
237
- const fileInput = document.getElementById('file-upload');
238
- if (!fileInput.files || fileInput.files.length === 0) {
239
- alert("Please select a video file first.");
240
- return;
241
- }
242
- document.getElementById('upload-view').style.display = 'none';
243
- document.getElementById('loading-view').style.display = 'flex';
244
- const formData = new FormData();
245
- formData.append('file', fileInput.files[0]);
246
- try {
247
- const response = await fetch('/uploader', {
248
- method: 'POST',
249
- body: formData
250
- });
251
- if (!response.ok) {
252
- throw new Error('Upload failed!');
253
- }
254
- statusInterval = setInterval(checkStatus, 2000);
255
- } catch (error) {
256
- console.error("Upload error:", error);
257
- document.getElementById('status-text').textContent = "Failed to start generation. Please try again.";
258
- }
259
- });
260
- </script>
261
- </body>
262
- </html>
263
- '''
264
-
265
- os.makedirs('video', exist_ok=True)
266
- os.makedirs('frames/final', exist_ok=True)
267
- os.makedirs('output', exist_ok=True)
268
-
269
- def update_status(message, progress):
270
- status_file = os.path.join('output', 'status.json')
271
- with open(status_file, 'w') as f:
272
- json.dump({'message': message, 'progress': progress}, f)
273
-
274
- class EnhancedComicGenerator:
275
- def __init__(self):
276
- self.video_path = 'video/uploaded.mp4'
277
- self.frames_dir = 'frames/final'
278
- self.output_dir = 'output'
279
- self.apply_comic_style = False
280
- self.video_fps = None
281
-
282
- def cleanup_generated(self):
283
- print("🧹 Performing full cleanup...")
284
- if os.path.isdir(self.frames_dir): shutil.rmtree(self.frames_dir)
285
- if os.path.isdir(self.output_dir): shutil.rmtree(self.output_dir)
286
- if os.path.isdir('temp'): shutil.rmtree('temp')
287
- if os.path.exists('test1.srt'): os.remove('test1.srt')
288
- os.makedirs(self.frames_dir, exist_ok=True)
289
- os.makedirs(self.output_dir, exist_ok=True)
290
- print("✅ Cleanup complete.")
291
-
292
- def regenerate_frame(self, frame_filename, direction):
293
- try:
294
- if not self.video_fps:
295
- return {"success": False, "message": "Video FPS not found."}
296
- metadata_path = 'frames/frame_metadata.json'
297
- if not os.path.exists(metadata_path):
298
- return {"success": False, "message": "Frame metadata missing."}
299
- with open(metadata_path, 'r') as f:
300
- frame_to_time = json.load(f)
301
- if frame_filename not in frame_to_time:
302
- return {"success": False, "message": "Panel not linked to video."}
303
- current_time = frame_to_time[frame_filename]['time'] if isinstance(frame_to_time[frame_filename], dict) else frame_to_time[frame_filename]
304
- frame_duration = 1.0 / self.video_fps
305
- target_time = current_time + frame_duration if direction == 'forward' else current_time - frame_duration
306
- target_time = max(0, target_time)
307
- cap = cv2.VideoCapture(self.video_path)
308
- if not cap.isOpened(): return {"success": False, "message": "Cannot open video."}
309
- cap.set(cv2.CAP_PROP_POS_MSEC, target_time * 1000)
310
- ret, frame = cap.read()
311
- cap.release()
312
- if not ret or frame is None:
313
- return {"success": False, "message": f"No frame at {target_time:.2f}s."}
314
-
315
- new_path = os.path.join(self.frames_dir, frame_filename)
316
- cv2.imwrite(new_path, frame)
317
-
318
- print(f"🎨 Applying enhancements to the new frame: {frame_filename}")
319
- self._enhance_all_images(single_image_path=new_path)
320
- self._enhance_quality_colors(single_image_path=new_path)
321
-
322
- if isinstance(frame_to_time[frame_filename], dict):
323
- frame_to_time[frame_filename]['time'] = target_time
324
- else:
325
- frame_to_time[frame_filename] = target_time
326
- with open(metadata_path, 'w') as f: json.dump(frame_to_time, f, indent=2)
327
- message = f"Adjusted {direction} to {target_time:.3f}s"
328
- print(f"✅ {message}")
329
- return {"success": True, "message": message, "new_filename": frame_filename}
330
- except Exception as e:
331
- traceback.print_exc()
332
- return {"success": False, "message": str(e)}
333
-
334
- def get_frame_at_timestamp(self, frame_filename, timestamp_seconds):
335
- try:
336
- metadata_path = 'frames/frame_metadata.json'
337
- if not os.path.exists(metadata_path): return {"success": False, "message": "Frame metadata missing."}
338
- cap = cv2.VideoCapture(self.video_path)
339
- if not cap.isOpened(): return {"success": False, "message": "Cannot open video."}
340
- fps = cap.get(cv2.CAP_PROP_FPS)
341
- if fps == 0: fps = 25
342
- duration = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) / fps
343
- if timestamp_seconds < 0 or timestamp_seconds > duration:
344
- cap.release()
345
- return {"success": False, "message": f"Timestamp must be between 0 and {duration:.2f}s."}
346
- cap.set(cv2.CAP_PROP_POS_MSEC, timestamp_seconds * 1000)
347
- ret, frame = cap.read()
348
- cap.release()
349
- if not ret or frame is None: return {"success": False, "message": f"Could not retrieve frame at {timestamp_seconds:.2f}s."}
350
-
351
- new_path = os.path.join(self.frames_dir, frame_filename)
352
- cv2.imwrite(new_path, frame)
353
-
354
- print(f"🎨 Applying enhancements to the new frame from timestamp: {frame_filename}")
355
- self._enhance_all_images(single_image_path=new_path)
356
- self._enhance_quality_colors(single_image_path=new_path)
357
-
358
- with open(metadata_path, 'r') as f: frame_to_time = json.load(f)
359
- if frame_filename in frame_to_time:
360
- if isinstance(frame_to_time[frame_filename], dict):
361
- frame_to_time[frame_filename]['time'] = timestamp_seconds
362
- else:
363
- frame_to_time[frame_filename] = timestamp_seconds
364
- with open(metadata_path, 'w') as f: json.dump(frame_to_time, f, indent=2)
365
- message = f"Jumped to timestamp {timestamp_seconds:.3f}s"
366
- print(f"✅ {message}")
367
- return { "success": True, "message": message }
368
- except Exception as e:
369
- traceback.print_exc()
370
- return {"success": False, "message": str(e)}
371
-
372
- def generate_keyframes_from_moments(self, video_path, key_moments, max_frames=32):
373
- try:
374
- cap = cv2.VideoCapture(video_path)
375
- if not cap.isOpened(): raise Exception("Cannot open video for keyframe extraction")
376
- fps, total_frames = self.video_fps, int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
377
- duration = total_frames / fps
378
- key_moments.sort(key=lambda x: x['start'])
379
- if len(key_moments) > max_frames: pass # Simplified sampling
380
- frame_metadata, frame_count = {}, 0
381
- for i, moment in enumerate(key_moments):
382
- update_status(f"Extracting frame {i+1}/{len(key_moments)}...", 25 + int(20 * (i / len(key_moments))))
383
- frame_time = (moment['start'] + moment['end']) / 2
384
- if frame_time > duration: continue
385
- frame_number = int(frame_time * fps)
386
- cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
387
- ret, frame = cap.read()
388
- if ret:
389
- frame_filename = f"frame_{frame_count:04d}.png"
390
- frame_path = os.path.join(self.frames_dir, frame_filename)
391
- cv2.imwrite(frame_path, frame)
392
- frame_metadata[frame_filename] = { 'time': frame_time, 'dialogue': moment['text'], 'start': moment['start'], 'end': moment['end'] }
393
- frame_count += 1
394
- cap.release()
395
- with open(os.path.join('frames', 'frame_metadata.json'), 'w') as f:
396
- json.dump(frame_metadata, f, indent=2)
397
- print(f"✅ Extracted {frame_count} keyframes from video")
398
- return True
399
- except Exception as e:
400
- print(f"❌ Error extracting keyframes: {e}")
401
- return False
402
-
403
- def generate_comic(self):
404
- start_time = time.time()
405
- try:
406
- update_status("Cleaning up...", 0)
407
- self.cleanup_generated()
408
- update_status("Analyzing video...", 5)
409
- cap = cv2.VideoCapture(self.video_path)
410
- if not cap.isOpened(): raise Exception("Cannot open video to get FPS.")
411
- self.video_fps = cap.get(cv2.CAP_PROP_FPS)
412
- if self.video_fps == 0: self.video_fps = 25
413
- cap.release()
414
- print(f"✅ Video FPS detected: {self.video_fps:.2f}")
415
- update_status("Generating subtitles (this may take a while)...", 10)
416
- get_real_subtitles(self.video_path)
417
- with open('test1.srt', 'r', encoding='utf-8') as f:
418
- all_subs = list(srt.parse(f.read()))
419
- key_moments = [{'index': s.index, 'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in all_subs]
420
- if not self.generate_keyframes_from_moments(self.video_path, key_moments, max_frames=32):
421
- raise Exception("Keyframe extraction failed.")
422
- update_status("Cropping black bars...", 45)
423
- black_x, black_y, _, _ = black_bar_crop()
424
- update_status("Enhancing images (in parallel)...", 50)
425
- self._enhance_all_images()
426
- self._enhance_quality_colors()
427
- update_status("Placing speech bubbles (in parallel)...", 75)
428
- bubbles = self._create_ai_bubbles_from_moments(black_x, black_y)
429
- update_status("Assembling comic pages...", 90)
430
- pages = self._generate_pages(bubbles)
431
- update_status("Saving final comic...", 95)
432
- self._save_results(pages)
433
- execution_time = (time.time() - start_time) / 60
434
- print(f"✅ Comic generation completed in {execution_time:.2f} minutes")
435
- update_status("Complete!", 100)
436
- return True
437
- except Exception as e:
438
- print(f"❌ Comic generation failed: {e}")
439
- traceback.print_exc()
440
- update_status(f"Error: {e}", -1)
441
- return False
442
-
443
- def _enhance_all_images(self, single_image_path=None):
444
- try:
445
- enhancer = SimpleColorEnhancer()
446
- if single_image_path:
447
- enhancer.enhance_single(single_image_path)
448
- else:
449
- frame_paths = [os.path.join(self.frames_dir, f) for f in os.listdir(self.frames_dir) if f.endswith('.png')]
450
- with ThreadPoolExecutor() as executor:
451
- list(executor.map(enhancer.enhance_single, frame_paths))
452
- except Exception as e:
453
- print(f"❌ Simple enhancement failed: {e}")
454
-
455
- def _enhance_quality_colors(self, single_image_path=None):
456
- try:
457
- enhancer = QualityColorEnhancer()
458
- if single_image_path:
459
- enhancer.enhance_single(single_image_path)
460
- else:
461
- frame_paths = [os.path.join(self.frames_dir, f) for f in os.listdir(self.frames_dir) if f.endswith('.png')]
462
- with ThreadPoolExecutor() as executor:
463
- list(executor.map(enhancer.enhance_single, frame_paths))
464
- except Exception as e:
465
- print(f"⚠️ Quality enhancement failed: {e}")
466
-
467
- def _process_bubble_for_frame(self, frame_file):
468
- frame_path = os.path.join(self.frames_dir, frame_file)
469
- dialogue = self.frame_metadata.get(frame_file, {}).get('dialogue', "")
470
- try:
471
- faces = face_detector.detect_faces(frame_path)
472
- lip_x, lip_y = face_detector.get_lip_position(frame_path, faces[0]) if faces else (-1, -1)
473
- bubble_x, bubble_y = ai_bubble_placer.place_bubble_ai(frame_path, (lip_x, lip_y))
474
- return bubble(bubble_offset_x=bubble_x, bubble_offset_y=bubble_y, lip_x=lip_x, lip_y=lip_y, dialog=dialogue, emotion='normal')
475
- except Exception as e:
476
- print(f"-> Could not place bubble for {frame_file}: {e}. Using default.")
477
- return bubble(bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, dialog=dialogue, emotion='normal')
478
-
479
- def _create_ai_bubbles_from_moments(self, black_x, black_y):
480
- frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
481
- metadata_path = 'frames/frame_metadata.json'
482
- if not os.path.exists(metadata_path):
483
- return [bubble(dialog="") for _ in frame_files]
484
-
485
- with open(metadata_path, 'r') as f:
486
- self.frame_metadata = json.load(f)
487
-
488
- with ThreadPoolExecutor() as executor:
489
- bubbles = list(executor.map(self._process_bubble_for_frame, frame_files))
490
-
491
- return bubbles
492
-
493
- def _generate_pages(self, bubbles):
494
- try:
495
- from backend.fixed_12_pages_800x1080 import generate_12_pages_800x1080
496
- return generate_12_pages_800x1080(sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')]), bubbles)
497
- except ImportError:
498
- pages, frame_files = [], sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
499
- num_pages = (len(frame_files) + 3) // 4
500
- for i in range(num_pages):
501
- start, end = i*4, (i+1)*4
502
- page_panels = [panel(image=f) for f in frame_files[start:end]]
503
- page_bubbles = bubbles[start:end]
504
- if page_panels: pages.append(Page(panels=page_panels, bubbles=page_bubbles))
505
- return pages
506
-
507
- def _save_results(self, pages):
508
- try:
509
- os.makedirs(self.output_dir, exist_ok=True)
510
- pages_data = []
511
- for page in pages:
512
- panels = [p.__dict__ if hasattr(p, '__dict__') else p for p in page.panels]
513
- bubbles_data = [b.__dict__ if hasattr(b, '__dict__') else b for b in page.bubbles]
514
- pages_data.append({'panels': panels, 'bubbles': bubbles_data})
515
- with open(os.path.join(self.output_dir, 'pages.json'), 'w', encoding='utf-8') as f:
516
- json.dump(pages_data, f, indent=2)
517
- self._copy_template_files()
518
- print("✅ Results saved successfully!")
519
- except Exception as e:
520
- print(f"Save results failed: {e}")
521
-
522
- def _copy_template_files(self):
523
- try:
524
- template_html = '''<!DOCTYPE html>
525
- <html lang="en">
526
- <head>
527
- <meta charset="UTF-8">
528
- <title>Comic Editor</title>
529
- <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
530
- <link rel="preconnect" href="https://fonts.googleapis.com">
531
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
532
- <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Gloria+Hallelujah&family=Lato&display=swap" rel="stylesheet">
533
  <style>
534
  body { margin: 0; padding: 20px; background: #f0f0f0; font-family: 'Lato', sans-serif; }
535
  .comic-container { max-width: 1200px; margin: 0 auto; }
@@ -566,75 +113,114 @@ class EnhancedComicGenerator:
566
  .speech-bubble.selected { outline: 2px dashed #4CAF50; }
567
  .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; }
568
 
569
- /* <<< ROBUST BORDER-BASED SHARK FIN TAIL >>> */
570
  .speech-bubble.speech {
571
- --bubble-fill-color: #4ECDC4; /* Default fill */
572
  --bubble-text-color: #ffffff;
 
573
  --tail-pos: 50%;
574
 
575
  background: var(--bubble-fill-color);
576
  color: var(--bubble-text-color);
577
- border-radius: 12px;
 
578
  padding: 1em;
579
  position: absolute;
580
  }
581
 
582
- /* The Tail Pseudo-Element using BORDERS (Export Safe) */
583
- .speech-bubble.speech:before {
 
 
 
 
 
 
 
 
 
 
584
  content: "";
585
  position: absolute;
586
- width: 0;
587
- height: 0;
588
- border-style: solid;
589
  pointer-events: none;
590
-
591
- /* Shark Fin Shape: Right Angled Triangle logic */
592
- /* This creates a solid triangle that works in html2canvas */
593
  }
594
 
595
- /* BOTTOM TAIL */
 
 
 
 
 
 
 
 
596
  .speech-bubble.speech.tail-bottom:before {
597
- /* Triangle pointing down */
598
- border-width: 25px 20px 0 0;
599
- border-color: var(--bubble-fill-color) transparent transparent transparent;
600
-
601
- top: 100%;
602
  left: var(--tail-pos);
603
- transform: translateX(-50%); /* Center the tip horizontally */
 
604
  }
605
 
606
- /* TOP TAIL */
 
 
 
 
 
 
 
 
607
  .speech-bubble.speech.tail-top:before {
608
- /* Triangle pointing up */
609
- border-width: 0 20px 25px 0;
610
- border-color: transparent transparent var(--bubble-fill-color) transparent;
611
-
612
- bottom: 100%;
613
  left: var(--tail-pos);
614
- transform: translateX(-50%);
 
615
  }
616
 
617
- /* LEFT TAIL */
618
- .speech-bubble.speech.tail-left:before {
619
- /* Triangle pointing left */
620
- border-width: 0 25px 20px 0;
621
- border-color: transparent var(--bubble-fill-color) transparent transparent;
622
-
623
- right: 100%;
624
  top: var(--tail-pos);
625
- transform: translateY(-50%);
 
626
  }
627
-
628
- /* RIGHT TAIL */
629
  .speech-bubble.speech.tail-right:before {
630
- /* Triangle pointing right */
631
- border-width: 20px 25px 0 0;
632
- border-color: var(--bubble-fill-color) transparent transparent transparent;
633
-
634
- left: 100%;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
635
  top: var(--tail-pos);
636
- transform: translateY(-50%) rotate(90deg); /* Rotate standard triangle */
637
- transform-origin: top left;
638
  }
639
 
640
  .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
@@ -1066,7 +652,7 @@ class EnhancedComicGenerator:
1066
  const pages = document.querySelectorAll('.comic-page');
1067
  if (pages.length === 0) return alert("No pages found.");
1068
 
1069
- // 1. FREEZE DIMENSIONS BEFORE EXPORT (Prevents size reset bug)
1070
  const bubbles = document.querySelectorAll('.speech-bubble');
1071
  bubbles.forEach(b => {
1072
  const rect = b.getBoundingClientRect();
@@ -1089,7 +675,7 @@ class EnhancedComicGenerator:
1089
  } catch (err) { alert(`Failed to export page ${i + 1}.`); }
1090
  }
1091
 
1092
- // 2. UNFREEZE DIMENSIONS (Restore normal behavior)
1093
  bubbles.forEach(b => {
1094
  b.style.minWidth = '50px';
1095
  b.style.minHeight = '30px';
 
77
  <link rel="preconnect" href="https://fonts.googleapis.com">
78
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
79
  <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Gloria+Hallelujah&family=Lato&display=swap" rel="stylesheet">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  <style>
81
  body { margin: 0; padding: 20px; background: #f0f0f0; font-family: 'Lato', sans-serif; }
82
  .comic-container { max-width: 1200px; margin: 0 auto; }
 
113
  .speech-bubble.selected { outline: 2px dashed #4CAF50; }
114
  .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; }
115
 
116
+ /* <<< EXPORT-SAFE CURVED TAIL (NO MASKS) >>> */
117
  .speech-bubble.speech {
118
+ --bubble-fill-color: #4ECDC4;
119
  --bubble-text-color: #ffffff;
120
+ --bubble-border-color: #333333;
121
  --tail-pos: 50%;
122
 
123
  background: var(--bubble-fill-color);
124
  color: var(--bubble-text-color);
125
+ border: 2px solid var(--bubble-border-color);
126
+ border-radius: 15px;
127
  padding: 1em;
128
  position: absolute;
129
  }
130
 
131
+ /*
132
+ We use two pseudo-elements:
133
+ :before -> The outer border (Outline)
134
+ :after -> The inner color (Fill)
135
+
136
+ Shape Logic:
137
+ We create a square and round ONE corner to 100%.
138
+ This creates a perfect "Shark Fin" curve that is export-safe.
139
+ */
140
+
141
+ .speech-bubble.speech:before,
142
+ .speech-bubble.speech:after {
143
  content: "";
144
  position: absolute;
145
+ width: 20px;
146
+ height: 20px;
 
147
  pointer-events: none;
 
 
 
148
  }
149
 
150
+ /* --- BOTTOM TAIL --- */
151
+ .speech-bubble.speech.tail-bottom:after {
152
+ background: var(--bubble-fill-color);
153
+ border-bottom-right-radius: 20px; /* The Curve */
154
+ bottom: -13px; /* Overlap slightly to hide gap */
155
+ left: var(--tail-pos);
156
+ transform: translateX(-50%) rotate(45deg);
157
+ z-index: 2; /* On top of border */
158
+ }
159
  .speech-bubble.speech.tail-bottom:before {
160
+ background: var(--bubble-border-color);
161
+ border-bottom-right-radius: 22px;
162
+ width: 24px; height: 24px; /* Slightly larger for border effect */
163
+ bottom: -16px;
 
164
  left: var(--tail-pos);
165
+ transform: translateX(-50%) rotate(45deg);
166
+ z-index: 1;
167
  }
168
 
169
+ /* --- TOP TAIL --- */
170
+ .speech-bubble.speech.tail-top:after {
171
+ background: var(--bubble-fill-color);
172
+ border-top-left-radius: 20px;
173
+ top: -13px;
174
+ left: var(--tail-pos);
175
+ transform: translateX(-50%) rotate(45deg);
176
+ z-index: 2;
177
+ }
178
  .speech-bubble.speech.tail-top:before {
179
+ background: var(--bubble-border-color);
180
+ border-top-left-radius: 22px;
181
+ width: 24px; height: 24px;
182
+ top: -16px;
 
183
  left: var(--tail-pos);
184
+ transform: translateX(-50%) rotate(45deg);
185
+ z-index: 1;
186
  }
187
 
188
+ /* --- RIGHT TAIL --- */
189
+ .speech-bubble.speech.tail-right:after {
190
+ background: var(--bubble-fill-color);
191
+ border-top-right-radius: 20px;
192
+ right: -13px;
 
 
193
  top: var(--tail-pos);
194
+ transform: translateY(-50%) rotate(45deg);
195
+ z-index: 2;
196
  }
 
 
197
  .speech-bubble.speech.tail-right:before {
198
+ background: var(--bubble-border-color);
199
+ border-top-right-radius: 22px;
200
+ width: 24px; height: 24px;
201
+ right: -16px;
202
+ top: var(--tail-pos);
203
+ transform: translateY(-50%) rotate(45deg);
204
+ z-index: 1;
205
+ }
206
+
207
+ /* --- LEFT TAIL --- */
208
+ .speech-bubble.speech.tail-left:after {
209
+ background: var(--bubble-fill-color);
210
+ border-bottom-left-radius: 20px;
211
+ left: -13px;
212
+ top: var(--tail-pos);
213
+ transform: translateY(-50%) rotate(45deg);
214
+ z-index: 2;
215
+ }
216
+ .speech-bubble.speech.tail-left:before {
217
+ background: var(--bubble-border-color);
218
+ border-bottom-left-radius: 22px;
219
+ width: 24px; height: 24px;
220
+ left: -16px;
221
  top: var(--tail-pos);
222
+ transform: translateY(-50%) rotate(45deg);
223
+ z-index: 1;
224
  }
225
 
226
  .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
 
652
  const pages = document.querySelectorAll('.comic-page');
653
  if (pages.length === 0) return alert("No pages found.");
654
 
655
+ // 1. FREEZE DIMENSIONS BEFORE EXPORT
656
  const bubbles = document.querySelectorAll('.speech-bubble');
657
  bubbles.forEach(b => {
658
  const rect = b.getBoundingClientRect();
 
675
  } catch (err) { alert(`Failed to export page ${i + 1}.`); }
676
  }
677
 
678
+ // 2. UNFREEZE DIMENSIONS
679
  bubbles.forEach(b => {
680
  b.style.minWidth = '50px';
681
  b.style.minHeight = '30px';