jhh6576 commited on
Commit
c2571ea
·
verified ·
1 Parent(s): a6a00f9

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +319 -573
app_enhanced.py CHANGED
@@ -1,476 +1,4 @@
1
-
2
- import os
3
- import webbrowser
4
- import time
5
- import threading
6
- from flask import Flask, render_template, request, jsonify, send_from_directory, send_file
7
- from pathlib import Path
8
- import cv2
9
- import numpy as np
10
- from PIL import Image
11
- import srt
12
- import json
13
- import shutil
14
- from typing import List
15
- import traceback
16
-
17
- # --- ROBUST IMPORTS WITH FALLBACKS ---
18
- # This structure prevents the app from crashing if a module is missing.
19
- # It will log a warning and skip the feature instead.
20
-
21
- try:
22
- from backend.keyframes.keyframes import black_bar_crop
23
- print("✅ Black bar cropping module loaded.")
24
- except Exception as e:
25
- print(f"⚠️ Could not load black_bar_crop: {e}. Cropping will be SKIPPED.")
26
- def black_bar_crop():
27
- return 0, 0, None, None
28
-
29
- try:
30
- from backend.simple_color_enhancer import SimpleColorEnhancer
31
- print("✅ SimpleColorEnhancer loaded.")
32
- except Exception as e:
33
- print(f"⚠️ Could not load SimpleColorEnhancer: {e}. This feature will be SKIPPED.")
34
- class SimpleColorEnhancer:
35
- def enhance_batch(self, *args, **kwargs):
36
- print("-> Skipping simple color enhancement (module not loaded).")
37
- pass
38
-
39
- try:
40
- from backend.quality_color_enhancer import QualityColorEnhancer
41
- print("✅ QualityColorEnhancer loaded.")
42
- except Exception as e:
43
- print(f"⚠️ Could not load QualityColorEnhancer: {e}. This feature will be SKIPPED.")
44
- class QualityColorEnhancer:
45
- def batch_enhance(self, *args, **kwargs):
46
- print("-> Skipping quality color enhancement (module not loaded).")
47
- pass
48
-
49
- try:
50
- from backend.class_def import bubble, panel, Page
51
- print("✅ Core class definitions (bubble, panel, Page) loaded.")
52
- except Exception as e:
53
- print(f"⚠️ CRITICAL: Could not load core class definitions: {e}. Using fallback definitions.")
54
- def bubble(**kwargs): return kwargs
55
- def panel(**kwargs): return kwargs
56
- class Page:
57
- def __init__(self, panels, bubbles):
58
- self.panels = panels
59
- self.bubbles = bubbles
60
-
61
- try:
62
- from backend.ai_enhanced_core import image_processor, comic_styler, face_detector, layout_optimizer
63
- from backend.ai_bubble_placement import ai_bubble_placer
64
- from backend.subtitles.subs_real import get_real_subtitles
65
- from backend.keyframes.keyframes_simple import generate_keyframes_simple
66
- print("✅ Core utility modules loaded.")
67
- except Exception as e:
68
- print(f"⚠️ Could not load a core utility module: {e}")
69
-
70
- # Import smart comic generation
71
- try:
72
- from backend.emotion_aware_comic import EmotionAwareComicGenerator
73
- from backend.story_analyzer import SmartComicGenerator
74
- SMART_COMIC_AVAILABLE = True
75
- print("✅ Smart comic generation available!")
76
- except Exception as e:
77
- SMART_COMIC_AVAILABLE = False
78
- print(f"⚠️ Smart comic generation not available: {e}")
79
-
80
- # Import panel extractor
81
- try:
82
- from backend.panel_extractor import PanelExtractor
83
- PANEL_EXTRACTOR_AVAILABLE = True
84
- print("✅ Panel extractor available!")
85
- except Exception as e:
86
- PANEL_EXTRACTOR_AVAILABLE = False
87
- print(f"⚠️ Panel extractor not available: {e}")
88
-
89
- # Import smart story extractor
90
- try:
91
- from backend.smart_story_extractor import SmartStoryExtractor
92
- STORY_EXTRACTOR_AVAILABLE = True
93
- print("✅ Smart story extractor available!")
94
- except Exception as e:
95
- STORY_EXTRACTOR_AVAILABLE = False
96
- print(f"⚠️ Smart story extractor not available: {e}")
97
-
98
- app = Flask(__name__)
99
-
100
- # Import editor routes
101
- try:
102
- from comic_editor_server import add_editor_routes
103
- add_editor_routes(app)
104
- print("✅ Comic editor integrated!")
105
- except Exception as e:
106
- print(f"⚠️ Could not load comic editor: {e}")
107
-
108
- # Ensure directories exist
109
- os.makedirs('video', exist_ok=True)
110
- os.makedirs('frames/final', exist_ok=True)
111
- os.makedirs('output', exist_ok=True)
112
-
113
- class EnhancedComicGenerator:
114
- """High-quality comic generation with AI enhancement"""
115
- def __init__(self):
116
- self.video_path = 'video/uploaded.mp4'
117
- self.frames_dir = 'frames/final'
118
- self.output_dir = 'output'
119
- self.apply_comic_style = False
120
-
121
- def cleanup_generated(self):
122
- """Deletes all old files to ensure a fresh start."""
123
- print("🧹 Performing full cleanup of previous run...")
124
- if os.path.isdir(self.frames_dir): shutil.rmtree(self.frames_dir)
125
- if os.path.isdir(self.output_dir): shutil.rmtree(self.output_dir)
126
- if os.path.isdir('temp'): shutil.rmtree('temp')
127
- if os.path.exists('test1.srt'): os.remove('test1.srt')
128
- os.makedirs(self.frames_dir, exist_ok=True)
129
- os.makedirs(self.output_dir, exist_ok=True)
130
- print("✅ Cleanup complete.")
131
-
132
- def detect_eye_state(self, frame_path):
133
- """
134
- Detect if eyes are closed or semi-closed in a frame
135
- Returns: 'open', 'semi-closed', or 'closed'
136
- """
137
- try:
138
- img = cv2.imread(frame_path)
139
- gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
140
- face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
141
- eye_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_eye.xml')
142
- faces = face_cascade.detectMultiScale(gray, 1.3, 5)
143
- for (x, y, w, h) in faces:
144
- roi_gray = gray[y:y+h, x:x+w]
145
- eyes = eye_cascade.detectMultiScale(roi_gray)
146
- if len(eyes) == 0:
147
- return 'closed'
148
- elif len(eyes) == 1:
149
- return 'semi-closed'
150
- for (ex, ey, ew, eh) in eyes:
151
- eye_region = roi_gray[ey:ey+eh, ex:ex+ew]
152
- vert_var = np.var(eye_region, axis=0).mean()
153
- if vert_var < 500:
154
- return 'semi-closed'
155
- return 'open'
156
- except:
157
- return 'open'
158
-
159
- def regenerate_frame(self, frame_filename):
160
- """
161
- Regenerate frame by moving +0.1s forward in the original video.
162
- Updates metadata so repeated clicks keep advancing.
163
- """
164
- try:
165
- metadata_path = 'frames/frame_metadata.json'
166
- if not os.path.exists(metadata_path):
167
- return {"success": False, "message": "Frame metadata missing."}
168
-
169
- with open(metadata_path, 'r') as f:
170
- frame_to_time = json.load(f)
171
-
172
- if frame_filename not in frame_to_time:
173
- return {"success": False, "message": "Panel not linked to original video."}
174
-
175
- if isinstance(frame_to_time[frame_filename], dict):
176
- current_time = frame_to_time[frame_filename]['time']
177
- else:
178
- current_time = frame_to_time[frame_filename]
179
-
180
- target_time = current_time + 0.1
181
-
182
- cap = cv2.VideoCapture(self.video_path)
183
- if not cap.isOpened():
184
- return {"success": False, "message": "Cannot open video."}
185
-
186
- cap.set(cv2.CAP_PROP_POS_MSEC, target_time * 1000)
187
- ret, frame = cap.read()
188
- cap.release()
189
-
190
- if not ret or frame is None:
191
- return {"success": False, "message": "No next frame available at +0.1s."}
192
-
193
- new_path = os.path.join(self.frames_dir, frame_filename)
194
- cv2.imwrite(new_path, frame)
195
-
196
- if isinstance(frame_to_time[frame_filename], dict):
197
- frame_to_time[frame_filename]['time'] = target_time
198
- else:
199
- frame_to_time[frame_filename] = target_time
200
-
201
- with open(metadata_path, 'w') as f:
202
- json.dump(frame_to_time, f, indent=2)
203
-
204
- print(f"✅ Regenerated {frame_filename} to time {target_time:.2f}s without enhancement.")
205
-
206
- return {
207
- "success": True,
208
- "message": f"Advanced to {target_time:.2f}s (+0.1s)",
209
- "new_filename": frame_filename
210
- }
211
-
212
- except Exception as e:
213
- traceback.print_exc()
214
- return {"success": False, "message": str(e)}
215
-
216
- def generate_keyframes_from_moments(self, video_path, key_moments, max_frames=48):
217
- """
218
- Generate frames specifically at the key moments timestamps
219
- """
220
- try:
221
- cap = cv2.VideoCapture(video_path)
222
- if not cap.isOpened():
223
- print("❌ Cannot open video for keyframe extraction")
224
- return False
225
-
226
- fps = cap.get(cv2.CAP_PROP_FPS)
227
- if fps == 0:
228
- print("⚠️ Video FPS is 0, defaulting to 25. Keyframe extraction might be inaccurate.")
229
- fps = 25
230
-
231
- total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
232
- duration = total_frames / fps
233
-
234
- key_moments.sort(key=lambda x: x['start'])
235
-
236
- if len(key_moments) > max_frames:
237
- first_count = min(5, max_frames // 4)
238
- last_count = min(5, max_frames // 4)
239
- middle_count = max_frames - first_count - last_count
240
-
241
- if middle_count > 0 and len(key_moments) > (first_count + last_count):
242
- first_moments = key_moments[:first_count]
243
- last_moments = key_moments[-last_count:]
244
- middle_moments = key_moments[first_count:-last_count]
245
-
246
- if len(middle_moments) > middle_count:
247
- step = len(middle_moments) / middle_count
248
- middle_sampled = [middle_moments[int(i * step)] for i in range(middle_count)]
249
- else:
250
- middle_sampled = middle_moments
251
-
252
- key_moments = first_moments + middle_sampled + last_moments
253
- else:
254
- step = len(key_moments) / max_frames
255
- key_moments = [key_moments[int(i * step)] for i in range(max_frames)]
256
-
257
- frame_metadata = {}
258
- frame_count = 0
259
-
260
- for moment in key_moments:
261
- frame_time = (moment['start'] + moment['end']) / 2
262
-
263
- if frame_time > duration:
264
- continue
265
-
266
- frame_number = int(frame_time * fps)
267
-
268
- cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
269
- ret, frame = cap.read()
270
-
271
- if ret:
272
- frame_filename = f"frame_{frame_count:04d}.png"
273
- frame_path = os.path.join(self.frames_dir, frame_filename)
274
- cv2.imwrite(frame_path, frame)
275
-
276
- frame_metadata[frame_filename] = {
277
- 'time': frame_time,
278
- 'dialogue': moment['text'],
279
- 'start': moment['start'],
280
- 'end': moment['end']
281
- }
282
- frame_count += 1
283
- print(f"📸 Extracted frame at {frame_time:.2f}s: {moment['text'][:30]}...")
284
-
285
- cap.release()
286
-
287
- with open(os.path.join('frames', 'frame_metadata.json'), 'w') as f:
288
- json.dump(frame_metadata, f, indent=2)
289
-
290
- print(f"✅ Extracted {frame_count} keyframes from video")
291
- return True
292
-
293
- except Exception as e:
294
- print(f"❌ Error extracting keyframes: {e}")
295
- traceback.print_exc()
296
- return False
297
-
298
- def generate_comic(self, smart_mode=False, emotion_match=False):
299
- """Main comic generation pipeline"""
300
- start_time = time.time()
301
- self.cleanup_generated()
302
- print("🎬 Starting Enhanced Comic Generation...")
303
- try:
304
- print("📝 Generating subtitles...")
305
- get_real_subtitles(self.video_path)
306
- all_subs = []
307
- if os.path.exists('test1.srt'):
308
- with open('test1.srt', 'r', encoding='utf-8') as f:
309
- all_subs = list(srt.parse(f.read()))
310
- print(f"✅ Loaded {len(all_subs)} subtitles")
311
- else:
312
- print("❌ Subtitle file (test1.srt) not found!")
313
- return False
314
-
315
- try:
316
- from backend.full_story_extractor import FullStoryExtractor
317
- extractor = FullStoryExtractor()
318
- sub_list = [{'index': s.index, 'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in all_subs]
319
- os.makedirs('temp', exist_ok=True)
320
- with open('temp/all_subs.json', 'w') as f: json.dump(sub_list, f)
321
-
322
- story_subs = extractor.extract_full_story('temp/all_subs.json')
323
- story_indices = {s.get('index') for s in story_subs}
324
- filtered_subs = [sub for sub in all_subs if sub.index in story_indices]
325
- print(f"📚 Full story: {len(filtered_subs)} key moments from {len(all_subs)} total")
326
- except Exception as e:
327
- print(f"⚠️ Full story extraction failed, using all subtitles: {e}")
328
- filtered_subs = all_subs
329
-
330
- key_moments = [{'index': s.index, 'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in filtered_subs]
331
-
332
- with open(os.path.join(self.output_dir, 'key_moments.json'), 'w', encoding='utf-8') as f:
333
- json.dump(key_moments, f, indent=2)
334
-
335
- print("🎬 Extracting frames at key moments...")
336
- if not self.generate_keyframes_from_moments(self.video_path, key_moments, max_frames=48):
337
- print("❌ Keyframe extraction failed.")
338
- return False
339
-
340
- print("✂️ Cropping black bars...")
341
- black_x, black_y, _, _ = black_bar_crop()
342
- print("✅ Black bars cropped.")
343
-
344
- print("🎨 Enhancing images...")
345
- self._enhance_all_images()
346
- self._enhance_quality_colors()
347
- print("✅ Images enhancement step complete.")
348
-
349
- print("💬 Creating AI bubbles with key moment dialogues...")
350
- bubbles = self._create_ai_bubbles_from_moments(black_x, black_y)
351
- print(f"✅ Created {len(bubbles)} bubbles.")
352
-
353
- print("📋 Generating pages...")
354
- pages = self._generate_pages(bubbles)
355
- print(f"✅ Generated {len(pages)} pages.")
356
-
357
- print("💾 Saving results...")
358
- self._save_results(pages)
359
- print("✅ Results saved.")
360
-
361
- execution_time = (time.time() - start_time) / 60
362
- print(f"✅ Comic generation completed in {execution_time:.2f} minutes")
363
- return True
364
- except Exception as e:
365
- print(f"❌ Comic generation failed: {e}")
366
- traceback.print_exc()
367
- return False
368
-
369
- def _enhance_all_images(self, single_image_path=None):
370
- """Enhances colors for a batch of images."""
371
- target_dir = self.frames_dir
372
- if single_image_path:
373
- target_dir = os.path.dirname(single_image_path)
374
- if not os.path.exists(target_dir): return
375
- try:
376
- enhancer = SimpleColorEnhancer()
377
- enhancer.enhance_batch(target_dir)
378
- except Exception as e:
379
- print(f"❌ Simple enhancement failed during execution: {e}")
380
-
381
- def _enhance_quality_colors(self, single_image_path=None):
382
- """Enhances quality and colors for a batch of images."""
383
- target_dir = self.frames_dir
384
- if single_image_path:
385
- target_dir = os.path.dirname(single_image_path)
386
- try:
387
- enhancer = QualityColorEnhancer()
388
- enhancer.batch_enhance(target_dir)
389
- except Exception as e:
390
- print(f"⚠️ Quality enhancement failed during execution: {e}")
391
-
392
- def _create_ai_bubbles_from_moments(self, black_x, black_y):
393
- """Create bubbles using the key moments dialogues"""
394
- bubbles = []
395
- frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
396
-
397
- metadata_path = 'frames/frame_metadata.json'
398
- if not os.path.exists(metadata_path):
399
- print("⚠️ Frame metadata not found, creating empty bubbles.")
400
- return [bubble(dialog="") for _ in frame_files]
401
-
402
- with open(metadata_path, 'r') as f:
403
- frame_metadata = json.load(f)
404
-
405
- for frame_file in frame_files:
406
- frame_path = os.path.join(self.frames_dir, frame_file)
407
- dialogue = ""
408
-
409
- if frame_file in frame_metadata:
410
- dialogue = frame_metadata[frame_file]['dialogue']
411
-
412
- try:
413
- lip_x, lip_y = -1, -1
414
- faces = face_detector.detect_faces(frame_path)
415
- if faces:
416
- lip_x, lip_y = face_detector.get_lip_position(frame_path, faces[0])
417
-
418
- bubble_x, bubble_y = ai_bubble_placer.place_bubble_ai(frame_path, (lip_x, lip_y))
419
- bubbles.append(bubble(
420
- bubble_offset_x=bubble_x, bubble_offset_y=bubble_y,
421
- lip_x=lip_x, lip_y=lip_y, dialog=dialogue, emotion='normal'
422
- ))
423
- except Exception as e:
424
- print(f"-> Could not place bubble for {frame_file} due to error: {e}. Using default.")
425
- bubbles.append(bubble(
426
- bubble_offset_x=50, bubble_offset_y=20,
427
- lip_x=-1, lip_y=-1, dialog=dialogue, emotion='normal'
428
- ))
429
- return bubbles
430
-
431
- def _generate_pages(self, bubbles):
432
- try:
433
- from backend.fixed_12_pages_800x1080 import generate_12_pages_800x1080
434
- frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
435
- return generate_12_pages_800x1080(frame_files, bubbles)
436
- except ImportError:
437
- pages = []
438
- frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
439
- frames_per_page = 4
440
- num_pages = (len(frame_files) + frames_per_page - 1) // frames_per_page
441
- frame_counter = 0
442
- for i in range(num_pages):
443
- page_panels, page_bubbles = [], []
444
- for _ in range(frames_per_page):
445
- if frame_counter < len(frame_files):
446
- page_panels.append(panel(
447
- image=frame_files[frame_counter], row_span=6, col_span=6
448
- ))
449
- page_bubbles.append(bubbles[frame_counter] if frame_counter < len(bubbles) else bubble(dialog=""))
450
- frame_counter += 1
451
- if page_panels:
452
- pages.append(Page(panels=page_panels, bubbles=page_bubbles))
453
- return pages
454
-
455
- def _save_results(self, pages):
456
- try:
457
- os.makedirs(self.output_dir, exist_ok=True)
458
- pages_data = []
459
- for page in pages:
460
- page_dict = {
461
- 'panels': [p if isinstance(p, dict) else p.__dict__ for p in page.panels],
462
- 'bubbles': [b if isinstance(b, dict) else b.__dict__ for b in page.bubbles]
463
- }
464
- pages_data.append(page_dict)
465
- with open(os.path.join(self.output_dir, 'pages.json'), 'w', encoding='utf-8') as f:
466
- json.dump(pages_data, f, indent=2)
467
- self._copy_template_files()
468
- print("✅ Results saved successfully!")
469
- except Exception as e:
470
- print(f"Save results failed: {e}")
471
- traceback.print_exc()
472
-
473
- def _copy_template_files(self):
474
  """This function now includes the working 'Replace Image', 'Flip Bubble', and Panel Gaps features."""
475
  try:
476
  template_html = '''<!DOCTYPE html>
@@ -483,22 +11,49 @@ class EnhancedComicGenerator:
483
  <style>
484
  body { margin: 0; padding: 20px; background: #f0f0f0; font-family: Arial, sans-serif; }
485
  .comic-container { max-width: 1200px; margin: 0 auto; }
486
- .comic-page { background: white; width: 600px; height: 400px; box-shadow: 0 0 10px rgba(0,0,0,0.1); box-sizing: content-box; position: relative; overflow: hidden; border: 1px solid #333; padding: 10px; }
487
- .comic-grid { display: grid; grid-template-columns: 285px 285px; grid-template-rows: 185px 185px; gap: 10px; width: 100%; height: 100%; }
 
 
 
 
 
 
 
 
 
 
 
488
  .page-wrapper { margin: 30px auto; width: 622px; display: flex; flex-direction: column; align-items: center; }
489
  .page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
490
- .panel { position: relative; overflow: hidden; width: 100%; height: 100%; box-sizing: border-box; cursor: pointer; border: 1px solid #333; }
 
 
 
491
  .panel.selected { outline: 3px solid #2196F3; outline-offset: -3px; }
492
  .panel img { width: 100%; height: 100%; object-fit: cover; object-position: center; }
493
- .speech-bubble { position: absolute; display: flex; justify-content: center; align-items: center; width: auto; height: auto; min-width: 50px; max-width: 220px; min-height: 30px; box-sizing: border-box; padding: 8px; box-shadow: 2px 2px 5px rgba(0,0,0,0.3); z-index: 10; cursor: move; overflow: visible; font-size: 13px; font-weight: bold; text-align: center; }
 
 
 
 
 
 
 
494
  .bubble-text { padding: 2px; word-wrap: break-word; }
495
  .speech-bubble.selected { outline: 2px dashed #4CAF50; }
496
- .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; }
 
 
 
 
 
497
  .speech-bubble.speech { background: white; border: 2px solid #333; color: #333; border-radius: 15px; }
498
  .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
499
  .speech-bubble.reaction { background: #FFD700; border: 3px solid #E53935; color: #D32F2F; font-weight: 900; text-transform: uppercase; width: 180px; clip-path: polygon(0% 25%, 17% 21%, 17% 0%, 31% 16%, 50% 4%, 69% 16%, 83% 0%, 83% 21%, 100% 25%, 85% 45%, 95% 62%, 82% 79%, 100% 97%, 79% 89%, 60% 98%, 46% 82%, 27% 95%, 15% 78%, 5% 62%, 15% 45%); }
500
  .speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; }
501
  .speech-bubble.idea { background: linear-gradient(180deg,#FFFDD0 0%, #FFF8B5 100%); border: 2px solid #FFA500; color: #6a4b00; border-radius: 40% 60% 40% 60% / 60% 40% 60% 40%; }
 
502
  .speech-bubble.speech::after, .speech-bubble.idea::after { content: ''; position: absolute; width: 0; height: 0; border-left: 10px solid transparent; border-right: 10px solid transparent; }
503
  .speech-bubble.speech::after { border-top: 10px solid #333; bottom: -9px; left: 20px; }
504
  .speech-bubble.idea::after { border-top: 10px solid #FFA500; bottom: -9px; left: 20px; }
@@ -506,13 +61,19 @@ body { margin: 0; padding: 20px; background: #f0f0f0; font-family: Arial, sans-s
506
  .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
507
  .thought-dot-1 { width: 20px; height: 20px; bottom: -20px; left: 15px; }
508
  .thought-dot-2 { width: 12px; height: 12px; bottom: -32px; left: 5px; }
 
509
  .speech-bubble.flipped.speech::after, .speech-bubble.flipped.idea::after { left: auto; right: 20px; }
510
  .speech-bubble.flipped.thought .thought-dot-1 { left: auto; right: 15px; }
511
  .speech-bubble.flipped.thought .thought-dot-2 { left: auto; right: 5px; }
 
512
  .speech-bubble.flipped-vertical.speech::after, .speech-bubble.flipped-vertical.idea::after { bottom: auto; top: -9px; transform: rotate(180deg); }
513
  .speech-bubble.flipped-vertical.thought .thought-dot-1 { bottom: auto; top: -20px; }
514
  .speech-bubble.flipped-vertical.thought .thought-dot-2 { bottom: auto; top: -32px; }
515
- .edit-controls { position: fixed; bottom: 20px; right: 20px; background: rgba(44, 62, 80, 0.9); color: white; padding: 10px 15px; border-radius: 8px; font-size: 13px; z-index: 1000; box-shadow: 0 4px 12px rgba(0,0,0,0.3); width: 220px; }
 
 
 
 
516
  .edit-controls h4 { margin: 0 0 10px 0; color: #26a69a; text-align: center; }
517
  .edit-controls button, .edit-controls select { margin-top: 5px; padding: 6px 8px; font-size: 12px; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; width: 100%; box-sizing: border-box; }
518
  .edit-controls .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
@@ -532,7 +93,11 @@ body { margin: 0; padding: 20px; background: #f0f0f0; font-family: Arial, sans-s
532
  <div class="control-group">
533
  <label for="bubble-type-select">Change Selected Bubble Type:</label>
534
  <select id="bubble-type-select" onchange="changeBubbleType(this.value)">
535
- <option value="speech">Speech</option><option value="thought">Thought</option><option value="reaction">Reaction</option><option value="narration">Narration</option><option value="idea">Idea</option>
 
 
 
 
536
  </select>
537
  <button onclick="rotateBubbleTail()" class="secondary-button">🔄 Rotate Tail</button>
538
  </div>
@@ -552,8 +117,279 @@ body { margin: 0; padding: 20px; background: #f0f0f0; font-family: Arial, sans-s
552
  .then(data => { renderComic(data); initializeEditor(); })
553
  .catch(err => { document.getElementById('comic-pages').innerHTML = `<div class="loading">Error: ${err.message}</div>`; });
554
  });
555
- function renderComic(data) { /* (Full JS code here) */ }
556
- // ... all your other Javascript functions ...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
  </script>
558
  </body>
559
  </html>'''
@@ -561,94 +397,4 @@ body { margin: 0; padding: 20px; background: #f0f0f0; font-family: Arial, sans-s
561
  f.write(template_html)
562
  print("📄 Template files copied successfully!")
563
  except Exception as e:
564
- print(f"Template copy failed: {e}")
565
-
566
- # --- Flask Routes ---
567
- comic_generator = EnhancedComicGenerator()
568
-
569
- @app.route('/')
570
- def index():
571
- # This assumes you have a templates/index.html file.
572
- # If not, you might want to return something else.
573
- return render_template('index.html')
574
-
575
- @app.route('/uploader', methods=['POST'])
576
- def upload_file():
577
- try:
578
- if 'file' not in request.files or request.files['file'].filename == '':
579
- return "❌ No file selected"
580
- f = request.files['file']
581
- if os.path.exists(comic_generator.video_path):
582
- os.remove(comic_generator.video_path)
583
- f.save(comic_generator.video_path)
584
- success = comic_generator.generate_comic()
585
- if success:
586
- return "🎉 Enhanced Comic Created Successfully! View it at the /comic endpoint."
587
- else:
588
- return "❌ Comic generation failed. Check the Space logs for details."
589
- except Exception as e:
590
- traceback.print_exc()
591
- return f"❌ An unexpected error occurred: {str(e)}"
592
-
593
- @app.route('/handle_link', methods=['POST'])
594
- def handle_link():
595
- try:
596
- link = request.form.get('link', '')
597
- if not link: return "❌ No link provided"
598
- import yt_dlp
599
- ydl_opts = {'outtmpl': comic_generator.video_path, 'format': 'best[height<=720]'}
600
- with yt_dlp.YoutubeDL(ydl_opts) as ydl:
601
- ydl.download([link])
602
- success = comic_generator.generate_comic()
603
- if success:
604
- return "🎉 Enhanced Comic Created Successfully! View it at the /comic endpoint."
605
- else:
606
- return "❌ Comic generation failed. Check the Space logs for details."
607
- except Exception as e:
608
- traceback.print_exc()
609
- return f"❌ An unexpected error occurred: {str(e)}"
610
-
611
- @app.route('/replace_panel', methods=['POST'])
612
- def replace_panel():
613
- try:
614
- if 'image' not in request.files: return jsonify({'success': False, 'error': 'No image file provided.'})
615
- file = request.files['image']
616
- timestamp = int(time.time() * 1000)
617
- filename = f"replaced_panel_{timestamp}.png"
618
- save_path = os.path.join(comic_generator.frames_dir, filename)
619
- file.save(save_path)
620
- print(f"✅ Replaced panel with '{filename}' without applying color enhancement.")
621
- return jsonify({'success': True, 'new_filename': filename})
622
- except Exception as e:
623
- traceback.print_exc()
624
- return jsonify({'success': False, 'error': str(e)})
625
-
626
- @app.route('/regenerate_frame', methods=['POST'])
627
- def regenerate_frame_route():
628
- try:
629
- data = request.get_json()
630
- filename = data.get('filename')
631
- if not filename: return jsonify({'success': False, 'message': 'No filename provided'})
632
- result = comic_generator.regenerate_frame(filename)
633
- return jsonify(result)
634
- except Exception as e:
635
- traceback.print_exc()
636
- return jsonify({'success': False, 'message': str(e)})
637
-
638
- @app.route('/comic')
639
- def view_comic():
640
- return send_from_directory('output', 'page.html')
641
-
642
- @app.route('/output/<path:filename>')
643
- def output_file(filename):
644
- return send_from_directory('output', filename)
645
-
646
- @app.route('/frames/final/<path:filename>')
647
- def frame_file(filename):
648
- return send_from_directory('frames/final', filename)
649
-
650
- if __name__ == '__main__':
651
- port = int(os.getenv("PORT", 7860))
652
- print(f"🚀 Starting Enhanced Comic Generator on host 0.0.0.0, port {port}")
653
- # This is the line that had the syntax error. It is now clean.
654
- app.run(debug=False, host='0.0.0.0', port=port)
 
1
+ def _copy_template_files(self):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  """This function now includes the working 'Replace Image', 'Flip Bubble', and Panel Gaps features."""
3
  try:
4
  template_html = '''<!DOCTYPE html>
 
11
  <style>
12
  body { margin: 0; padding: 20px; background: #f0f0f0; font-family: Arial, sans-serif; }
13
  .comic-container { max-width: 1200px; margin: 0 auto; }
14
+ .comic-page {
15
+ background: white; width: 600px; height: 400px;
16
+ box-shadow: 0 0 10px rgba(0,0,0,0.1); box-sizing: content-box;
17
+ position: relative; overflow: hidden; border: 1px solid #333;
18
+ padding: 10px;
19
+ }
20
+ .comic-grid {
21
+ display: grid;
22
+ grid-template-columns: 285px 285px;
23
+ grid-template-rows: 185px 185px;
24
+ gap: 10px;
25
+ width: 100%; height: 100%;
26
+ }
27
  .page-wrapper { margin: 30px auto; width: 622px; display: flex; flex-direction: column; align-items: center; }
28
  .page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
29
+ .panel {
30
+ position: relative; overflow: hidden; width: 100%; height: 100%;
31
+ box-sizing: border-box; cursor: pointer; border: 1px solid #333;
32
+ }
33
  .panel.selected { outline: 3px solid #2196F3; outline-offset: -3px; }
34
  .panel img { width: 100%; height: 100%; object-fit: cover; object-position: center; }
35
+ .speech-bubble {
36
+ position: absolute; display: flex; justify-content: center; align-items: center;
37
+ width: auto; height: auto;
38
+ min-width: 50px; max-width: 220px; min-height: 30px;
39
+ box-sizing: border-box; padding: 8px;
40
+ box-shadow: 2px 2px 5px rgba(0,0,0,0.3); z-index: 10;
41
+ cursor: move; overflow: visible; font-size: 13px; font-weight: bold; text-align: center;
42
+ }
43
  .bubble-text { padding: 2px; word-wrap: break-word; }
44
  .speech-bubble.selected { outline: 2px dashed #4CAF50; }
45
+ .speech-bubble textarea {
46
+ position: absolute; top: 0; left: 0; width: 100%; height: 100%; box-sizing: border-box;
47
+ border: 1px solid #4CAF50; background: rgba(255,255,255,0.95);
48
+ font: inherit; text-align: center; resize: none; padding: 8px; z-index: 102;
49
+ }
50
+ /* --- Bubble Styles --- */
51
  .speech-bubble.speech { background: white; border: 2px solid #333; color: #333; border-radius: 15px; }
52
  .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
53
  .speech-bubble.reaction { background: #FFD700; border: 3px solid #E53935; color: #D32F2F; font-weight: 900; text-transform: uppercase; width: 180px; clip-path: polygon(0% 25%, 17% 21%, 17% 0%, 31% 16%, 50% 4%, 69% 16%, 83% 0%, 83% 21%, 100% 25%, 85% 45%, 95% 62%, 82% 79%, 100% 97%, 79% 89%, 60% 98%, 46% 82%, 27% 95%, 15% 78%, 5% 62%, 15% 45%); }
54
  .speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; }
55
  .speech-bubble.idea { background: linear-gradient(180deg,#FFFDD0 0%, #FFF8B5 100%); border: 2px solid #FFA500; color: #6a4b00; border-radius: 40% 60% 40% 60% / 60% 40% 60% 40%; }
56
+ /* --- Tail and Dot Styles (4-Direction Flip) --- */
57
  .speech-bubble.speech::after, .speech-bubble.idea::after { content: ''; position: absolute; width: 0; height: 0; border-left: 10px solid transparent; border-right: 10px solid transparent; }
58
  .speech-bubble.speech::after { border-top: 10px solid #333; bottom: -9px; left: 20px; }
59
  .speech-bubble.idea::after { border-top: 10px solid #FFA500; bottom: -9px; left: 20px; }
 
61
  .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
62
  .thought-dot-1 { width: 20px; height: 20px; bottom: -20px; left: 15px; }
63
  .thought-dot-2 { width: 12px; height: 12px; bottom: -32px; left: 5px; }
64
+ /* Horizontal Flip */
65
  .speech-bubble.flipped.speech::after, .speech-bubble.flipped.idea::after { left: auto; right: 20px; }
66
  .speech-bubble.flipped.thought .thought-dot-1 { left: auto; right: 15px; }
67
  .speech-bubble.flipped.thought .thought-dot-2 { left: auto; right: 5px; }
68
+ /* Vertical Flip */
69
  .speech-bubble.flipped-vertical.speech::after, .speech-bubble.flipped-vertical.idea::after { bottom: auto; top: -9px; transform: rotate(180deg); }
70
  .speech-bubble.flipped-vertical.thought .thought-dot-1 { bottom: auto; top: -20px; }
71
  .speech-bubble.flipped-vertical.thought .thought-dot-2 { bottom: auto; top: -32px; }
72
+ .edit-controls {
73
+ position: fixed; bottom: 20px; right: 20px; background: rgba(44, 62, 80, 0.9);
74
+ color: white; padding: 10px 15px; border-radius: 8px; font-size: 13px;
75
+ z-index: 1000; box-shadow: 0 4px 12px rgba(0,0,0,0.3); width: 220px;
76
+ }
77
  .edit-controls h4 { margin: 0 0 10px 0; color: #26a69a; text-align: center; }
78
  .edit-controls button, .edit-controls select { margin-top: 5px; padding: 6px 8px; font-size: 12px; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; width: 100%; box-sizing: border-box; }
79
  .edit-controls .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
 
93
  <div class="control-group">
94
  <label for="bubble-type-select">Change Selected Bubble Type:</label>
95
  <select id="bubble-type-select" onchange="changeBubbleType(this.value)">
96
+ <option value="speech">Speech</option>
97
+ <option value="thought">Thought</option>
98
+ <option value="reaction">Reaction</option>
99
+ <option value="narration">Narration</option>
100
+ <option value="idea">Idea</option>
101
  </select>
102
  <button onclick="rotateBubbleTail()" class="secondary-button">🔄 Rotate Tail</button>
103
  </div>
 
117
  .then(data => { renderComic(data); initializeEditor(); })
118
  .catch(err => { document.getElementById('comic-pages').innerHTML = `<div class="loading">Error: ${err.message}</div>`; });
119
  });
120
+
121
+ function renderComic(data) {
122
+ const container = document.getElementById('comic-pages');
123
+ container.innerHTML = '';
124
+ if (!data || data.length === 0) return;
125
+ data.forEach((pageData, pageIndex) => {
126
+ if (!pageData.panels || pageData.panels.length === 0) return;
127
+ const pageWrapper = document.createElement('div');
128
+ pageWrapper.className = 'page-wrapper';
129
+ const pageTitleEl = document.createElement('h2');
130
+ pageTitleEl.className = 'page-title';
131
+ pageTitleEl.textContent = `Page ${pageIndex + 1}`;
132
+ pageWrapper.appendChild(pageTitleEl);
133
+ const pageDiv = document.createElement('div');
134
+ pageDiv.className = 'comic-page';
135
+ const grid = document.createElement('div');
136
+ grid.className = 'comic-grid';
137
+ pageData.panels.forEach((panelData, panelIndex) => {
138
+ const panelDiv = document.createElement('div');
139
+ panelDiv.className = 'panel';
140
+ const img = document.createElement('img');
141
+ img.src = '/frames/final/' + panelData.image;
142
+ panelDiv.appendChild(img);
143
+ if (pageData.bubbles && pageData.bubbles[panelIndex]) {
144
+ const bubbleData = pageData.bubbles[panelIndex];
145
+ const bubbleDiv = createBubbleElement({
146
+ id: `initial-${pageIndex}-${panelIndex}`,
147
+ text: bubbleData.dialog || '',
148
+ left: `${bubbleData.bubble_offset_x ?? 50}px`,
149
+ top: `${bubbleData.bubble_offset_y ?? 20}px`,
150
+ });
151
+ panelDiv.appendChild(bubbleDiv);
152
+ }
153
+ grid.appendChild(panelDiv);
154
+ });
155
+ pageDiv.appendChild(grid);
156
+ pageWrapper.appendChild(pageDiv);
157
+ container.appendChild(pageWrapper);
158
+ });
159
+ }
160
+
161
+ let currentlyEditing = null, draggedBubble = null, offset = {x: 0, y: 0};
162
+ let currentlySelectedBubble = null;
163
+ let currentlySelectedPanel = null;
164
+
165
+ function initializeEditor() {
166
+ document.querySelectorAll('.panel').forEach(p => p.addEventListener('click', e => selectPanel(e.currentTarget)));
167
+ document.querySelectorAll('.speech-bubble').forEach(b => initializeBubbleEvents(b));
168
+ document.addEventListener('mousemove', e => { if (draggedBubble) drag(e); });
169
+ document.addEventListener('mouseup', () => { if (draggedBubble) stopDrag(); });
170
+ }
171
+
172
+ function initializeBubbleEvents(bubble) {
173
+ bubble.addEventListener('dblclick', e => { e.stopPropagation(); editBubbleText(bubble); });
174
+ bubble.addEventListener('mousedown', e => startDrag(e));
175
+ bubble.addEventListener('click', e => { e.stopPropagation(); selectBubble(bubble); });
176
+ bubble.addEventListener('wheel', e => {
177
+ e.preventDefault();
178
+ const currentWidth = parseFloat(bubble.style.width) || bubble.offsetWidth;
179
+ const newWidth = currentWidth - (e.deltaY > 0 ? 10 : -10);
180
+ if (newWidth >= 60) {
181
+ bubble.style.width = `${newWidth}px`;
182
+ bubble.style.height = 'auto';
183
+ }
184
+ }, { passive: false });
185
+ }
186
+
187
+ function createBubbleElement(data) {
188
+ const bubbleDiv = document.createElement('div');
189
+ bubbleDiv.dataset.id = data.id;
190
+ const textSpan = document.createElement('span');
191
+ textSpan.className = 'bubble-text';
192
+ textSpan.textContent = data.text;
193
+ bubbleDiv.appendChild(textSpan);
194
+ bubbleDiv.style.left = data.left;
195
+ bubbleDiv.style.top = data.top;
196
+ applyBubbleType(bubbleDiv, 'speech'); // Default to speech
197
+ return bubbleDiv;
198
+ }
199
+
200
+ function applyBubbleType(bubble, type) {
201
+ bubble.querySelectorAll('.thought-dot').forEach(el => el.remove());
202
+ let classesToKeep = 'speech-bubble';
203
+ if (bubble.classList.contains('selected')) classesToKeep += ' selected';
204
+ if (bubble.classList.contains('flipped')) classesToKeep += ' flipped';
205
+ if (bubble.classList.contains('flipped-vertical')) classesToKeep += ' flipped-vertical';
206
+ bubble.className = classesToKeep;
207
+ bubble.classList.add(type);
208
+ bubble.dataset.type = type;
209
+ if (type === 'thought') {
210
+ for (let i = 1; i <= 2; i++) {
211
+ const dot = document.createElement('div');
212
+ dot.className = `thought-dot thought-dot-${i}`;
213
+ bubble.appendChild(dot);
214
+ }
215
+ }
216
+ }
217
+
218
+ function changeBubbleType(type) {
219
+ if (!currentlySelectedBubble) return;
220
+ applyBubbleType(currentlySelectedBubble, type);
221
+ }
222
+
223
+ function rotateBubbleTail() {
224
+ if (!currentlySelectedBubble) return alert("Please select a bubble to rotate.");
225
+ const isFlippedH = currentlySelectedBubble.classList.contains('flipped');
226
+ const isFlippedV = currentlySelectedBubble.classList.contains('flipped-vertical');
227
+ if (!isFlippedH && !isFlippedV) { // State 0 -> 1
228
+ currentlySelectedBubble.classList.add('flipped');
229
+ } else if (isFlippedH && !isFlippedV) { // State 1 -> 2
230
+ currentlySelectedBubble.classList.add('flipped-vertical');
231
+ } else if (isFlippedH && isFlippedV) { // State 2 -> 3
232
+ currentlySelectedBubble.classList.remove('flipped');
233
+ } else { // State 3 -> 0
234
+ currentlySelectedBubble.classList.remove('flipped-vertical');
235
+ }
236
+ }
237
+
238
+ function selectPanel(panel) {
239
+ document.querySelectorAll('.panel.selected').forEach(p => p.classList.remove('selected'));
240
+ panel.classList.add('selected');
241
+ currentlySelectedPanel = panel;
242
+ selectBubble(null);
243
+ }
244
+
245
+ function selectBubble(bubble) {
246
+ if (currentlySelectedBubble) currentlySelectedBubble.classList.remove('selected');
247
+ currentlySelectedBubble = bubble;
248
+ if (currentlySelectedBubble) {
249
+ currentlySelectedBubble.classList.add('selected');
250
+ document.querySelectorAll('.panel.selected').forEach(p => p.classList.remove('selected'));
251
+ document.getElementById('bubble-type-select').value = currentlySelectedBubble.dataset.type || 'speech';
252
+ }
253
+ }
254
+
255
+ function editBubbleText(bubble) {
256
+ if (currentlyEditing) return;
257
+ currentlyEditing = bubble;
258
+ const textSpan = bubble.querySelector('.bubble-text');
259
+ const currentText = textSpan.textContent;
260
+ textSpan.style.display = 'none';
261
+ bubble.style.height = 'auto';
262
+ const textarea = document.createElement('textarea');
263
+ textarea.value = currentText;
264
+ bubble.appendChild(textarea);
265
+ textarea.focus();
266
+ const finishEditing = () => {
267
+ textSpan.textContent = textarea.value;
268
+ bubble.removeChild(textarea);
269
+ textSpan.style.display = '';
270
+ currentlyEditing = null;
271
+ bubble.style.height = 'auto';
272
+ };
273
+ textarea.addEventListener('blur', finishEditing, { once: true });
274
+ textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); }});
275
+ }
276
+
277
+ function startDrag(e) {
278
+ const bubble = e.target.closest('.speech-bubble');
279
+ if (!bubble || currentlyEditing) return;
280
+ draggedBubble = bubble;
281
+ selectBubble(bubble);
282
+ const rect = bubble.getBoundingClientRect();
283
+ offset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
284
+ }
285
+
286
+ function drag(e) {
287
+ const parentRect = draggedBubble.parentElement.getBoundingClientRect();
288
+ let x = e.clientX - parentRect.left - offset.x;
289
+ let y = e.clientY - parentRect.top - offset.y;
290
+ draggedBubble.style.left = `${x}px`;
291
+ draggedBubble.style.top = `${y}px`;
292
+ }
293
+
294
+ function stopDrag() {
295
+ draggedBubble = null;
296
+ }
297
+
298
+ function clearSavedState() {
299
+ if (confirm("Reset all edits to the original AI-generated comic?")) {
300
+ localStorage.removeItem('comicEditorState');
301
+ window.location.reload();
302
+ }
303
+ }
304
+
305
+ async function exportPagesToPNG() {
306
+ const pages = document.querySelectorAll('.comic-page');
307
+ if (pages.length === 0) return alert("No pages found.");
308
+ alert(`Starting export of ${pages.length} page(s).`);
309
+ for (let i = 0; i < pages.length; i++) {
310
+ try {
311
+ const canvas = await html2canvas(pages[i], { scale: 2 });
312
+ const link = document.createElement('a');
313
+ link.download = `comic-page-${i + 1}.png`;
314
+ link.href = canvas.toDataURL('image/png');
315
+ link.click();
316
+ } catch (err) {
317
+ alert(`Failed to export page ${i + 1}.`);
318
+ }
319
+ }
320
+ }
321
+
322
+ function replacePanelImage() {
323
+ if (!currentlySelectedPanel) {
324
+ alert("Please select a panel first.");
325
+ return;
326
+ }
327
+ const img = currentlySelectedPanel.querySelector('img');
328
+ const uploader = document.getElementById('image-uploader');
329
+ const oneTimeListener = (event) => {
330
+ const file = event.target.files[0];
331
+ if (!file) return;
332
+ const formData = new FormData();
333
+ formData.append('image', file);
334
+ img.style.opacity = '0.5';
335
+ fetch('/replace_panel', { method: 'POST', body: formData })
336
+ .then(response => response.json())
337
+ .then(data => {
338
+ if (data.success) {
339
+ img.src = `/frames/final/${data.new_filename}?t=${new Date().getTime()}`;
340
+ } else {
341
+ alert('Error replacing image: ' + data.error);
342
+ }
343
+ img.style.opacity = '1';
344
+ })
345
+ .catch(error => {
346
+ alert('An error occurred during the upload.');
347
+ img.style.opacity = '1';
348
+ });
349
+ uploader.removeEventListener('change', oneTimeListener);
350
+ uploader.value = '';
351
+ };
352
+ uploader.addEventListener('change', oneTimeListener, { once: true });
353
+ uploader.click();
354
+ }
355
+
356
+ function regenerateFrame() {
357
+ if (!currentlySelectedPanel) {
358
+ alert("Please select a panel first.");
359
+ return;
360
+ }
361
+ const img = currentlySelectedPanel.querySelector('img');
362
+ const currentSrc = img.src;
363
+
364
+ let filename = currentSrc.substring(currentSrc.lastIndexOf('/') + 1);
365
+ if (filename.includes('?')) {
366
+ filename = filename.split('?')[0];
367
+ }
368
+
369
+ if (!confirm(`Regenerate frame "${filename}" with a better version?`)) {
370
+ return;
371
+ }
372
+ img.style.opacity = '0.5';
373
+ fetch('/regenerate_frame', {
374
+ method: 'POST',
375
+ headers: { 'Content-Type': 'application/json' },
376
+ body: JSON.stringify({ filename: filename })
377
+ })
378
+ .then(response => response.json())
379
+ .then(data => {
380
+ if (data.success) {
381
+ img.src = `/frames/final/${filename}?t=${new Date().getTime()}`;
382
+ alert(data.message);
383
+ } else {
384
+ alert('Error: ' + data.message);
385
+ }
386
+ img.style.opacity = '1';
387
+ })
388
+ .catch(error => {
389
+ alert('An error occurred during regeneration.');
390
+ img.style.opacity = '1';
391
+ });
392
+ }
393
  </script>
394
  </body>
395
  </html>'''
 
397
  f.write(template_html)
398
  print("📄 Template files copied successfully!")
399
  except Exception as e:
400
+ print(f"Template copy failed: {e}")