jhh6576 commited on
Commit
3ab7038
·
verified ·
1 Parent(s): 376a726

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +457 -6
app_enhanced.py CHANGED
@@ -73,6 +73,455 @@ 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
  <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>
@@ -164,13 +613,14 @@ INDEX_HTML = '''
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
 
@@ -179,7 +629,7 @@ INDEX_HTML = '''
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
  }
@@ -189,7 +639,7 @@ INDEX_HTML = '''
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
  }
@@ -199,7 +649,7 @@ INDEX_HTML = '''
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
  }
@@ -821,6 +1271,7 @@ INDEX_HTML = '''
821
  </script>
822
  </body>
823
  </html>'''
 
824
  with open(os.path.join(self.output_dir, 'page.html'), 'w', encoding='utf-8') as f:
825
  f.write(template_html)
826
  print("📄 Template files copied successfully!")
 
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>
 
613
  /* BOTTOM TAIL */
614
  .speech-bubble.speech.tail-bottom:before {
615
  top: 90%; /* Slight overlap to seal gap */
616
+ /* Exact positioning logic */
617
  left: clamp(0%, calc(var(--tail-pos) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
618
 
619
  /* The Crescent Shape Construction */
620
  border-radius: 0 0 100% 0; /* Bottom-right curve */
621
+ /* Using thinner shadow values (-6px vs -10px) makes the tail thinner */
622
+ box-shadow: inset -6px -6px 0px -2px var(--bubble-fill-color);
623
+ transform: rotate(-10deg);
624
  transform-origin: top left;
625
  }
626
 
 
629
  bottom: 90%;
630
  left: clamp(0%, calc(var(--tail-pos) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
631
  border-radius: 0 100% 0 0;
632
+ box-shadow: inset -6px 6px 0px -2px var(--bubble-fill-color);
633
  transform: rotate(10deg);
634
  transform-origin: bottom left;
635
  }
 
639
  right: 90%;
640
  top: clamp(0%, calc(var(--tail-pos) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
641
  border-radius: 0 0 0 100%;
642
+ box-shadow: inset 6px -6px 0px -2px var(--bubble-fill-color);
643
  transform: rotate(10deg);
644
  transform-origin: top right;
645
  }
 
649
  left: 90%;
650
  top: clamp(0%, calc(var(--tail-pos) - (1 - var(--t)) * var(--b) / 2), calc(100% - (1 - var(--t)) * var(--b)));
651
  border-radius: 0 0 100% 0;
652
+ box-shadow: inset -6px -6px 0px -2px var(--bubble-fill-color);
653
  transform: rotate(-10deg);
654
  transform-origin: top left;
655
  }
 
1271
  </script>
1272
  </body>
1273
  </html>'''
1274
+ # Fixed indentation for the write operation
1275
  with open(os.path.join(self.output_dir, 'page.html'), 'w', encoding='utf-8') as f:
1276
  f.write(template_html)
1277
  print("📄 Template files copied successfully!")