jhh6576 commited on
Commit
46d7eca
·
verified ·
1 Parent(s): a5879ed

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +453 -0
app_enhanced.py CHANGED
@@ -77,6 +77,459 @@ 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 { margin: 0; padding: 20px; background: #f0f0f0; font-family: 'Lato', sans-serif; }
82
  .comic-container { max-width: 1200px; margin: 0 auto; }
 
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; }