jhh6576 commited on
Commit
376a726
·
verified ·
1 Parent(s): 111bba5

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +56 -500
app_enhanced.py CHANGED
@@ -73,455 +73,6 @@ INDEX_HTML = '''
73
  <meta charset="UTF-8">
74
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
75
  <title>Movie to Comic Generator</title>
76
- <style>
77
- body {
78
- background-color: #fdf6e3;
79
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
80
- color: #3d3d3d;
81
- display: flex;
82
- justify-content: center;
83
- align-items: center;
84
- min-height: 100vh;
85
- margin: 0;
86
- }
87
- .container {
88
- max-width: 500px;
89
- width: 100%;
90
- padding: 40px;
91
- background-color: #ffffff;
92
- border-radius: 12px;
93
- box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
94
- text-align: center;
95
- }
96
- h1 {
97
- color: #2c3e50;
98
- margin-bottom: 30px;
99
- font-weight: 600;
100
- }
101
- .file-input { display: none; }
102
- .file-label {
103
- display: block;
104
- padding: 15px 25px;
105
- background-color: #2c3e50;
106
- color: white;
107
- border-radius: 8px;
108
- cursor: pointer;
109
- font-size: 16px;
110
- font-weight: 500;
111
- transition: background-color 0.3s ease, transform 0.2s ease;
112
- }
113
- .file-label:hover {
114
- background-color: #34495e;
115
- transform: translateY(-2px);
116
- }
117
- #file-name {
118
- display: block;
119
- margin-top: 15px;
120
- font-style: italic;
121
- color: #7f8c8d;
122
- }
123
- .submit-btn {
124
- width: 100%;
125
- padding: 15px;
126
- border: none;
127
- border-radius: 8px;
128
- background-color: #e67e22;
129
- color: white;
130
- font-size: 18px;
131
- font-weight: bold;
132
- cursor: pointer;
133
- transition: background-color 0.3s ease;
134
- margin-top: 20px;
135
- }
136
- .submit-btn:hover { background-color: #d35400; }
137
- .loading-container {
138
- display: none;
139
- flex-direction: column;
140
- align-items: center;
141
- justify-content: center;
142
- }
143
- .loader {
144
- width: 120px;
145
- height: 20px;
146
- border-radius: 20px;
147
- background:
148
- radial-gradient(circle 10px, #e67e22 100%, transparent 0),
149
- radial-gradient(circle 10px, #e67e22 100%, transparent 0),
150
- radial-gradient(circle 10px, #e67e22 100%, transparent 0);
151
- background-size: 20px 20px;
152
- background-position: 0px 50%, 50px 50%, 100px 50%;
153
- background-repeat: no-repeat;
154
- animation:-ball 2s infinite linear;
155
- }
156
- @keyframes -ball {
157
- 0% {background-position: 0px 50% ,50px 50% ,100px 50%}
158
- 16% {background-position: 0px 0% ,50px 50% ,100px 50%}
159
- 33% {background-position: 0px 100% ,50px 0% ,100px 50%}
160
- 50% {background-position: 0px 50% ,50px 100% ,100px 0%}
161
- 66% {background-position: 0px 50% ,50px 50% ,100px 100%}
162
- 83% {background-position: 0px 50% ,50px 50% ,100px 50%}
163
- 100% {background-position: 0px 50% ,50px 50% ,100px 50%}
164
- }
165
- #status-text {
166
- margin-top: 25px;
167
- color: #34495e;
168
- font-weight: 500;
169
- font-size: 18px;
170
- }
171
- #final-message {
172
- display: none;
173
- font-size: 20px;
174
- font-weight: bold;
175
- color: #27ae60;
176
- }
177
- </style>
178
- </head>
179
- <body>
180
- <div class="container" id="main-container">
181
- <div id="upload-view">
182
- <h1>🎬 Movie to Comic Generator</h1>
183
- <form id="upload-form">
184
- <label for="file-upload" class="file-label">Choose Video File</label>
185
- <input id="file-upload" class="file-input" type="file" name="file" onchange="updateFileName(this)">
186
- <span id="file-name">No file selected</span>
187
- <button class="submit-btn" type="submit">Generate Comic</button>
188
- </form>
189
- </div>
190
- <div class="loading-container" id="loading-view">
191
- <div class="loader"></div>
192
- <p id="status-text">Starting...</p>
193
- <p id="final-message">✅ Generation Complete! Opening your comic...</p>
194
- </div>
195
- </div>
196
- <script>
197
- let statusInterval;
198
- function updateFileName(input) {
199
- const fileNameSpan = document.getElementById('file-name');
200
- if (input.files && input.files.length > 0) {
201
- fileNameSpan.textContent = input.files[0].name;
202
- } else {
203
- fileNameSpan.textContent = 'No file selected';
204
- }
205
- }
206
- async function checkStatus() {
207
- try {
208
- const response = await fetch('/status');
209
- const data = await response.json();
210
- const statusText = document.getElementById('status-text');
211
- statusText.textContent = data.message;
212
- if (data.progress >= 100) {
213
- clearInterval(statusInterval);
214
- document.querySelector('.loader').style.display = 'none';
215
- statusText.style.display = 'none';
216
- document.getElementById('final-message').style.display = 'block';
217
- setTimeout(() => {
218
- window.open('/comic', '_blank');
219
- }, 1500);
220
- } else if (data.progress < 0) {
221
- clearInterval(statusInterval);
222
- statusText.textContent = "An error occurred. Please check the logs.";
223
- statusText.style.color = '#e74c3c';
224
- document.querySelector('.loader').style.display = 'none';
225
- }
226
- } catch (error) {
227
- console.error("Error fetching status:", error);
228
- document.getElementById('status-text').textContent = "Error checking status. Retrying...";
229
- }
230
- }
231
- document.getElementById('upload-form').addEventListener('submit', async function(event) {
232
- event.preventDefault();
233
- const fileInput = document.getElementById('file-upload');
234
- if (!fileInput.files || fileInput.files.length === 0) {
235
- alert("Please select a video file first.");
236
- return;
237
- }
238
- document.getElementById('upload-view').style.display = 'none';
239
- document.getElementById('loading-view').style.display = 'flex';
240
- const formData = new FormData();
241
- formData.append('file', fileInput.files[0]);
242
- try {
243
- const response = await fetch('/uploader', {
244
- method: 'POST',
245
- body: formData
246
- });
247
- if (!response.ok) {
248
- throw new Error('Upload failed!');
249
- }
250
- statusInterval = setInterval(checkStatus, 2000);
251
- } catch (error) {
252
- console.error("Upload error:", error);
253
- document.getElementById('status-text').textContent = "Failed to start generation. Please try again.";
254
- }
255
- });
256
- </script>
257
- </body>
258
- </html>
259
- '''
260
-
261
- os.makedirs('video', exist_ok=True)
262
- os.makedirs('frames/final', exist_ok=True)
263
- os.makedirs('output', exist_ok=True)
264
-
265
- def update_status(message, progress):
266
- status_file = os.path.join('output', 'status.json')
267
- with open(status_file, 'w') as f:
268
- json.dump({'message': message, 'progress': progress}, f)
269
-
270
- class EnhancedComicGenerator:
271
- def __init__(self):
272
- self.video_path = 'video/uploaded.mp4'
273
- self.frames_dir = 'frames/final'
274
- self.output_dir = 'output'
275
- self.apply_comic_style = False
276
- self.video_fps = None
277
-
278
- def cleanup_generated(self):
279
- print("🧹 Performing full cleanup...")
280
- if os.path.isdir(self.frames_dir): shutil.rmtree(self.frames_dir)
281
- if os.path.isdir(self.output_dir): shutil.rmtree(self.output_dir)
282
- if os.path.isdir('temp'): shutil.rmtree('temp')
283
- if os.path.exists('test1.srt'): os.remove('test1.srt')
284
- os.makedirs(self.frames_dir, exist_ok=True)
285
- os.makedirs(self.output_dir, exist_ok=True)
286
- print("✅ Cleanup complete.")
287
-
288
- def regenerate_frame(self, frame_filename, direction):
289
- try:
290
- if not self.video_fps:
291
- return {"success": False, "message": "Video FPS not found."}
292
- metadata_path = 'frames/frame_metadata.json'
293
- if not os.path.exists(metadata_path):
294
- return {"success": False, "message": "Frame metadata missing."}
295
- with open(metadata_path, 'r') as f:
296
- frame_to_time = json.load(f)
297
- if frame_filename not in frame_to_time:
298
- return {"success": False, "message": "Panel not linked to video."}
299
- current_time = frame_to_time[frame_filename]['time'] if isinstance(frame_to_time[frame_filename], dict) else frame_to_time[frame_filename]
300
- frame_duration = 1.0 / self.video_fps
301
- target_time = current_time + frame_duration if direction == 'forward' else current_time - frame_duration
302
- target_time = max(0, target_time)
303
- cap = cv2.VideoCapture(self.video_path)
304
- if not cap.isOpened(): return {"success": False, "message": "Cannot open video."}
305
- cap.set(cv2.CAP_PROP_POS_MSEC, target_time * 1000)
306
- ret, frame = cap.read()
307
- cap.release()
308
- if not ret or frame is None:
309
- return {"success": False, "message": f"No frame at {target_time:.2f}s."}
310
-
311
- new_path = os.path.join(self.frames_dir, frame_filename)
312
- cv2.imwrite(new_path, frame)
313
-
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
320
- else:
321
- frame_to_time[frame_filename] = target_time
322
- with open(metadata_path, 'w') as f: json.dump(frame_to_time, f, indent=2)
323
- message = f"Adjusted {direction} to {target_time:.3f}s"
324
- print(f"✅ {message}")
325
- return {"success": True, "message": message, "new_filename": frame_filename}
326
- except Exception as e:
327
- traceback.print_exc()
328
- return {"success": False, "message": str(e)}
329
-
330
- def get_frame_at_timestamp(self, frame_filename, timestamp_seconds):
331
- try:
332
- metadata_path = 'frames/frame_metadata.json'
333
- if not os.path.exists(metadata_path): return {"success": False, "message": "Frame metadata missing."}
334
- cap = cv2.VideoCapture(self.video_path)
335
- if not cap.isOpened(): return {"success": False, "message": "Cannot open video."}
336
- fps = cap.get(cv2.CAP_PROP_FPS)
337
- if fps == 0: fps = 25
338
- duration = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) / fps
339
- if timestamp_seconds < 0 or timestamp_seconds > duration:
340
- cap.release()
341
- return {"success": False, "message": f"Timestamp must be between 0 and {duration:.2f}s."}
342
- cap.set(cv2.CAP_PROP_POS_MSEC, timestamp_seconds * 1000)
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
- 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
-
354
- with open(metadata_path, 'r') as f: frame_to_time = json.load(f)
355
- if frame_filename in frame_to_time:
356
- if isinstance(frame_to_time[frame_filename], dict):
357
- frame_to_time[frame_filename]['time'] = timestamp_seconds
358
- else:
359
- frame_to_time[frame_filename] = timestamp_seconds
360
- with open(metadata_path, 'w') as f: json.dump(frame_to_time, f, indent=2)
361
- message = f"Jumped to timestamp {timestamp_seconds:.3f}s"
362
- print(f"✅ {message}")
363
- return { "success": True, "message": message }
364
- except Exception as e:
365
- traceback.print_exc()
366
- return {"success": False, "message": str(e)}
367
-
368
- def generate_keyframes_from_moments(self, video_path, key_moments, max_frames=32):
369
- try:
370
- cap = cv2.VideoCapture(video_path)
371
- if not cap.isOpened(): raise Exception("Cannot open video for keyframe extraction")
372
- fps, total_frames = self.video_fps, int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
373
- duration = total_frames / fps
374
- key_moments.sort(key=lambda x: x['start'])
375
- if len(key_moments) > max_frames: pass # Simplified sampling
376
- frame_metadata, frame_count = {}, 0
377
- for i, moment in enumerate(key_moments):
378
- update_status(f"Extracting frame {i+1}/{len(key_moments)}...", 25 + int(20 * (i / len(key_moments))))
379
- frame_time = (moment['start'] + moment['end']) / 2
380
- if frame_time > duration: continue
381
- frame_number = int(frame_time * fps)
382
- cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
383
- ret, frame = cap.read()
384
- if ret:
385
- frame_filename = f"frame_{frame_count:04d}.png"
386
- frame_path = os.path.join(self.frames_dir, frame_filename)
387
- cv2.imwrite(frame_path, frame)
388
- frame_metadata[frame_filename] = { 'time': frame_time, 'dialogue': moment['text'], 'start': moment['start'], 'end': moment['end'] }
389
- frame_count += 1
390
- cap.release()
391
- with open(os.path.join('frames', 'frame_metadata.json'), 'w') as f:
392
- json.dump(frame_metadata, f, indent=2)
393
- print(f"✅ Extracted {frame_count} keyframes from video")
394
- return True
395
- except Exception as e:
396
- print(f"❌ Error extracting keyframes: {e}")
397
- return False
398
-
399
- def generate_comic(self):
400
- start_time = time.time()
401
- try:
402
- update_status("Cleaning up...", 0)
403
- self.cleanup_generated()
404
- update_status("Analyzing video...", 5)
405
- cap = cv2.VideoCapture(self.video_path)
406
- if not cap.isOpened(): raise Exception("Cannot open video to get FPS.")
407
- self.video_fps = cap.get(cv2.CAP_PROP_FPS)
408
- if self.video_fps == 0: self.video_fps = 25
409
- cap.release()
410
- print(f"✅ Video FPS detected: {self.video_fps:.2f}")
411
- update_status("Generating subtitles (this may take a while)...", 10)
412
- get_real_subtitles(self.video_path)
413
- with open('test1.srt', 'r', encoding='utf-8') as f:
414
- all_subs = list(srt.parse(f.read()))
415
- key_moments = [{'index': s.index, 'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in all_subs]
416
- if not self.generate_keyframes_from_moments(self.video_path, key_moments, max_frames=32):
417
- raise Exception("Keyframe extraction failed.")
418
- update_status("Cropping black bars...", 45)
419
- black_x, black_y, _, _ = black_bar_crop()
420
- update_status("Enhancing images (in parallel)...", 50)
421
- self._enhance_all_images()
422
- self._enhance_quality_colors()
423
- update_status("Placing speech bubbles (in parallel)...", 75)
424
- bubbles = self._create_ai_bubbles_from_moments(black_x, black_y)
425
- update_status("Assembling comic pages...", 90)
426
- pages = self._generate_pages(bubbles)
427
- update_status("Saving final comic...", 95)
428
- self._save_results(pages)
429
- execution_time = (time.time() - start_time) / 60
430
- print(f"✅ Comic generation completed in {execution_time:.2f} minutes")
431
- update_status("Complete!", 100)
432
- return True
433
- except Exception as e:
434
- print(f"❌ Comic generation failed: {e}")
435
- traceback.print_exc()
436
- update_status(f"Error: {e}", -1)
437
- return False
438
-
439
- def _enhance_all_images(self, single_image_path=None):
440
- try:
441
- enhancer = SimpleColorEnhancer()
442
- if single_image_path:
443
- enhancer.enhance_single(single_image_path)
444
- else:
445
- frame_paths = [os.path.join(self.frames_dir, f) for f in os.listdir(self.frames_dir) if f.endswith('.png')]
446
- with ThreadPoolExecutor() as executor:
447
- list(executor.map(enhancer.enhance_single, frame_paths))
448
- except Exception as e:
449
- print(f"❌ Simple enhancement failed: {e}")
450
-
451
- def _enhance_quality_colors(self, single_image_path=None):
452
- try:
453
- enhancer = QualityColorEnhancer()
454
- if single_image_path:
455
- enhancer.enhance_single(single_image_path)
456
- else:
457
- frame_paths = [os.path.join(self.frames_dir, f) for f in os.listdir(self.frames_dir) if f.endswith('.png')]
458
- with ThreadPoolExecutor() as executor:
459
- list(executor.map(enhancer.enhance_single, frame_paths))
460
- except Exception as e:
461
- print(f"⚠️ Quality enhancement failed: {e}")
462
-
463
- def _process_bubble_for_frame(self, frame_file):
464
- frame_path = os.path.join(self.frames_dir, frame_file)
465
- dialogue = self.frame_metadata.get(frame_file, {}).get('dialogue', "")
466
- try:
467
- faces = face_detector.detect_faces(frame_path)
468
- lip_x, lip_y = face_detector.get_lip_position(frame_path, faces[0]) if faces else (-1, -1)
469
- bubble_x, bubble_y = ai_bubble_placer.place_bubble_ai(frame_path, (lip_x, lip_y))
470
- return bubble(bubble_offset_x=bubble_x, bubble_offset_y=bubble_y, lip_x=lip_x, lip_y=lip_y, dialog=dialogue, emotion='normal')
471
- except Exception as e:
472
- print(f"-> Could not place bubble for {frame_file}: {e}. Using default.")
473
- return bubble(bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, dialog=dialogue, emotion='normal')
474
-
475
- def _create_ai_bubbles_from_moments(self, black_x, black_y):
476
- frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
477
- metadata_path = 'frames/frame_metadata.json'
478
- if not os.path.exists(metadata_path):
479
- return [bubble(dialog="") for _ in frame_files]
480
-
481
- with open(metadata_path, 'r') as f:
482
- self.frame_metadata = json.load(f)
483
-
484
- with ThreadPoolExecutor() as executor:
485
- bubbles = list(executor.map(self._process_bubble_for_frame, frame_files))
486
-
487
- return bubbles
488
-
489
- def _generate_pages(self, bubbles):
490
- try:
491
- from backend.fixed_12_pages_800x1080 import generate_12_pages_800x1080
492
- return generate_12_pages_800x1080(sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')]), bubbles)
493
- except ImportError:
494
- pages, frame_files = [], sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
495
- num_pages = (len(frame_files) + 3) // 4
496
- for i in range(num_pages):
497
- start, end = i*4, (i+1)*4
498
- page_panels = [panel(image=f) for f in frame_files[start:end]]
499
- page_bubbles = bubbles[start:end]
500
- if page_panels: pages.append(Page(panels=page_panels, bubbles=page_bubbles))
501
- return pages
502
-
503
- def _save_results(self, pages):
504
- try:
505
- os.makedirs(self.output_dir, exist_ok=True)
506
- pages_data = []
507
- for page in pages:
508
- panels = [p.__dict__ if hasattr(p, '__dict__') else p for p in page.panels]
509
- bubbles_data = [b.__dict__ if hasattr(b, '__dict__') else b for b in page.bubbles]
510
- pages_data.append({'panels': panels, 'bubbles': bubbles_data})
511
- with open(os.path.join(self.output_dir, 'pages.json'), 'w', encoding='utf-8') as f:
512
- json.dump(pages_data, f, indent=2)
513
- self._copy_template_files()
514
- print("✅ Results saved successfully!")
515
- except Exception as e:
516
- print(f"Save results failed: {e}")
517
-
518
- def _copy_template_files(self):
519
- try:
520
- template_html = '''<!DOCTYPE html>
521
- <html lang="en">
522
- <head>
523
- <meta charset="UTF-8">
524
- <title>Comic Editor</title>
525
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
526
  <link rel="preconnect" href="https://fonts.googleapis.com">
527
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -562,90 +113,95 @@ class EnhancedComicGenerator:
562
  .speech-bubble.selected { outline: 2px dashed #4CAF50; }
563
  .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; }
564
 
565
- /* <<< EXPORT-SAFE VARIANT #85 (Using Border-Radius Tricks) >>> */
566
  /*
567
- Since html2canvas does NOT support CSS masks, we replicate the
568
- "Shark Fin" shape using carefully crafted border-radius geometry.
569
- This looks identical but renders perfectly on export.
570
  */
571
  .speech-bubble.speech {
572
  --bubble-fill-color: #4ECDC4;
573
  --bubble-text-color: #ffffff;
 
 
574
 
575
- /* The Main Body Bubble Variables */
576
- --b: 3em; /* Tail base */
577
- --h: 1.8em; /* Tail height */
578
- --t: 0.6; /* Thickness */
579
- --p: var(--tail-pos, 50%);
580
- --r: 1.2em; /* Radius */
581
 
582
  background: var(--bubble-fill-color);
583
  color: var(--bubble-text-color);
584
  padding: 1em;
585
  position: absolute;
586
 
587
- /* Replicate the bubble body shape from the user CSS */
588
  border-radius: var(--r) var(--r)
589
- min(var(--r), calc(100% - var(--p) - (1 - var(--t)) * var(--b) / 2))
590
- min(var(--r), calc(var(--p) - (1 - var(--t)) * var(--b) / 2))
591
  / var(--r);
592
  }
593
 
594
- /* The Tail Pseudo-Element (Using Geometric Border Radius) */
595
  .speech-bubble.speech:before {
596
  content: "";
597
  position: absolute;
598
- width: var(--b);
599
- height: var(--h);
600
- background: var(--bubble-fill-color); /* Same as body */
601
  pointer-events: none;
602
 
603
- /* This shape mimics the Shark Fin: Square with one rounded corner */
604
- border-bottom-left-radius: 100%;
605
-
606
- /* Optional: Use clip-path to clean up edges if needed, but this shape is pure */
 
 
 
 
 
 
 
607
  }
608
 
609
  /* BOTTOM TAIL */
610
  .speech-bubble.speech.tail-bottom:before {
611
- top: 100%; /* Attach to bottom */
612
- /* Exact positioning calc from user CSS */
613
- left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
614
- /* No rotation needed for bottom-left curve look */
 
 
 
 
 
615
  }
616
 
617
- /* TOP TAIL (Flip Vertical) */
618
- .speech-bubble.speech.tail-top {
619
- border-radius: min(var(--r), calc(var(--p) - (1 - var(--t)) * var(--b) / 2))
620
- min(var(--r), calc(100% - var(--p) - (1 - var(--t)) * var(--b) / 2))
621
- var(--r) var(--r) / var(--r);
622
- }
623
  .speech-bubble.speech.tail-top:before {
624
- bottom: 100%;
625
- left: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
626
- transform: scaleY(-1);
 
 
 
627
  }
628
 
629
- /* LEFT TAIL (Rotate 90) */
630
- .speech-bubble.speech.tail-left {
631
- border-radius: var(--r);
632
- }
633
  .speech-bubble.speech.tail-left:before {
634
- right: 100%;
635
- top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
636
- transform: rotate(90deg);
637
- transform-origin: right top;
 
 
638
  }
639
 
640
- /* RIGHT TAIL (Rotate -90) */
641
- .speech-bubble.speech.tail-right {
642
- border-radius: var(--r);
643
- }
644
  .speech-bubble.speech.tail-right:before {
645
- left: 100%;
646
- top: clamp(0%, calc(var(--p) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
647
- transform: rotate(-90deg);
648
- transform-origin: left top;
 
 
649
  }
650
 
651
  .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
@@ -875,7 +431,7 @@ class EnhancedComicGenerator:
875
  bubble.dataset.tailPos = '0';
876
  }
877
  if (type === 'thought') {
878
- for (let i = 1; i <= 2; i++) {
879
  const dot = document.createElement('div');
880
  dot.className = `thought-dot thought-dot-${i}`;
881
  bubble.appendChild(dot);
 
73
  <meta charset="UTF-8">
74
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
75
  <title>Movie to Comic Generator</title>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
77
  <link rel="preconnect" href="https://fonts.googleapis.com">
78
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 
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
+ /* <<< THIN "SHARK FIN" TAIL (EXPORT SAFE) >>> */
117
  /*
118
+ We replicate the visual result of the Mask CSS using a "Crescent Shadow" trick.
119
+ This creates the thin, curved thorn shape using box-shadow on a rounded element,
120
+ which IS supported by html2canvas (unlike masks).
121
  */
122
  .speech-bubble.speech {
123
  --bubble-fill-color: #4ECDC4;
124
  --bubble-text-color: #ffffff;
125
+ --bubble-border-color: #333333; /* Only used if borders enabled */
126
+ --tail-pos: 50%;
127
 
128
+ /* The original variables from your CSS */
129
+ --b: 3em; /* base */
130
+ --h: 1.8em; /* height */
131
+ --t: .6; /* thickness */
132
+ --r: 1.2em; /* radius */
 
133
 
134
  background: var(--bubble-fill-color);
135
  color: var(--bubble-text-color);
136
  padding: 1em;
137
  position: absolute;
138
 
139
+ /* The exact border-radius logic from your CSS */
140
  border-radius: var(--r) var(--r)
141
+ min(var(--r), calc(100% - var(--tail-pos) - (1 - var(--t)) * var(--b) / 2))
142
+ min(var(--r), calc(var(--tail-pos) - (1 - var(--t)) * var(--b) / 2))
143
  / var(--r);
144
  }
145
 
 
146
  .speech-bubble.speech:before {
147
  content: "";
148
  position: absolute;
 
 
 
149
  pointer-events: none;
150
 
151
+ /*
152
+ CRESCENT TRICK:
153
+ 1. Create a transparent box.
154
+ 2. Round one corner significantly.
155
+ 3. Use box-shadow to "paint" the shape outside the transparent box.
156
+ This creates a sharp, thin, concave curve exactly like the mask.
157
+ */
158
+ width: 1.5em; /* Controls fin thinness width */
159
+ height: 1.8em; /* Controls fin height */
160
+ background: transparent;
161
+ z-index: 1;
162
  }
163
 
164
  /* BOTTOM TAIL */
165
  .speech-bubble.speech.tail-bottom:before {
166
+ top: 90%; /* Slight overlap to seal gap */
167
+ /* Exact placement logic */
168
+ left: clamp(0%, calc(var(--tail-pos) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
169
+
170
+ /* The Crescent Shape Construction */
171
+ border-radius: 0 0 100% 0; /* Bottom-right curve */
172
+ box-shadow: inset -10px -10px 0px -2px var(--bubble-fill-color); /* The Fin */
173
+ transform: rotate(-10deg); /* Angle it slightly for the style */
174
+ transform-origin: top left;
175
  }
176
 
177
+ /* TOP TAIL (Reflected) */
 
 
 
 
 
178
  .speech-bubble.speech.tail-top:before {
179
+ bottom: 90%;
180
+ left: clamp(0%, calc(var(--tail-pos) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
181
+ border-radius: 0 100% 0 0;
182
+ box-shadow: inset -10px 10px 0px -2px var(--bubble-fill-color);
183
+ transform: rotate(10deg);
184
+ transform-origin: bottom left;
185
  }
186
 
187
+ /* LEFT TAIL */
 
 
 
188
  .speech-bubble.speech.tail-left:before {
189
+ right: 90%;
190
+ top: clamp(0%, calc(var(--tail-pos) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
191
+ border-radius: 0 0 0 100%;
192
+ box-shadow: inset 10px -10px 0px -2px var(--bubble-fill-color);
193
+ transform: rotate(10deg);
194
+ transform-origin: top right;
195
  }
196
 
197
+ /* RIGHT TAIL */
 
 
 
198
  .speech-bubble.speech.tail-right:before {
199
+ left: 90%;
200
+ top: clamp(0%, calc(var(--tail-pos) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
201
+ border-radius: 0 0 100% 0;
202
+ box-shadow: inset -10px -10px 0px -2px var(--bubble-fill-color);
203
+ transform: rotate(-10deg);
204
+ transform-origin: top left;
205
  }
206
 
207
  .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
 
431
  bubble.dataset.tailPos = '0';
432
  }
433
  if (type === 'thought') {
434
+ for (let i = 2; i <= 2; i++) {
435
  const dot = document.createElement('div');
436
  dot.className = `thought-dot thought-dot-${i}`;
437
  bubble.appendChild(dot);