jhh6576 commited on
Commit
9ce96e0
·
verified ·
1 Parent(s): 73d799a

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +745 -796
app_enhanced.py CHANGED
@@ -65,12 +65,208 @@ except Exception as e:
65
 
66
  app = Flask(__name__)
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  os.makedirs('video', exist_ok=True)
69
  os.makedirs('frames/final', exist_ok=True)
70
  os.makedirs('output', exist_ok=True)
71
 
 
 
 
 
 
72
  class EnhancedComicGenerator:
73
- """High-quality comic generation with AI enhancement"""
74
  def __init__(self):
75
  self.video_path = 'video/uploaded.mp4'
76
  self.frames_dir = 'frames/final'
@@ -79,8 +275,7 @@ class EnhancedComicGenerator:
79
  self.video_fps = None
80
 
81
  def cleanup_generated(self):
82
- """Deletes all old files to ensure a fresh start."""
83
- print("🧹 Performing full cleanup of previous run...")
84
  if os.path.isdir(self.frames_dir): shutil.rmtree(self.frames_dir)
85
  if os.path.isdir(self.output_dir): shutil.rmtree(self.output_dir)
86
  if os.path.isdir('temp'): shutil.rmtree('temp')
@@ -89,382 +284,191 @@ class EnhancedComicGenerator:
89
  os.makedirs(self.output_dir, exist_ok=True)
90
  print("✅ Cleanup complete.")
91
 
92
- def detect_eye_state(self, frame_path):
93
- """
94
- Detect if eyes are closed or semi-closed in a frame
95
- Returns: 'open', 'semi-closed', or 'closed'
96
- """
97
- try:
98
- img = cv2.imread(frame_path)
99
- gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
100
- face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
101
- eye_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_eye.xml')
102
- faces = face_cascade.detectMultiScale(gray, 1.3, 5)
103
- for (x, y, w, h) in faces:
104
- roi_gray = gray[y:y+h, x:x+w]
105
- eyes = eye_cascade.detectMultiScale(roi_gray)
106
- if len(eyes) == 0:
107
- return 'closed'
108
- elif len(eyes) == 1:
109
- return 'semi-closed'
110
- for (ex, ey, ew, eh) in eyes:
111
- eye_region = roi_gray[ey:ey+eh, ex:ex+ew]
112
- vert_var = np.var(eye_region, axis=0).mean()
113
- if vert_var < 500:
114
- return 'semi-closed'
115
- return 'open'
116
- except:
117
- return 'open'
118
-
119
  def regenerate_frame(self, frame_filename, direction):
120
- """
121
- Regenerate a frame by moving one frame forward or backward in the video.
122
- """
123
  try:
124
  if not self.video_fps:
125
- return {"success": False, "message": "Video FPS not found. Please regenerate the comic first."}
126
-
127
  metadata_path = 'frames/frame_metadata.json'
128
  if not os.path.exists(metadata_path):
129
  return {"success": False, "message": "Frame metadata missing."}
130
-
131
  with open(metadata_path, 'r') as f:
132
  frame_to_time = json.load(f)
133
-
134
  if frame_filename not in frame_to_time:
135
- return {"success": False, "message": "Panel not linked to original video."}
136
-
137
- if isinstance(frame_to_time[frame_filename], dict):
138
- current_time = frame_to_time[frame_filename]['time']
139
- else:
140
- current_time = frame_to_time[frame_filename]
141
-
142
  frame_duration = 1.0 / self.video_fps
143
-
144
- if direction == 'forward':
145
- target_time = current_time + frame_duration
146
- elif direction == 'backward':
147
- target_time = current_time - frame_duration
148
- else:
149
- return {"success": False, "message": "Invalid direction specified."}
150
-
151
  target_time = max(0, target_time)
152
-
153
  cap = cv2.VideoCapture(self.video_path)
154
- if not cap.isOpened():
155
- return {"success": False, "message": "Cannot open video."}
156
-
157
  cap.set(cv2.CAP_PROP_POS_MSEC, target_time * 1000)
158
  ret, frame = cap.read()
159
  cap.release()
160
-
161
  if not ret or frame is None:
162
- return {"success": False, "message": f"No frame available at {target_time:.2f}s."}
163
-
164
  new_path = os.path.join(self.frames_dir, frame_filename)
165
  cv2.imwrite(new_path, frame)
166
-
167
  if isinstance(frame_to_time[frame_filename], dict):
168
  frame_to_time[frame_filename]['time'] = target_time
169
  else:
170
  frame_to_time[frame_filename] = target_time
171
-
172
- with open(metadata_path, 'w') as f:
173
- json.dump(frame_to_time, f, indent=2)
174
-
175
  message = f"Adjusted {direction} to {target_time:.3f}s"
176
  print(f"✅ {message}")
177
-
178
- return {
179
- "success": True,
180
- "message": message,
181
- "new_filename": frame_filename
182
- }
183
-
184
  except Exception as e:
185
  traceback.print_exc()
186
  return {"success": False, "message": str(e)}
187
 
188
  def get_frame_at_timestamp(self, frame_filename, timestamp_seconds):
189
- """
190
- Overwrites a specific frame file with the video frame from a precise timestamp.
191
- """
192
  try:
193
  metadata_path = 'frames/frame_metadata.json'
194
- if not os.path.exists(metadata_path):
195
- return {"success": False, "message": "Frame metadata missing."}
196
-
197
  cap = cv2.VideoCapture(self.video_path)
198
- if not cap.isOpened():
199
- return {"success": False, "message": "Cannot open video."}
200
-
201
  fps = cap.get(cv2.CAP_PROP_FPS)
202
  if fps == 0: fps = 25
203
- total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
204
- duration = total_frames / fps
205
  if timestamp_seconds < 0 or timestamp_seconds > duration:
206
  cap.release()
207
- return {"success": False, "message": f"Timestamp must be between 0 and {duration:.2f} seconds."}
208
-
209
  cap.set(cv2.CAP_PROP_POS_MSEC, timestamp_seconds * 1000)
210
  ret, frame = cap.read()
211
  cap.release()
212
-
213
- if not ret or frame is None:
214
- return {"success": False, "message": f"Could not retrieve frame at {timestamp_seconds:.2f}s."}
215
-
216
  new_path = os.path.join(self.frames_dir, frame_filename)
217
  cv2.imwrite(new_path, frame)
218
-
219
- with open(metadata_path, 'r') as f:
220
- frame_to_time = json.load(f)
221
-
222
  if frame_filename in frame_to_time:
223
  if isinstance(frame_to_time[frame_filename], dict):
224
  frame_to_time[frame_filename]['time'] = timestamp_seconds
225
  else:
226
  frame_to_time[frame_filename] = timestamp_seconds
227
-
228
- with open(metadata_path, 'w') as f:
229
- json.dump(frame_to_time, f, indent=2)
230
-
231
  message = f"Jumped to timestamp {timestamp_seconds:.3f}s"
232
  print(f"✅ {message}")
233
-
234
  return { "success": True, "message": message }
235
-
236
  except Exception as e:
237
  traceback.print_exc()
238
  return {"success": False, "message": str(e)}
239
 
240
  def generate_keyframes_from_moments(self, video_path, key_moments, max_frames=48):
241
- """
242
- Generate frames specifically at the key moments timestamps
243
- """
244
  try:
245
  cap = cv2.VideoCapture(video_path)
246
- if not cap.isOpened():
247
- print("❌ Cannot open video for keyframe extraction")
248
- return False
249
-
250
- fps = self.video_fps
251
- total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
252
  duration = total_frames / fps
253
-
254
  key_moments.sort(key=lambda x: x['start'])
255
-
256
- if len(key_moments) > max_frames:
257
- first_count = min(5, max_frames // 4)
258
- last_count = min(5, max_frames // 4)
259
- middle_count = max_frames - first_count - last_count
260
-
261
- if middle_count > 0 and len(key_moments) > (first_count + last_count):
262
- first_moments = key_moments[:first_count]
263
- last_moments = key_moments[-last_count:]
264
- middle_moments = key_moments[first_count:-last_count]
265
-
266
- if len(middle_moments) > middle_count:
267
- step = len(middle_moments) / middle_count
268
- middle_sampled = [middle_moments[int(i * step)] for i in range(middle_count)]
269
- else:
270
- middle_sampled = middle_moments
271
-
272
- key_moments = first_moments + middle_sampled + last_moments
273
- else:
274
- step = len(key_moments) / max_frames
275
- key_moments = [key_moments[int(i * step)] for i in range(max_frames)]
276
-
277
- frame_metadata = {}
278
- frame_count = 0
279
-
280
- for moment in key_moments:
281
  frame_time = (moment['start'] + moment['end']) / 2
282
-
283
- if frame_time > duration:
284
- continue
285
-
286
  frame_number = int(frame_time * fps)
287
-
288
  cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
289
  ret, frame = cap.read()
290
-
291
  if ret:
292
  frame_filename = f"frame_{frame_count:04d}.png"
293
  frame_path = os.path.join(self.frames_dir, frame_filename)
294
  cv2.imwrite(frame_path, frame)
295
-
296
- frame_metadata[frame_filename] = {
297
- 'time': frame_time,
298
- 'dialogue': moment['text'],
299
- 'start': moment['start'],
300
- 'end': moment['end']
301
- }
302
  frame_count += 1
303
- print(f"📸 Extracted frame at {frame_time:.2f}s: {moment['text'][:30]}...")
304
-
305
  cap.release()
306
-
307
  with open(os.path.join('frames', 'frame_metadata.json'), 'w') as f:
308
  json.dump(frame_metadata, f, indent=2)
309
-
310
  print(f"✅ Extracted {frame_count} keyframes from video")
311
  return True
312
-
313
  except Exception as e:
314
  print(f"❌ Error extracting keyframes: {e}")
315
- traceback.print_exc()
316
  return False
317
 
318
- def generate_comic(self, smart_mode=False, emotion_match=False):
319
- """Main comic generation pipeline"""
320
  start_time = time.time()
321
- self.cleanup_generated()
322
- print("🎬 Starting Enhanced Comic Generation...")
323
  try:
 
 
 
324
  cap = cv2.VideoCapture(self.video_path)
325
- if not cap.isOpened():
326
- print("❌ Cannot open video to get FPS.")
327
- return False
328
  self.video_fps = cap.get(cv2.CAP_PROP_FPS)
329
- if self.video_fps == 0:
330
- print("⚠️ Video FPS is 0, defaulting to 25.")
331
- self.video_fps = 25
332
  cap.release()
333
  print(f"✅ Video FPS detected: {self.video_fps:.2f}")
334
-
335
- print("📝 Generating subtitles...")
336
  get_real_subtitles(self.video_path)
337
- all_subs = []
338
- if os.path.exists('test1.srt'):
339
- with open('test1.srt', 'r', encoding='utf-8') as f:
340
- all_subs = list(srt.parse(f.read()))
341
- print(f"✅ Loaded {len(all_subs)} subtitles")
342
- else:
343
- print("❌ Subtitle file (test1.srt) not found!")
344
- return False
345
-
346
- filtered_subs = all_subs
347
-
348
- key_moments = [{'index': s.index, 'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in filtered_subs]
349
-
350
- with open(os.path.join(self.output_dir, 'key_moments.json'), 'w', encoding='utf-8') as f:
351
- json.dump(key_moments, f, indent=2)
352
-
353
- print("🎬 Extracting frames at key moments...")
354
  if not self.generate_keyframes_from_moments(self.video_path, key_moments, max_frames=48):
355
- print("Keyframe extraction failed.")
356
- return False
357
-
358
- print("✂️ Cropping black bars...")
359
  black_x, black_y, _, _ = black_bar_crop()
360
- print(" Black bars cropped.")
361
-
362
- print("🎨 Enhancing images...")
363
  self._enhance_all_images()
364
  self._enhance_quality_colors()
365
- print(" Images enhancement step complete.")
366
-
367
- print("💬 Creating AI bubbles with key moment dialogues...")
368
  bubbles = self._create_ai_bubbles_from_moments(black_x, black_y)
369
- print(f" Created {len(bubbles)} bubbles.")
370
-
371
- print("📋 Generating pages...")
372
  pages = self._generate_pages(bubbles)
373
- print(f" Generated {len(pages)} pages.")
374
-
375
- print("💾 Saving results...")
376
  self._save_results(pages)
377
- print("✅ Results saved.")
378
-
379
  execution_time = (time.time() - start_time) / 60
380
  print(f"✅ Comic generation completed in {execution_time:.2f} minutes")
 
381
  return True
382
  except Exception as e:
383
  print(f"❌ Comic generation failed: {e}")
384
  traceback.print_exc()
 
385
  return False
386
 
387
  def _enhance_all_images(self, single_image_path=None):
388
  target_dir = self.frames_dir
389
- if single_image_path:
390
- target_dir = os.path.dirname(single_image_path)
391
  if not os.path.exists(target_dir): return
392
  try:
393
- enhancer = SimpleColorEnhancer()
394
- enhancer.enhance_batch(target_dir)
395
- except Exception as e:
396
- print(f"❌ Simple enhancement failed during execution: {e}")
397
 
398
  def _enhance_quality_colors(self, single_image_path=None):
399
  target_dir = self.frames_dir
400
- if single_image_path:
401
- target_dir = os.path.dirname(single_image_path)
402
  try:
403
- enhancer = QualityColorEnhancer()
404
- enhancer.batch_enhance(target_dir)
405
- except Exception as e:
406
- print(f"⚠️ Quality enhancement failed during execution: {e}")
407
 
408
  def _create_ai_bubbles_from_moments(self, black_x, black_y):
409
- bubbles = []
410
- frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
411
-
412
  metadata_path = 'frames/frame_metadata.json'
413
- if not os.path.exists(metadata_path):
414
- print("⚠️ Frame metadata not found, creating empty bubbles.")
415
- return [bubble(dialog="") for _ in frame_files]
416
-
417
- with open(metadata_path, 'r') as f:
418
- frame_metadata = json.load(f)
419
-
420
- for frame_file in frame_files:
421
  frame_path = os.path.join(self.frames_dir, frame_file)
422
- dialogue = ""
423
-
424
- if frame_file in frame_metadata:
425
- dialogue = frame_metadata[frame_file]['dialogue']
426
-
427
  try:
428
- lip_x, lip_y = -1, -1
429
  faces = face_detector.detect_faces(frame_path)
430
- if faces:
431
- lip_x, lip_y = face_detector.get_lip_position(frame_path, faces[0])
432
-
433
  bubble_x, bubble_y = ai_bubble_placer.place_bubble_ai(frame_path, (lip_x, lip_y))
434
- bubbles.append(bubble(
435
- bubble_offset_x=bubble_x, bubble_offset_y=bubble_y,
436
- lip_x=lip_x, lip_y=lip_y, dialog=dialogue, emotion='normal'
437
- ))
438
  except Exception as e:
439
- print(f"-> Could not place bubble for {frame_file} due to error: {e}. Using default.")
440
- bubbles.append(bubble(
441
- bubble_offset_x=50, bubble_offset_y=20,
442
- lip_x=-1, lip_y=-1, dialog=dialogue, emotion='normal'
443
- ))
444
  return bubbles
445
 
446
  def _generate_pages(self, bubbles):
447
  try:
448
  from backend.fixed_12_pages_800x1080 import generate_12_pages_800x1080
449
- frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
450
- return generate_12_pages_800x1080(frame_files, bubbles)
451
  except ImportError:
452
- pages = []
453
- frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
454
- frames_per_page = 4
455
- num_pages = (len(frame_files) + frames_per_page - 1) // frames_per_page
456
- frame_counter = 0
457
  for i in range(num_pages):
458
- page_panels, page_bubbles = [], []
459
- for _ in range(frames_per_page):
460
- if frame_counter < len(frame_files):
461
- page_panels.append(panel(
462
- image=frame_files[frame_counter], row_span=6, col_span=6
463
- ))
464
- page_bubbles.append(bubbles[frame_counter] if frame_counter < len(bubbles) else bubble(dialog=""))
465
- frame_counter += 1
466
- if page_panels:
467
- pages.append(Page(panels=page_panels, bubbles=page_bubbles))
468
  return pages
469
 
470
  def _save_results(self, pages):
@@ -472,545 +476,516 @@ class EnhancedComicGenerator:
472
  os.makedirs(self.output_dir, exist_ok=True)
473
  pages_data = []
474
  for page in pages:
475
- page_dict = {
476
- 'panels': [p if isinstance(p, dict) else p.__dict__ for p in page.panels],
477
- 'bubbles': [b if isinstance(b, dict) else b.__dict__ for b in page.bubbles]
478
- }
479
- pages_data.append(page_dict)
480
  with open(os.path.join(self.output_dir, 'pages.json'), 'w', encoding='utf-8') as f:
481
  json.dump(pages_data, f, indent=2)
482
  self._copy_template_files()
483
  print("✅ Results saved successfully!")
484
  except Exception as e:
485
  print(f"Save results failed: {e}")
486
- traceback.print_exc()
487
 
488
  def _copy_template_files(self):
489
- """This function contains the complete HTML, CSS, and JavaScript for the interactive editor."""
490
  try:
491
  template_html = '''<!DOCTYPE html>
492
  <html lang="en">
493
  <head>
494
- <meta charset="UTF-8">
495
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
496
- <title>Generated Comic - Interactive Editor</title>
497
- <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
498
- <style>
499
- body { margin: 0; padding: 20px; background: #f0f0f0; font-family: Arial, sans-serif; }
500
- .comic-container { max-width: 1200px; margin: 0 auto; }
501
- .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; }
502
- .comic-grid { display: grid; grid-template-columns: 285px 285px; grid-template-rows: 185px 185px; gap: 10px; width: 100%; height: 100%; }
503
- .page-wrapper { margin: 30px auto; width: 622px; display: flex; flex-direction: column; align-items: center; }
504
- .page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
505
- .panel { position: relative; overflow: hidden; width: 100%; height: 100%; box-sizing: border-box; cursor: pointer; border: 1px solid #333; }
506
- .panel.selected { outline: 3px solid #2196F3; outline-offset: -3px; }
507
- .panel img { width: 100%; height: 100%; object-fit: cover; object-position: center; transition: transform 0.1s ease-out; }
508
- .panel img.pannable { cursor: grab; }
509
- .panel img.panning { cursor: grabbing; }
510
- .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; }
511
- .bubble-text { padding: 2px; word-wrap: break-word; }
512
- .speech-bubble.selected { outline: 2px dashed #4CAF50; }
513
- .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; }
514
- .speech-bubble.speech { background: white; border: 2px solid #333; color: #333; border-radius: 15px; }
515
- .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
516
- .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%); }
517
- .speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; }
518
- .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%; }
519
- .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; }
520
- .speech-bubble.speech::after { border-top: 10px solid #333; bottom: -9px; left: 20px; }
521
- .speech-bubble.idea::after { border-top: 10px solid #FFA500; bottom: -9px; left: 20px; }
522
- .speech-bubble.thought::after { display: none; }
523
- .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
524
- .thought-dot-1 { width: 20px; height: 20px; bottom: -20px; left: 15px; }
525
- .thought-dot-2 { width: 12px; height: 12px; bottom: -32px; left: 5px; }
526
- .speech-bubble.flipped.speech::after, .speech-bubble.flipped.idea::after { left: auto; right: 20px; }
527
- .speech-bubble.flipped.thought .thought-dot-1 { left: auto; right: 15px; }
528
- .speech-bubble.flipped.thought .thought-dot-2 { left: auto; right: 5px; }
529
- .speech-bubble.flipped-vertical.speech::after, .speech-bubble.flipped-vertical.idea::after { bottom: auto; top: -9px; transform: rotate(180deg); }
530
- .speech-bubble.flipped-vertical.thought .thought-dot-1 { bottom: auto; top: -20px; }
531
- .speech-bubble.flipped-vertical.thought .thought-dot-2 { bottom: auto; top: -32px; }
532
- .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; }
533
- .edit-controls h4 { margin: 0 0 10px 0; color: #26a69a; text-align: center; }
534
- .edit-controls button, .edit-controls select, .edit-controls input { 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; }
535
- .edit-controls .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
536
- .edit-controls .reset-button { background-color: #e74c3c; }
537
- .edit-controls .action-button { background-color: #4CAF50; }
538
- .edit-controls .secondary-button { background-color: #f39c12; }
539
- .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
540
- .zoom-controls { display: grid; grid-template-columns: auto 1fr; gap: 5px; align-items: center;}
541
- .timestamp-controls { display: grid; grid-template-columns: 1fr auto; gap: 5px; }
542
- .timestamp-controls input { color: #333; font-weight: normal; }
543
- </style>
 
 
 
544
  </head>
545
  <body>
546
- <div class="comic-container">
547
- <h1 class="comic-title">🎬 Generated Comic</h1>
548
- <div id="comic-pages"><div class="loading">Loading comic...</div></div>
549
- </div>
550
- <input type="file" id="image-uploader" style="display: none;" accept="image/*">
551
- <div class="edit-controls">
552
- <h4>✏️ Interactive Editor</h4>
553
- <div class="control-group">
554
- <label>Bubble Tools:</label>
555
- <select id="bubble-type-select" onchange="changeBubbleType(this.value)">
556
- <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>
557
- </select>
558
- <button onclick="rotateBubbleTail()" class="secondary-button">🔄 Rotate Tail</button>
559
- <button onclick="addBubbleToPanel()" class="action-button">💬 Add Bubble</button>
560
  </div>
561
- <div class="control-group">
562
- <label>Panel Tools (Select Panel):</label>
563
- <button onclick="replacePanelImage()" class="action-button">🖼️ Replace Image</button>
564
- <div class="button-grid">
565
- <button onclick="adjustFrame('backward')" class="secondary-button">⬅️ Prev Frame</button>
566
- <button onclick="adjustFrame('forward')" class="action-button">Next Frame ➡️</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
567
  </div>
568
- <div class="timestamp-controls">
569
- <input type="text" id="timestamp-input" placeholder="mm:ss or secs">
570
- <button onclick="gotoTimestamp()" class="action-button">Go</button>
 
 
 
 
 
 
 
 
571
  </div>
572
- </div>
573
- <div class="control-group">
574
- <label>Zoom & Pan (Select Panel):</label>
575
- <div class="zoom-controls">
576
- <button onclick="resetPanelTransform()" class="secondary-button" style="padding: 4px 6px;">Reset</button>
577
- <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled>
 
 
 
 
578
  </div>
579
  </div>
580
- <div class="control-group">
581
- <button onclick="exportPagesToPNG()" class="action-button" style="background-color: #2196F3;">🖨️ Export Pages</button>
582
- <button onclick="clearSavedState()" class="reset-button">🔄 Clear Edits & Reset</button>
583
- </div>
584
- </div>
585
- <script>
586
- document.addEventListener('DOMContentLoaded', () => {
587
- fetch('/output/pages.json')
588
- .then(res => res.ok ? res.json() : Promise.reject(new Error('Failed to load pages.json')))
589
- .then(data => { renderComic(data); initializeEditor(); })
590
- .catch(err => { document.getElementById('comic-pages').innerHTML = `<div class="loading">Error: ${err.message}</div>`; });
591
- });
592
-
593
- function renderComic(data) {
594
- const container = document.getElementById('comic-pages');
595
- container.innerHTML = '';
596
- if (!data || data.length === 0) return;
597
- data.forEach((pageData, pageIndex) => {
598
- const pageWrapper = document.createElement('div');
599
- pageWrapper.className = 'page-wrapper';
600
- const pageTitleEl = document.createElement('h2');
601
- pageTitleEl.className = 'page-title';
602
- pageTitleEl.textContent = `Page ${pageIndex + 1}`;
603
- pageWrapper.appendChild(pageTitleEl);
604
- const pageDiv = document.createElement('div');
605
- pageDiv.className = 'comic-page';
606
- const grid = document.createElement('div');
607
- grid.className = 'comic-grid';
608
- pageData.panels.forEach((panelData, panelIndex) => {
609
- const panelDiv = document.createElement('div');
610
- panelDiv.className = 'panel';
611
- const img = document.createElement('img');
612
- img.src = '/frames/final/' + panelData.image;
613
- panelDiv.appendChild(img);
614
- if (pageData.bubbles && pageData.bubbles[panelIndex] && pageData.bubbles[panelIndex].dialog) {
615
- const bubbleData = pageData.bubbles[panelIndex];
616
- const bubbleDiv = createBubbleElement({
617
- id: `initial-${pageIndex}-${panelIndex}`,
618
- text: bubbleData.dialog || '',
619
- left: `${bubbleData.bubble_offset_x ?? 50}px`,
620
- top: `${bubbleData.bubble_offset_y ?? 20}px`,
621
- });
622
- panelDiv.appendChild(bubbleDiv);
623
- }
624
- grid.appendChild(panelDiv);
625
- });
626
- pageDiv.appendChild(grid);
627
- pageWrapper.appendChild(pageDiv);
628
- container.appendChild(pageWrapper);
629
  });
630
- }
631
-
632
- let currentlyEditing = null, draggedBubble = null, offset = {x: 0, y: 0};
633
- let currentlySelectedBubble = null;
634
- let currentlySelectedPanel = null;
635
- let isPanning = false, panStartX, panStartY, panStartTranslateX, panStartTranslateY;
636
-
637
- function initializeEditor() {
638
- document.querySelectorAll('.panel').forEach(panel => {
639
- panel.addEventListener('click', e => selectPanel(panel));
640
- const img = panel.querySelector('img');
641
- if(img) initializePanelImageEvents(img);
642
- });
643
- document.querySelectorAll('.speech-bubble').forEach(b => initializeBubbleEvents(b));
644
- document.getElementById('zoom-slider').addEventListener('input', handleZoom);
645
 
646
- document.addEventListener('mousemove', (e) => {
647
- if (isPanning) panImage(e);
648
- if (draggedBubble) drag(e);
649
- });
650
- document.addEventListener('mouseup', (e) => {
651
- if (isPanning) stopPan(e);
652
- if (draggedBubble) stopDrag(e);
653
- });
654
- document.addEventListener('mouseleave', (e) => {
655
- if (isPanning) stopPan(e);
656
- if (draggedBubble) stopDrag(e);
657
- });
658
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
659
 
660
- function initializePanelImageEvents(img) {
661
- img.addEventListener('mousedown', startPan);
662
- }
663
-
664
- function initializeBubbleEvents(bubble) {
665
- bubble.addEventListener('dblclick', e => { e.stopPropagation(); editBubbleText(bubble); });
666
- bubble.addEventListener('mousedown', e => { e.stopPropagation(); startDrag(e); });
667
- bubble.addEventListener('click', e => { e.stopPropagation(); selectBubble(bubble); });
668
- bubble.addEventListener('wheel', e => {
669
- e.preventDefault();
670
- const currentWidth = parseFloat(bubble.style.width) || bubble.offsetWidth;
671
- const newWidth = currentWidth - (e.deltaY > 0 ? 10 : -10);
672
- if (newWidth >= 60) {
673
- bubble.style.width = `${newWidth}px`;
674
- bubble.style.height = 'auto';
675
- }
676
- }, { passive: false });
677
- }
678
-
679
- function createBubbleElement(data) {
680
- const bubbleDiv = document.createElement('div');
681
- bubbleDiv.dataset.id = data.id;
682
- const textSpan = document.createElement('span');
683
- textSpan.className = 'bubble-text';
684
- textSpan.textContent = data.text;
685
- bubbleDiv.appendChild(textSpan);
686
- bubbleDiv.style.left = data.left;
687
- bubbleDiv.style.top = data.top;
688
- applyBubbleType(bubbleDiv, 'speech');
689
- return bubbleDiv;
690
- }
691
-
692
- function applyBubbleType(bubble, type) {
693
- bubble.querySelectorAll('.thought-dot').forEach(el => el.remove());
694
- let classesToKeep = 'speech-bubble';
695
- if (bubble.classList.contains('selected')) classesToKeep += ' selected';
696
- if (bubble.classList.contains('flipped')) classesToKeep += ' flipped';
697
- if (bubble.classList.contains('flipped-vertical')) classesToKeep += ' flipped-vertical';
698
- bubble.className = classesToKeep;
699
- bubble.classList.add(type);
700
- bubble.dataset.type = type;
701
- if (type === 'thought') {
702
- for (let i = 1; i <= 2; i++) {
703
- const dot = document.createElement('div');
704
- dot.className = `thought-dot thought-dot-${i}`;
705
- bubble.appendChild(dot);
 
 
 
 
 
 
 
 
 
 
706
  }
707
  }
708
- }
709
-
710
- function changeBubbleType(type) {
711
- if (!currentlySelectedBubble) return;
712
- applyBubbleType(currentlySelectedBubble, type);
713
- }
714
-
715
- function rotateBubbleTail() {
716
- if (!currentlySelectedBubble) { alert("Please select a bubble first."); return; }
717
- const isFlippedH = currentlySelectedBubble.classList.contains('flipped');
718
- const isFlippedV = currentlySelectedBubble.classList.contains('flipped-vertical');
719
- if (!isFlippedH && !isFlippedV) {
720
- currentlySelectedBubble.classList.add('flipped');
721
- } else if (isFlippedH && !isFlippedV) {
722
- currentlySelectedBubble.classList.add('flipped-vertical');
723
- } else if (isFlippedH && isFlippedV) {
724
- currentlySelectedBubble.classList.remove('flipped');
725
- } else {
726
- currentlySelectedBubble.classList.remove('flipped-vertical');
727
  }
728
- }
729
-
730
- function selectPanel(panel) {
731
- document.querySelectorAll('.panel.selected').forEach(p => p.classList.remove('selected'));
732
- panel.classList.add('selected');
733
- currentlySelectedPanel = panel;
734
- selectBubble(null);
735
 
736
- const img = currentlySelectedPanel.querySelector('img');
737
- const zoomSlider = document.getElementById('zoom-slider');
738
- zoomSlider.value = img.dataset.zoom || 100;
739
- zoomSlider.disabled = false;
740
- }
741
-
742
- function selectBubble(bubble) {
743
- if (currentlySelectedBubble) currentlySelectedBubble.classList.remove('selected');
744
- currentlySelectedBubble = bubble;
745
- if (currentlySelectedBubble) {
746
- currentlySelectedBubble.classList.add('selected');
747
- if(currentlySelectedPanel) currentlySelectedPanel.classList.remove('selected');
748
- currentlySelectedPanel = null;
749
- document.getElementById('bubble-type-select').value = currentlySelectedBubble.dataset.type || 'speech';
750
- document.getElementById('zoom-slider').disabled = true;
751
  }
752
- }
753
-
754
- function editBubbleText(bubble) {
755
- if (currentlyEditing) return;
756
- currentlyEditing = bubble;
757
- const textSpan = bubble.querySelector('.bubble-text');
758
- const currentText = textSpan.textContent;
759
- textSpan.style.display = 'none';
760
- bubble.style.height = 'auto';
761
- const textarea = document.createElement('textarea');
762
- textarea.value = currentText;
763
- bubble.appendChild(textarea);
764
- textarea.focus();
765
- const finishEditing = () => {
766
- textSpan.textContent = textarea.value;
767
- bubble.removeChild(textarea);
768
- textSpan.style.display = '';
769
- currentlyEditing = null;
770
- bubble.style.height = 'auto';
771
- };
772
- textarea.addEventListener('blur', finishEditing, { once: true });
773
- textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); }});
774
- }
775
-
776
- function startDrag(e) {
777
- const bubble = e.target.closest('.speech-bubble');
778
- if (!bubble || currentlyEditing) return;
779
- draggedBubble = bubble;
780
- selectBubble(bubble);
781
- const rect = bubble.getBoundingClientRect();
782
- offset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
783
- }
784
-
785
- function drag(e) {
786
- if (!draggedBubble) return;
787
- const parentRect = draggedBubble.parentElement.getBoundingClientRect();
788
- let x = e.clientX - parentRect.left - offset.x;
789
- let y = e.clientY - parentRect.top - offset.y;
790
- draggedBubble.style.left = `${x}px`;
791
- draggedBubble.style.top = `${y}px`;
792
- }
793
-
794
- function stopDrag() {
795
- draggedBubble = null;
796
- }
797
-
798
- function clearSavedState() {
799
- if (confirm("Reset all edits to the original AI-generated comic?")) {
800
- localStorage.removeItem('comicEditorState');
801
- window.location.reload();
802
  }
803
- }
804
-
805
- async function exportPagesToPNG() {
806
- const pages = document.querySelectorAll('.comic-page');
807
- if (pages.length === 0) return alert("No pages found.");
808
- alert(`Starting export of ${pages.length} page(s).`);
809
- for (let i = 0; i < pages.length; i++) {
810
- try {
811
- const canvas = await html2canvas(pages[i], { scale: 2 });
812
- const link = document.createElement('a');
813
- link.download = `comic-page-${i + 1}.png`;
814
- link.href = canvas.toDataURL('image/png');
815
- link.click();
816
- } catch (err) {
817
- alert(`Failed to export page ${i + 1}.`);
 
 
 
 
 
 
 
818
  }
819
  }
820
- }
821
-
822
- function replacePanelImage() {
823
- if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
824
- const img = currentlySelectedPanel.querySelector('img');
825
- const uploader = document.getElementById('image-uploader');
826
- const oneTimeListener = (event) => {
827
- const file = event.target.files[0];
828
- if (!file) return;
829
- const formData = new FormData();
830
- formData.append('image', file);
831
- img.style.opacity = '0.5';
832
- fetch('/replace_panel', { method: 'POST', body: formData })
833
- .then(response => response.json())
834
- .then(data => {
835
- if (data.success) {
836
- img.src = `/frames/final/${data.new_filename}?t=${new Date().getTime()}`;
837
- } else { alert('Error replacing image: ' + data.error); }
838
- img.style.opacity = '1';
839
- })
840
- .catch(error => {
841
- alert('An error occurred during the upload.');
842
- img.style.opacity = '1';
843
- });
844
- uploader.removeEventListener('change', oneTimeListener);
845
- uploader.value = '';
846
- };
847
- uploader.addEventListener('change', oneTimeListener, { once: true });
848
- uploader.click();
849
- }
850
-
851
- function adjustFrame(direction) {
852
- if (!currentlySelectedPanel) { alert("Please select a panel first to adjust its frame."); return; }
853
- const img = currentlySelectedPanel.querySelector('img');
854
- const currentSrc = img.src;
855
- let filename = currentSrc.substring(currentSrc.lastIndexOf('/') + 1);
856
- if (filename.includes('?')) { filename = filename.split('?')[0]; }
857
- img.style.opacity = '0.5';
858
- fetch('/regenerate_frame', {
859
- method: 'POST',
860
- headers: { 'Content-Type': 'application/json' },
861
- body: JSON.stringify({ filename: filename, direction: direction })
862
- })
863
- .then(response => response.json())
864
- .then(data => {
865
- if (data.success) {
866
- img.src = `/frames/final/${filename}?t=${new Date().getTime()}`;
867
- console.log(data.message);
868
- } else { alert('Error: ' + data.message); }
869
- img.style.opacity = '1';
870
- })
871
- .catch(error => {
872
- alert('An error occurred during frame adjustment.');
873
- img.style.opacity = '1';
874
- });
875
- }
876
 
877
- function updateImageTransform(img) {
878
- const zoom = (img.dataset.zoom || 100) / 100;
879
- const x = img.dataset.translateX || 0;
880
- const y = img.dataset.translateY || 0;
881
- img.style.transform = `translateX(${x}px) translateY(${y}px) scale(${zoom})`;
882
- if (zoom > 1) { img.classList.add('pannable'); } else { img.classList.remove('pannable'); }
883
- }
 
884
 
885
- function handleZoom(event) {
886
- if (!currentlySelectedPanel) return;
887
- const img = currentlySelectedPanel.querySelector('img');
888
- img.dataset.zoom = event.target.value;
889
- updateImageTransform(img);
890
- }
891
-
892
- function resetPanelTransform() {
893
- if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
894
- const img = currentlySelectedPanel.querySelector('img');
895
- img.dataset.zoom = 100;
896
- img.dataset.translateX = 0;
897
- img.dataset.translateY = 0;
898
- document.getElementById('zoom-slider').value = 100;
899
- updateImageTransform(img);
900
- }
901
 
902
- function startPan(event) {
903
- if (event.button !== 0) return;
904
- const img = event.target;
905
- const zoom = parseFloat(img.dataset.zoom || 100);
906
- if (zoom <= 100) return;
907
- event.preventDefault();
908
- isPanning = true;
909
- img.classList.add('panning');
910
- panStartX = event.clientX;
911
- panStartY = event.clientY;
912
- panStartTranslateX = parseFloat(img.dataset.translateX || 0);
913
- panStartTranslateY = parseFloat(img.dataset.translateY || 0);
914
- }
915
 
916
- function panImage(event) {
917
- if (!isPanning || !currentlySelectedPanel) return;
918
- const img = currentlySelectedPanel.querySelector('img');
919
- const dx = event.clientX - panStartX;
920
- const dy = event.clientY - panStartY;
921
- img.dataset.translateX = panStartTranslateX + dx;
922
- img.dataset.translateY = panStartTranslateY + dy;
923
- updateImageTransform(img);
924
- }
925
 
926
- function stopPan(event) {
927
- if (!isPanning) return;
928
- isPanning = false;
929
- if (currentlySelectedPanel) {
 
 
 
 
 
 
 
 
 
 
 
 
 
930
  const img = currentlySelectedPanel.querySelector('img');
931
- if(img) img.classList.remove('panning');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
932
  }
933
- }
934
-
935
- function addBubbleToPanel() {
936
- if (!currentlySelectedPanel) {
937
- alert("Please select a panel first to add a bubble to it.");
938
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
939
  }
940
 
941
- const newBubble = createBubbleElement({
942
- id: `new-bubble-${Date.now()}`,
943
- text: 'New Text...',
944
- left: '10%',
945
- top: '10%'
946
- });
 
947
 
948
- currentlySelectedPanel.appendChild(newBubble);
949
- initializeBubbleEvents(newBubble);
950
- selectBubble(newBubble);
951
- editBubbleText(newBubble);
952
- }
953
-
954
- function gotoTimestamp() {
955
- if (!currentlySelectedPanel) {
956
- alert("Please select a panel first to jump to a timestamp.");
957
- return;
958
  }
959
- const input = document.getElementById('timestamp-input');
960
- const timeStr = input.value.trim();
961
- if (!timeStr) {
962
- alert("Please enter a timestamp (e.g., '1:32' or '92.5').");
963
- return;
 
 
 
 
964
  }
965
 
966
- let parsedSeconds = 0;
967
- if (timeStr.includes(':')) {
968
- const parts = timeStr.split(':');
969
- try {
970
- const minutes = parseInt(parts[0], 10);
971
- const seconds = parseFloat(parts[1]);
972
- if (!isNaN(minutes) && !isNaN(seconds)) {
973
- parsedSeconds = (minutes * 60) + seconds;
974
- } else { throw new Error(); }
975
- } catch {
976
- alert("Invalid time format. Please use 'mm:ss'.");
977
- return;
978
- }
979
- } else {
980
- parsedSeconds = parseFloat(timeStr);
981
- if (isNaN(parsedSeconds)) {
982
- alert("Invalid time format. Please enter seconds as a number.");
983
- return;
984
- }
985
  }
986
 
987
- const img = currentlySelectedPanel.querySelector('img');
988
- let filename = img.src.substring(img.src.lastIndexOf('/') + 1);
989
- if (filename.includes('?')) { filename = filename.split('?')[0]; }
 
 
 
 
990
 
991
- img.style.opacity = '0.5';
992
- fetch('/goto_timestamp', {
993
- method: 'POST',
994
- headers: { 'Content-Type': 'application/json' },
995
- body: JSON.stringify({ filename: filename, timestamp: parsedSeconds })
996
- })
997
- .then(response => response.json())
998
- .then(data => {
999
- if (data.success) {
1000
- img.src = `/frames/final/${filename}?t=${new Date().getTime()}`;
1001
- input.value = '';
1002
- resetPanelTransform();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1003
  } else {
1004
- alert('Error: ' + data.message);
1005
  }
1006
- img.style.opacity = '1';
1007
- })
1008
- .catch(error => {
1009
- alert('An error occurred while fetching the timestamp.');
1010
- img.style.opacity = '1';
1011
- });
1012
- }
1013
- </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1014
  </body>
1015
  </html>'''
1016
  with open(os.path.join(self.output_dir, 'page.html'), 'w', encoding='utf-8') as f:
@@ -1024,87 +999,61 @@ comic_generator = EnhancedComicGenerator()
1024
 
1025
  @app.route('/')
1026
  def index():
1027
- return render_template('index.html')
1028
 
1029
  @app.route('/uploader', methods=['POST'])
1030
  def upload_file():
1031
  try:
1032
- if 'file' not in request.files or request.files['file'].filename == '':
1033
- return "❌ No file selected"
1034
  f = request.files['file']
1035
- if os.path.exists(comic_generator.video_path):
1036
- os.remove(comic_generator.video_path)
1037
  f.save(comic_generator.video_path)
1038
- success = comic_generator.generate_comic()
1039
- if success:
1040
- return "🎉 Enhanced Comic Created Successfully! View it at the /comic endpoint."
1041
- else:
1042
- return "❌ Comic generation failed. Check the Space logs for details."
1043
  except Exception as e:
1044
  traceback.print_exc()
1045
- return f"❌ An unexpected error occurred: {str(e)}"
 
 
 
 
 
 
 
1046
 
1047
  @app.route('/handle_link', methods=['POST'])
1048
  def handle_link():
1049
- try:
1050
- link = request.form.get('link', '')
1051
- if not link: return "❌ No link provided"
1052
- import yt_dlp
1053
- ydl_opts = {'outtmpl': comic_generator.video_path, 'format': 'best[height<=720]'}
1054
- with yt_dlp.YoutubeDL(ydl_opts) as ydl:
1055
- ydl.download([link])
1056
- success = comic_generator.generate_comic()
1057
- if success:
1058
- return "🎉 Enhanced Comic Created Successfully! View it at the /comic endpoint."
1059
- else:
1060
- return "❌ Comic generation failed. Check the Space logs for details."
1061
- except Exception as e:
1062
- traceback.print_exc()
1063
- return f"❌ An unexpected error occurred: {str(e)}"
1064
 
1065
  @app.route('/replace_panel', methods=['POST'])
1066
  def replace_panel():
1067
  try:
1068
- if 'image' not in request.files: return jsonify({'success': False, 'error': 'No image file provided.'})
1069
  file = request.files['image']
1070
- timestamp = int(time.time() * 1000)
1071
- filename = f"replaced_panel_{timestamp}.png"
1072
- save_path = os.path.join(comic_generator.frames_dir, filename)
1073
- file.save(save_path)
1074
- print(f"✅ Replaced panel with '{filename}' without applying color enhancement.")
1075
  return jsonify({'success': True, 'new_filename': filename})
1076
  except Exception as e:
1077
- traceback.print_exc()
1078
  return jsonify({'success': False, 'error': str(e)})
1079
 
1080
  @app.route('/regenerate_frame', methods=['POST'])
1081
  def regenerate_frame_route():
1082
  try:
1083
  data = request.get_json()
1084
- filename = data.get('filename')
1085
- direction = data.get('direction')
1086
- if not filename or not direction:
1087
- return jsonify({'success': False, 'message': 'Filename or direction missing.'})
1088
-
1089
- result = comic_generator.regenerate_frame(filename, direction)
1090
  return jsonify(result)
1091
  except Exception as e:
1092
- traceback.print_exc()
1093
  return jsonify({'success': False, 'message': str(e)})
1094
 
1095
  @app.route('/goto_timestamp', methods=['POST'])
1096
  def goto_timestamp_route():
1097
  try:
1098
  data = request.get_json()
1099
- filename = data.get('filename')
1100
- timestamp = data.get('timestamp')
1101
- if filename is None or timestamp is None:
1102
- return jsonify({'success': False, 'message': 'Filename or timestamp missing.'})
1103
-
1104
- result = comic_generator.get_frame_at_timestamp(filename, float(timestamp))
1105
  return jsonify(result)
1106
  except Exception as e:
1107
- traceback.print_exc()
1108
  return jsonify({'success': False, 'message': str(e)})
1109
 
1110
  @app.route('/comic')
 
65
 
66
  app = Flask(__name__)
67
 
68
+ INDEX_HTML = '''
69
+ <!DOCTYPE html>
70
+ <html lang="en">
71
+ <head>
72
+ <meta charset="UTF-8">
73
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
74
+ <title>Movie to Comic Generator</title>
75
+ <style>
76
+ body {
77
+ background-color: #fdf6e3;
78
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
79
+ color: #3d3d3d;
80
+ display: flex;
81
+ justify-content: center;
82
+ align-items: center;
83
+ min-height: 100vh;
84
+ margin: 0;
85
+ }
86
+ .container {
87
+ max-width: 500px;
88
+ width: 100%;
89
+ padding: 40px;
90
+ background-color: #ffffff;
91
+ border-radius: 12px;
92
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
93
+ text-align: center;
94
+ }
95
+ h1 {
96
+ color: #2c3e50;
97
+ margin-bottom: 30px;
98
+ font-weight: 600;
99
+ }
100
+ .file-input { display: none; }
101
+ .file-label {
102
+ display: block;
103
+ padding: 15px 25px;
104
+ background-color: #2c3e50;
105
+ color: white;
106
+ border-radius: 8px;
107
+ cursor: pointer;
108
+ font-size: 16px;
109
+ font-weight: 500;
110
+ transition: background-color 0.3s ease, transform 0.2s ease;
111
+ }
112
+ .file-label:hover {
113
+ background-color: #34495e;
114
+ transform: translateY(-2px);
115
+ }
116
+ #file-name {
117
+ display: block;
118
+ margin-top: 15px;
119
+ font-style: italic;
120
+ color: #7f8c8d;
121
+ }
122
+ .submit-btn {
123
+ width: 100%;
124
+ padding: 15px;
125
+ border: none;
126
+ border-radius: 8px;
127
+ background-color: #e67e22;
128
+ color: white;
129
+ font-size: 18px;
130
+ font-weight: bold;
131
+ cursor: pointer;
132
+ transition: background-color 0.3s ease;
133
+ margin-top: 20px;
134
+ }
135
+ .submit-btn:hover { background-color: #d35400; }
136
+ .loading-container {
137
+ display: none;
138
+ flex-direction: column;
139
+ align-items: center;
140
+ justify-content: center;
141
+ }
142
+ .loader {
143
+ width: 120px;
144
+ height: 20px;
145
+ border-radius: 20px;
146
+ background:
147
+ radial-gradient(circle 10px, #e67e22 100%, transparent 0),
148
+ radial-gradient(circle 10px, #e67e22 100%, transparent 0),
149
+ radial-gradient(circle 10px, #e67e22 100%, transparent 0);
150
+ background-size: 20px 20px;
151
+ background-position: 0px 50%, 50px 50%, 100px 50%;
152
+ background-repeat: no-repeat;
153
+ animation:-ball 2s infinite linear;
154
+ }
155
+ @keyframes -ball {
156
+ 0% {background-position: 0px 50% ,50px 50% ,100px 50%}
157
+ 16% {background-position: 0px 0% ,50px 50% ,100px 50%}
158
+ 33% {background-position: 0px 100% ,50px 0% ,100px 50%}
159
+ 50% {background-position: 0px 50% ,50px 100% ,100px 0%}
160
+ 66% {background-position: 0px 50% ,50px 50% ,100px 100%}
161
+ 83% {background-position: 0px 50% ,50px 50% ,100px 50%}
162
+ 100% {background-position: 0px 50% ,50px 50% ,100px 50%}
163
+ }
164
+ #status-text {
165
+ margin-top: 25px;
166
+ color: #34495e;
167
+ font-weight: 500;
168
+ font-size: 18px;
169
+ }
170
+ #final-message {
171
+ display: none;
172
+ font-size: 20px;
173
+ font-weight: bold;
174
+ color: #27ae60;
175
+ }
176
+ </style>
177
+ </head>
178
+ <body>
179
+ <div class="container" id="main-container">
180
+ <div id="upload-view">
181
+ <h1>🎬 Movie to Comic Generator</h1>
182
+ <form id="upload-form">
183
+ <label for="file-upload" class="file-label">Choose Video File</label>
184
+ <input id="file-upload" class="file-input" type="file" name="file" onchange="updateFileName(this)">
185
+ <span id="file-name">No file selected</span>
186
+ <button class="submit-btn" type="submit">Generate Comic</button>
187
+ </form>
188
+ </div>
189
+ <div class="loading-container" id="loading-view">
190
+ <div class="loader"></div>
191
+ <p id="status-text">Starting...</p>
192
+ <p id="final-message">✅ Generation Complete! Opening your comic...</p>
193
+ </div>
194
+ </div>
195
+ <script>
196
+ let statusInterval;
197
+ function updateFileName(input) {
198
+ const fileNameSpan = document.getElementById('file-name');
199
+ if (input.files && input.files.length > 0) {
200
+ fileNameSpan.textContent = input.files[0].name;
201
+ } else {
202
+ fileNameSpan.textContent = 'No file selected';
203
+ }
204
+ }
205
+ async function checkStatus() {
206
+ try {
207
+ const response = await fetch('/status');
208
+ const data = await response.json();
209
+ const statusText = document.getElementById('status-text');
210
+ statusText.textContent = data.message;
211
+ if (data.progress >= 100) {
212
+ clearInterval(statusInterval);
213
+ document.querySelector('.loader').style.display = 'none';
214
+ statusText.style.display = 'none';
215
+ document.getElementById('final-message').style.display = 'block';
216
+ setTimeout(() => {
217
+ window.open('/comic', '_blank');
218
+ }, 1500);
219
+ } else if (data.progress < 0) {
220
+ clearInterval(statusInterval);
221
+ statusText.textContent = "An error occurred. Please check the logs.";
222
+ statusText.style.color = '#e74c3c';
223
+ document.querySelector('.loader').style.display = 'none';
224
+ }
225
+ } catch (error) {
226
+ console.error("Error fetching status:", error);
227
+ document.getElementById('status-text').textContent = "Error checking status. Retrying...";
228
+ }
229
+ }
230
+ document.getElementById('upload-form').addEventListener('submit', async function(event) {
231
+ event.preventDefault();
232
+ const fileInput = document.getElementById('file-upload');
233
+ if (!fileInput.files || fileInput.files.length === 0) {
234
+ alert("Please select a video file first.");
235
+ return;
236
+ }
237
+ document.getElementById('upload-view').style.display = 'none';
238
+ document.getElementById('loading-view').style.display = 'flex';
239
+ const formData = new FormData();
240
+ formData.append('file', fileInput.files[0]);
241
+ try {
242
+ const response = await fetch('/uploader', {
243
+ method: 'POST',
244
+ body: formData
245
+ });
246
+ if (!response.ok) {
247
+ throw new Error('Upload failed!');
248
+ }
249
+ statusInterval = setInterval(checkStatus, 2000);
250
+ } catch (error) {
251
+ console.error("Upload error:", error);
252
+ document.getElementById('status-text').textContent = "Failed to start generation. Please try again.";
253
+ }
254
+ });
255
+ </script>
256
+ </body>
257
+ </html>
258
+ '''
259
+
260
  os.makedirs('video', exist_ok=True)
261
  os.makedirs('frames/final', exist_ok=True)
262
  os.makedirs('output', exist_ok=True)
263
 
264
+ def update_status(message, progress):
265
+ status_file = os.path.join('output', 'status.json')
266
+ with open(status_file, 'w') as f:
267
+ json.dump({'message': message, 'progress': progress}, f)
268
+
269
  class EnhancedComicGenerator:
 
270
  def __init__(self):
271
  self.video_path = 'video/uploaded.mp4'
272
  self.frames_dir = 'frames/final'
 
275
  self.video_fps = None
276
 
277
  def cleanup_generated(self):
278
+ print("🧹 Performing full cleanup...")
 
279
  if os.path.isdir(self.frames_dir): shutil.rmtree(self.frames_dir)
280
  if os.path.isdir(self.output_dir): shutil.rmtree(self.output_dir)
281
  if os.path.isdir('temp'): shutil.rmtree('temp')
 
284
  os.makedirs(self.output_dir, exist_ok=True)
285
  print("✅ Cleanup complete.")
286
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  def regenerate_frame(self, frame_filename, direction):
 
 
 
288
  try:
289
  if not self.video_fps:
290
+ return {"success": False, "message": "Video FPS not found."}
 
291
  metadata_path = 'frames/frame_metadata.json'
292
  if not os.path.exists(metadata_path):
293
  return {"success": False, "message": "Frame metadata missing."}
 
294
  with open(metadata_path, 'r') as f:
295
  frame_to_time = json.load(f)
 
296
  if frame_filename not in frame_to_time:
297
+ return {"success": False, "message": "Panel not linked to video."}
298
+ current_time = frame_to_time[frame_filename]['time'] if isinstance(frame_to_time[frame_filename], dict) else frame_to_time[frame_filename]
 
 
 
 
 
299
  frame_duration = 1.0 / self.video_fps
300
+ target_time = current_time + frame_duration if direction == 'forward' else current_time - frame_duration
 
 
 
 
 
 
 
301
  target_time = max(0, target_time)
 
302
  cap = cv2.VideoCapture(self.video_path)
303
+ if not cap.isOpened(): return {"success": False, "message": "Cannot open video."}
 
 
304
  cap.set(cv2.CAP_PROP_POS_MSEC, target_time * 1000)
305
  ret, frame = cap.read()
306
  cap.release()
 
307
  if not ret or frame is None:
308
+ return {"success": False, "message": f"No frame at {target_time:.2f}s."}
 
309
  new_path = os.path.join(self.frames_dir, frame_filename)
310
  cv2.imwrite(new_path, frame)
 
311
  if isinstance(frame_to_time[frame_filename], dict):
312
  frame_to_time[frame_filename]['time'] = target_time
313
  else:
314
  frame_to_time[frame_filename] = target_time
315
+ with open(metadata_path, 'w') as f: json.dump(frame_to_time, f, indent=2)
 
 
 
316
  message = f"Adjusted {direction} to {target_time:.3f}s"
317
  print(f"✅ {message}")
318
+ return {"success": True, "message": message, "new_filename": frame_filename}
 
 
 
 
 
 
319
  except Exception as e:
320
  traceback.print_exc()
321
  return {"success": False, "message": str(e)}
322
 
323
  def get_frame_at_timestamp(self, frame_filename, timestamp_seconds):
 
 
 
324
  try:
325
  metadata_path = 'frames/frame_metadata.json'
326
+ if not os.path.exists(metadata_path): return {"success": False, "message": "Frame metadata missing."}
 
 
327
  cap = cv2.VideoCapture(self.video_path)
328
+ if not cap.isOpened(): return {"success": False, "message": "Cannot open video."}
 
 
329
  fps = cap.get(cv2.CAP_PROP_FPS)
330
  if fps == 0: fps = 25
331
+ duration = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) / fps
 
332
  if timestamp_seconds < 0 or timestamp_seconds > duration:
333
  cap.release()
334
+ return {"success": False, "message": f"Timestamp must be between 0 and {duration:.2f}s."}
 
335
  cap.set(cv2.CAP_PROP_POS_MSEC, timestamp_seconds * 1000)
336
  ret, frame = cap.read()
337
  cap.release()
338
+ if not ret or frame is None: return {"success": False, "message": f"Could not retrieve frame at {timestamp_seconds:.2f}s."}
 
 
 
339
  new_path = os.path.join(self.frames_dir, frame_filename)
340
  cv2.imwrite(new_path, frame)
341
+ with open(metadata_path, 'r') as f: frame_to_time = json.load(f)
 
 
 
342
  if frame_filename in frame_to_time:
343
  if isinstance(frame_to_time[frame_filename], dict):
344
  frame_to_time[frame_filename]['time'] = timestamp_seconds
345
  else:
346
  frame_to_time[frame_filename] = timestamp_seconds
347
+ with open(metadata_path, 'w') as f: json.dump(frame_to_time, f, indent=2)
 
 
 
348
  message = f"Jumped to timestamp {timestamp_seconds:.3f}s"
349
  print(f"✅ {message}")
 
350
  return { "success": True, "message": message }
 
351
  except Exception as e:
352
  traceback.print_exc()
353
  return {"success": False, "message": str(e)}
354
 
355
  def generate_keyframes_from_moments(self, video_path, key_moments, max_frames=48):
 
 
 
356
  try:
357
  cap = cv2.VideoCapture(video_path)
358
+ if not cap.isOpened(): raise Exception("Cannot open video for keyframe extraction")
359
+ fps, total_frames = self.video_fps, int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
 
 
 
 
360
  duration = total_frames / fps
 
361
  key_moments.sort(key=lambda x: x['start'])
362
+ if len(key_moments) > max_frames: pass # Simplified sampling
363
+ frame_metadata, frame_count = {}, 0
364
+ for i, moment in enumerate(key_moments):
365
+ update_status(f"Extracting frame {i+1}/{len(key_moments)}...", 25 + int(20 * (i / len(key_moments))))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  frame_time = (moment['start'] + moment['end']) / 2
367
+ if frame_time > duration: continue
 
 
 
368
  frame_number = int(frame_time * fps)
 
369
  cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
370
  ret, frame = cap.read()
 
371
  if ret:
372
  frame_filename = f"frame_{frame_count:04d}.png"
373
  frame_path = os.path.join(self.frames_dir, frame_filename)
374
  cv2.imwrite(frame_path, frame)
375
+ frame_metadata[frame_filename] = { 'time': frame_time, 'dialogue': moment['text'], 'start': moment['start'], 'end': moment['end'] }
 
 
 
 
 
 
376
  frame_count += 1
 
 
377
  cap.release()
 
378
  with open(os.path.join('frames', 'frame_metadata.json'), 'w') as f:
379
  json.dump(frame_metadata, f, indent=2)
 
380
  print(f"✅ Extracted {frame_count} keyframes from video")
381
  return True
 
382
  except Exception as e:
383
  print(f"❌ Error extracting keyframes: {e}")
 
384
  return False
385
 
386
+ def generate_comic(self):
 
387
  start_time = time.time()
 
 
388
  try:
389
+ update_status("Cleaning up...", 0)
390
+ self.cleanup_generated()
391
+ update_status("Analyzing video...", 5)
392
  cap = cv2.VideoCapture(self.video_path)
393
+ if not cap.isOpened(): raise Exception("Cannot open video to get FPS.")
 
 
394
  self.video_fps = cap.get(cv2.CAP_PROP_FPS)
395
+ if self.video_fps == 0: self.video_fps = 25
 
 
396
  cap.release()
397
  print(f"✅ Video FPS detected: {self.video_fps:.2f}")
398
+ update_status("Generating subtitles (this may take a while)...", 10)
 
399
  get_real_subtitles(self.video_path)
400
+ with open('test1.srt', 'r', encoding='utf-8') as f:
401
+ all_subs = list(srt.parse(f.read()))
402
+ key_moments = [{'index': s.index, 'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in all_subs]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  if not self.generate_keyframes_from_moments(self.video_path, key_moments, max_frames=48):
404
+ raise Exception("Keyframe extraction failed.")
405
+ update_status("Cropping black bars...", 45)
 
 
406
  black_x, black_y, _, _ = black_bar_crop()
407
+ update_status("Enhancing images...", 50)
 
 
408
  self._enhance_all_images()
409
  self._enhance_quality_colors()
410
+ update_status("Placing speech bubbles...", 75)
 
 
411
  bubbles = self._create_ai_bubbles_from_moments(black_x, black_y)
412
+ update_status("Assembling comic pages...", 90)
 
 
413
  pages = self._generate_pages(bubbles)
414
+ update_status("Saving final comic...", 95)
 
 
415
  self._save_results(pages)
 
 
416
  execution_time = (time.time() - start_time) / 60
417
  print(f"✅ Comic generation completed in {execution_time:.2f} minutes")
418
+ update_status("Complete!", 100)
419
  return True
420
  except Exception as e:
421
  print(f"❌ Comic generation failed: {e}")
422
  traceback.print_exc()
423
+ update_status(f"Error: {e}", -1)
424
  return False
425
 
426
  def _enhance_all_images(self, single_image_path=None):
427
  target_dir = self.frames_dir
428
+ if single_image_path: target_dir = os.path.dirname(single_image_path)
 
429
  if not os.path.exists(target_dir): return
430
  try:
431
+ SimpleColorEnhancer().enhance_batch(target_dir)
432
+ except Exception as e: print(f"❌ Simple enhancement failed: {e}")
 
 
433
 
434
  def _enhance_quality_colors(self, single_image_path=None):
435
  target_dir = self.frames_dir
436
+ if single_image_path: target_dir = os.path.dirname(single_image_path)
 
437
  try:
438
+ QualityColorEnhancer().batch_enhance(target_dir)
439
+ except Exception as e: print(f"⚠️ Quality enhancement failed: {e}")
 
 
440
 
441
  def _create_ai_bubbles_from_moments(self, black_x, black_y):
442
+ bubbles, frame_files = [], sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
 
 
443
  metadata_path = 'frames/frame_metadata.json'
444
+ if not os.path.exists(metadata_path): return [bubble(dialog="") for _ in frame_files]
445
+ with open(metadata_path, 'r') as f: frame_metadata = json.load(f)
446
+ for i, frame_file in enumerate(frame_files):
447
+ update_status(f"Placing bubble {i+1}/{len(frame_files)}...", 75 + int(15 * (i / len(frame_files))))
 
 
 
 
448
  frame_path = os.path.join(self.frames_dir, frame_file)
449
+ dialogue = frame_metadata.get(frame_file, {}).get('dialogue', "")
 
 
 
 
450
  try:
 
451
  faces = face_detector.detect_faces(frame_path)
452
+ lip_x, lip_y = face_detector.get_lip_position(frame_path, faces[0]) if faces else (-1, -1)
 
 
453
  bubble_x, bubble_y = ai_bubble_placer.place_bubble_ai(frame_path, (lip_x, lip_y))
454
+ bubbles.append(bubble(bubble_offset_x=bubble_x, bubble_offset_y=bubble_y, lip_x=lip_x, lip_y=lip_y, dialog=dialogue, emotion='normal'))
 
 
 
455
  except Exception as e:
456
+ print(f"-> Could not place bubble for {frame_file}: {e}. Using default.")
457
+ bubbles.append(bubble(bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, dialog=dialogue, emotion='normal'))
 
 
 
458
  return bubbles
459
 
460
  def _generate_pages(self, bubbles):
461
  try:
462
  from backend.fixed_12_pages_800x1080 import generate_12_pages_800x1080
463
+ return generate_12_pages_800x1080(sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')]), bubbles)
 
464
  except ImportError:
465
+ pages, frame_files = [], sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
466
+ num_pages = (len(frame_files) + 3) // 4
 
 
 
467
  for i in range(num_pages):
468
+ start, end = i*4, (i+1)*4
469
+ page_panels = [panel(image=f) for f in frame_files[start:end]]
470
+ page_bubbles = bubbles[start:end]
471
+ if page_panels: pages.append(Page(panels=page_panels, bubbles=page_bubbles))
 
 
 
 
 
 
472
  return pages
473
 
474
  def _save_results(self, pages):
 
476
  os.makedirs(self.output_dir, exist_ok=True)
477
  pages_data = []
478
  for page in pages:
479
+ # Ensure __dict__ is called on objects, not already-dicts from fallback
480
+ panels = [p.__dict__ if hasattr(p, '__dict__') else p for p in page.panels]
481
+ bubbles = [b.__dict__ if hasattr(b, '__dict__') else b for b in page.bubbles]
482
+ pages_data.append({'panels': panels, 'bubbles': bubbles})
483
+
484
  with open(os.path.join(self.output_dir, 'pages.json'), 'w', encoding='utf-8') as f:
485
  json.dump(pages_data, f, indent=2)
486
  self._copy_template_files()
487
  print("✅ Results saved successfully!")
488
  except Exception as e:
489
  print(f"Save results failed: {e}")
 
490
 
491
  def _copy_template_files(self):
 
492
  try:
493
  template_html = '''<!DOCTYPE html>
494
  <html lang="en">
495
  <head>
496
+ <meta charset="UTF-8">
497
+ <title>Comic Editor</title>
498
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
499
+ <style>
500
+ body { margin: 0; padding: 20px; background: #f0f0f0; font-family: Arial, sans-serif; }
501
+ .comic-container { max-width: 1200px; margin: 0 auto; }
502
+ .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; }
503
+ .comic-grid { display: grid; grid-template-columns: 285px 285px; grid-template-rows: 185px 185px; gap: 10px; width: 100%; height: 100%; }
504
+ .page-wrapper { margin: 30px auto; width: 622px; display: flex; flex-direction: column; align-items: center; }
505
+ .page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
506
+ .panel { position: relative; overflow: hidden; width: 100%; height: 100%; box-sizing: border-box; cursor: pointer; border: 1px solid #333; }
507
+ .panel.selected { outline: 3px solid #2196F3; outline-offset: -3px; }
508
+ .panel img { width: 100%; height: 100%; object-fit: cover; object-position: center; transition: transform 0.1s ease-out; }
509
+ .panel img.pannable { cursor: grab; }
510
+ .panel img.panning { cursor: grabbing; }
511
+ .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; }
512
+ .bubble-text { padding: 2px; word-wrap: break-word; }
513
+ .speech-bubble.selected { outline: 2px dashed #4CAF50; }
514
+ .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; }
515
+ .speech-bubble.speech { background: white; border: 2px solid #333; color: #333; border-radius: 15px; }
516
+ .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
517
+ .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%); }
518
+ .speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; }
519
+ .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%; }
520
+ .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; }
521
+ .speech-bubble.speech::after { border-top: 10px solid #333; bottom: -9px; left: 20px; }
522
+ .speech-bubble.idea::after { border-top: 10px solid #FFA500; bottom: -9px; left: 20px; }
523
+ .speech-bubble.thought::after { display: none; }
524
+ .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
525
+ .thought-dot-1 { width: 20px; height: 20px; bottom: -20px; left: 15px; }
526
+ .thought-dot-2 { width: 12px; height: 12px; bottom: -32px; left: 5px; }
527
+ .speech-bubble.flipped.speech::after, .speech-bubble.flipped.idea::after { left: auto; right: 20px; }
528
+ .speech-bubble.flipped.thought .thought-dot-1 { left: auto; right: 15px; }
529
+ .speech-bubble.flipped.thought .thought-dot-2 { left: auto; right: 5px; }
530
+ .speech-bubble.flipped-vertical.speech::after, .speech-bubble.flipped-vertical.idea::after { bottom: auto; top: -9px; transform: rotate(180deg); }
531
+ .speech-bubble.flipped-vertical.thought .thought-dot-1 { bottom: auto; top: -20px; }
532
+ .speech-bubble.flipped-vertical.thought .thought-dot-2 { bottom: auto; top: -32px; }
533
+ .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; }
534
+ .edit-controls h4 { margin: 0 0 10px 0; color: #26a69a; text-align: center; }
535
+ .edit-controls button, .edit-controls select, .edit-controls input { 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; }
536
+ .edit-controls .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
537
+ .edit-controls .reset-button { background-color: #e74c3c; }
538
+ .edit-controls .action-button { background-color: #4CAF50; }
539
+ .edit-controls .secondary-button { background-color: #f39c12; }
540
+ .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
541
+ .zoom-controls { display: grid; grid-template-columns: auto 1fr; gap: 5px; align-items: center;}
542
+ .timestamp-controls { display: grid; grid-template-columns: 1fr auto; gap: 5px; }
543
+ .timestamp-controls input { color: #333; font-weight: normal; }
544
+ .color-picker-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
545
+ .color-picker-grid div { text-align: center; }
546
+ .color-picker-grid label { font-size: 11px; }
547
+ .color-picker-grid input[type="color"] { height: 25px; padding: 2px; }
548
+ </style>
549
  </head>
550
  <body>
551
+ <div class="comic-container">
552
+ <h1 class="comic-title">🎬 Generated Comic</h1>
553
+ <div id="comic-pages"><div class="loading">Loading comic...</div></div>
 
 
 
 
 
 
 
 
 
 
 
554
  </div>
555
+ <input type="file" id="image-uploader" style="display: none;" accept="image/*">
556
+ <div class="edit-controls">
557
+ <h4>✏️ Interactive Editor</h4>
558
+ <div class="control-group">
559
+ <label>Bubble Tools:</label>
560
+ <select id="bubble-type-select" onchange="changeBubbleType(this.value)" disabled>
561
+ <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>
562
+ </select>
563
+ <div class="color-picker-grid">
564
+ <div>
565
+ <label for="bubble-text-color">Text</label>
566
+ <input type="color" id="bubble-text-color" value="#000000" disabled>
567
+ </div>
568
+ <div>
569
+ <label for="bubble-fill-color">Fill</label>
570
+ <input type="color" id="bubble-fill-color" value="#FFFFFF" disabled>
571
+ </div>
572
+ </div>
573
+ <button onclick="rotateBubbleTail()" class="secondary-button">🔄 Rotate Tail</button>
574
+ <button onclick="addBubbleToPanel()" class="action-button">💬 Add Bubble</button>
575
  </div>
576
+ <div class="control-group">
577
+ <label>Panel Tools (Select Panel):</label>
578
+ <button onclick="replacePanelImage()" class="action-button">🖼️ Replace Image</button>
579
+ <div class="button-grid">
580
+ <button onclick="adjustFrame('backward')" class="secondary-button">⬅️ Prev Frame</button>
581
+ <button onclick="adjustFrame('forward')" class="action-button">Next Frame ➡️</button>
582
+ </div>
583
+ <div class="timestamp-controls">
584
+ <input type="text" id="timestamp-input" placeholder="mm:ss or secs">
585
+ <button onclick="gotoTimestamp()" class="action-button">Go</button>
586
+ </div>
587
  </div>
588
+ <div class="control-group">
589
+ <label>Zoom & Pan (Select Panel):</label>
590
+ <div class="zoom-controls">
591
+ <button onclick="resetPanelTransform()" class="secondary-button" style="padding: 4px 6px;">Reset</button>
592
+ <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled>
593
+ </div>
594
+ </div>
595
+ <div class="control-group">
596
+ <button onclick="exportPagesToPNG()" class="action-button" style="background-color: #2196F3;">🖨️ Export Pages</button>
597
+ <button onclick="clearSavedState()" class="reset-button">🔄 Clear Edits & Reset</button>
598
  </div>
599
  </div>
600
+ <script>
601
+ document.addEventListener('DOMContentLoaded', () => {
602
+ fetch('/output/pages.json')
603
+ .then(res => res.ok ? res.json() : Promise.reject(new Error('Failed to load pages.json')))
604
+ .then(data => { renderComic(data); initializeEditor(); })
605
+ .catch(err => { document.getElementById('comic-pages').innerHTML = `<div class="loading">Error: ${err.message}</div>`; });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
606
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
607
 
608
+ let currentlyEditing = null, draggedBubble = null, offset = {x: 0, y: 0};
609
+ let currentlySelectedBubble = null;
610
+ let currentlySelectedPanel = null;
611
+ let isPanning = false, panStartX, panStartY, panStartTranslateX, panStartTranslateY;
612
+
613
+ function renderComic(data) {
614
+ const container = document.getElementById('comic-pages');
615
+ container.innerHTML = '';
616
+ data.forEach((pageData, pageIndex) => {
617
+ const pageWrapper = document.createElement('div');
618
+ pageWrapper.className = 'page-wrapper';
619
+ const pageTitleEl = document.createElement('h2');
620
+ pageTitleEl.className = 'page-title';
621
+ pageTitleEl.textContent = `Page ${pageIndex + 1}`;
622
+ pageWrapper.appendChild(pageTitleEl);
623
+ const pageDiv = document.createElement('div');
624
+ pageDiv.className = 'comic-page';
625
+ const grid = document.createElement('div');
626
+ grid.className = 'comic-grid';
627
+ pageData.panels.forEach((panelData, panelIndex) => {
628
+ const panelDiv = document.createElement('div');
629
+ panelDiv.className = 'panel';
630
+ const img = document.createElement('img');
631
+ img.src = '/frames/final/' + panelData.image;
632
+ panelDiv.appendChild(img);
633
+ if (pageData.bubbles && pageData.bubbles[panelIndex] && pageData.bubbles[panelIndex].dialog) {
634
+ const bubbleDiv = createBubbleElement({
635
+ id: `initial-${pageIndex}-${panelIndex}`,
636
+ text: pageData.bubbles[panelIndex].dialog || '',
637
+ left: `${pageData.bubbles[panelIndex].bubble_offset_x ?? 50}px`,
638
+ top: `${pageData.bubbles[panelIndex].bubble_offset_y ?? 20}px`,
639
+ });
640
+ panelDiv.appendChild(bubbleDiv);
641
+ }
642
+ grid.appendChild(panelDiv);
643
+ });
644
+ pageDiv.appendChild(grid);
645
+ pageWrapper.appendChild(pageDiv);
646
+ container.appendChild(pageWrapper);
647
+ });
648
+ }
649
+
650
+ function initializeEditor() {
651
+ document.querySelectorAll('.panel').forEach(panel => {
652
+ panel.addEventListener('click', () => selectPanel(panel));
653
+ panel.querySelector('img')?.addEventListener('mousedown', startPan);
654
+ });
655
+ document.querySelectorAll('.speech-bubble').forEach(initializeBubbleEvents);
656
+ document.getElementById('zoom-slider').addEventListener('input', handleZoom);
657
+
658
+ document.getElementById('bubble-text-color').addEventListener('input', (e) => {
659
+ if(currentlySelectedBubble) currentlySelectedBubble.style.color = e.target.value;
660
+ });
661
+ document.getElementById('bubble-fill-color').addEventListener('input', (e) => {
662
+ if(currentlySelectedBubble) currentlySelectedBubble.style.backgroundColor = e.target.value;
663
+ });
664
 
665
+ document.addEventListener('mousemove', e => { if (isPanning) panImage(e); if (draggedBubble) drag(e); });
666
+ document.addEventListener('mouseup', e => { if (isPanning) stopPan(e); if (draggedBubble) stopDrag(e); });
667
+ document.addEventListener('mouseleave', e => { if (isPanning) stopPan(e); if (draggedBubble) stopDrag(e); });
668
+ }
669
+
670
+ function initializeBubbleEvents(bubble) {
671
+ bubble.addEventListener('dblclick', e => { e.stopPropagation(); editBubbleText(bubble); });
672
+ bubble.addEventListener('mousedown', e => { e.stopPropagation(); startDrag(e); });
673
+ bubble.addEventListener('click', e => { e.stopPropagation(); selectBubble(bubble); });
674
+ bubble.addEventListener('wheel', e => {
675
+ e.preventDefault();
676
+ const currentWidth = parseFloat(bubble.style.width) || bubble.offsetWidth;
677
+ const newWidth = currentWidth - (e.deltaY > 0 ? 10 : -10);
678
+ if (newWidth >= 60) bubble.style.width = `${newWidth}px`;
679
+ }, { passive: false });
680
+ }
681
+
682
+ function createBubbleElement(data) {
683
+ const bubbleDiv = document.createElement('div');
684
+ bubbleDiv.dataset.id = data.id;
685
+ const textSpan = document.createElement('span');
686
+ textSpan.className = 'bubble-text';
687
+ textSpan.textContent = data.text;
688
+ bubbleDiv.appendChild(textSpan);
689
+ bubbleDiv.style.left = data.left;
690
+ bubbleDiv.style.top = data.top;
691
+ applyBubbleType(bubbleDiv, 'speech');
692
+ return bubbleDiv;
693
+ }
694
+
695
+ const rgbToHex = (rgb) => {
696
+ if (!rgb || !rgb.startsWith('rgb')) return '#000000';
697
+ let sep = rgb.indexOf(",") > -1 ? "," : " ";
698
+ rgb = rgb.substr(4).split(")")[0].split(sep);
699
+ let r = (+rgb[0]).toString(16), g = (+rgb[1]).toString(16), b = (+rgb[2]).toString(16);
700
+ if (r.length == 1) r = "0" + r;
701
+ if (g.length == 1) g = "0" + g;
702
+ if (b.length == 1) b = "0" + b;
703
+ return "#" + r + g + b;
704
+ }
705
+
706
+ function applyBubbleType(bubble, type) {
707
+ bubble.querySelectorAll('.thought-dot').forEach(el => el.remove());
708
+ let classesToKeep = 'speech-bubble';
709
+ if (bubble.classList.contains('selected')) classesToKeep += ' selected';
710
+ if (bubble.classList.contains('flipped')) classesToKeep += ' flipped';
711
+ if (bubble.classList.contains('flipped-vertical')) classesToKeep += ' flipped-vertical';
712
+ bubble.className = classesToKeep;
713
+ bubble.classList.add(type);
714
+ bubble.dataset.type = type;
715
+ if (type === 'thought') {
716
+ for (let i = 1; i <= 2; i++) {
717
+ const dot = document.createElement('div');
718
+ dot.className = `thought-dot thought-dot-${i}`;
719
+ bubble.appendChild(dot);
720
+ }
721
  }
722
  }
723
+
724
+ function changeBubbleType(type) {
725
+ if (!currentlySelectedBubble) return;
726
+ applyBubbleType(currentlySelectedBubble, type);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
727
  }
 
 
 
 
 
 
 
728
 
729
+ function rotateBubbleTail() {
730
+ if (!currentlySelectedBubble) { alert("Please select a bubble first."); return; }
731
+ const isFlippedH = currentlySelectedBubble.classList.contains('flipped');
732
+ const isFlippedV = currentlySelectedBubble.classList.contains('flipped-vertical');
733
+ if (!isFlippedH && !isFlippedV) { currentlySelectedBubble.classList.add('flipped'); }
734
+ else if (isFlippedH && !isFlippedV) { currentlySelectedBubble.classList.add('flipped-vertical'); }
735
+ else if (isFlippedH && isFlippedV) { currentlySelectedBubble.classList.remove('flipped'); }
736
+ else { currentlySelectedBubble.classList.remove('flipped-vertical'); }
 
 
 
 
 
 
 
737
  }
738
+
739
+ function selectPanel(panel) {
740
+ document.querySelectorAll('.panel.selected').forEach(p => p.classList.remove('selected'));
741
+ panel.classList.add('selected');
742
+ currentlySelectedPanel = panel;
743
+ selectBubble(null);
744
+
745
+ const img = currentlySelectedPanel.querySelector('img');
746
+ document.getElementById('zoom-slider').value = img.dataset.zoom || 100;
747
+ document.getElementById('zoom-slider').disabled = false;
748
+
749
+ document.getElementById('bubble-type-select').disabled = true;
750
+ document.getElementById('bubble-text-color').disabled = true;
751
+ document.getElementById('bubble-fill-color').disabled = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
752
  }
753
+
754
+ function selectBubble(bubble) {
755
+ if (currentlySelectedBubble) currentlySelectedBubble.classList.remove('selected');
756
+ currentlySelectedBubble = bubble;
757
+ const textColorPicker = document.getElementById('bubble-text-color');
758
+ const fillColorPicker = document.getElementById('bubble-fill-color');
759
+ const typeSelect = document.getElementById('bubble-type-select');
760
+
761
+ if (currentlySelectedBubble) {
762
+ currentlySelectedBubble.classList.add('selected');
763
+ if (currentlySelectedPanel) currentlySelectedPanel.classList.remove('selected');
764
+ currentlySelectedPanel = null;
765
+
766
+ const styles = window.getComputedStyle(currentlySelectedBubble);
767
+ textColorPicker.value = rgbToHex(styles.color);
768
+ fillColorPicker.value = rgbToHex(styles.backgroundColor);
769
+
770
+ typeSelect.value = currentlySelectedBubble.dataset.type || 'speech';
771
+ document.getElementById('zoom-slider').disabled = true;
772
+ textColorPicker.disabled = false;
773
+ fillColorPicker.disabled = false;
774
+ typeSelect.disabled = false;
775
  }
776
  }
777
+
778
+ function editBubbleText(bubble) {
779
+ if (currentlyEditing) return;
780
+ currentlyEditing = bubble;
781
+ const textSpan = bubble.querySelector('.bubble-text');
782
+ const textarea = document.createElement('textarea');
783
+ textarea.value = textSpan.textContent;
784
+ bubble.appendChild(textarea);
785
+ textSpan.style.display = 'none';
786
+ textarea.focus();
787
+ const finishEditing = () => {
788
+ textSpan.textContent = textarea.value;
789
+ bubble.removeChild(textarea);
790
+ textSpan.style.display = '';
791
+ currentlyEditing = null;
792
+ };
793
+ textarea.addEventListener('blur', finishEditing, { once: true });
794
+ textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); }});
795
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
796
 
797
+ function startDrag(e) {
798
+ const bubble = e.target.closest('.speech-bubble');
799
+ if (!bubble || currentlyEditing) return;
800
+ draggedBubble = bubble;
801
+ selectBubble(bubble);
802
+ const rect = bubble.getBoundingClientRect();
803
+ offset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
804
+ }
805
 
806
+ function drag(e) {
807
+ if (!draggedBubble) return;
808
+ const parentRect = draggedBubble.parentElement.getBoundingClientRect();
809
+ draggedBubble.style.left = `${e.clientX - parentRect.left - offset.x}px`;
810
+ draggedBubble.style.top = `${e.clientY - parentRect.top - offset.y}px`;
811
+ }
 
 
 
 
 
 
 
 
 
 
812
 
813
+ function stopDrag() { draggedBubble = null; }
 
 
 
 
 
 
 
 
 
 
 
 
814
 
815
+ function clearSavedState() {
816
+ if (confirm("Reset all edits?")) {
817
+ localStorage.removeItem('comicEditorState');
818
+ window.location.reload();
819
+ }
820
+ }
 
 
 
821
 
822
+ async function exportPagesToPNG() {
823
+ const pages = document.querySelectorAll('.comic-page');
824
+ if (pages.length === 0) return alert("No pages found.");
825
+ alert(`Starting export of ${pages.length} page(s).`);
826
+ for (let i = 0; i < pages.length; i++) {
827
+ try {
828
+ const canvas = await html2canvas(pages[i], { scale: 2 });
829
+ const link = document.createElement('a');
830
+ link.download = `comic-page-${i + 1}.png`;
831
+ link.href = canvas.toDataURL('image/png');
832
+ link.click();
833
+ } catch (err) { alert(`Failed to export page ${i + 1}.`); }
834
+ }
835
+ }
836
+
837
+ function replacePanelImage() {
838
+ if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
839
  const img = currentlySelectedPanel.querySelector('img');
840
+ const uploader = document.getElementById('image-uploader');
841
+ uploader.onchange = (event) => {
842
+ const file = event.target.files[0];
843
+ if (!file) return;
844
+ const formData = new FormData();
845
+ formData.append('image', file);
846
+ img.style.opacity = '0.5';
847
+ fetch('/replace_panel', { method: 'POST', body: formData })
848
+ .then(response => response.json())
849
+ .then(data => {
850
+ if (data.success) {
851
+ img.src = `/frames/final/${data.new_filename}?t=${new Date().getTime()}`;
852
+ } else { alert('Error replacing image: ' + data.error); }
853
+ img.style.opacity = '1';
854
+ })
855
+ .catch(() => {
856
+ alert('An error occurred during the upload.');
857
+ img.style.opacity = '1';
858
+ });
859
+ uploader.value = '';
860
+ };
861
+ uploader.click();
862
  }
863
+
864
+ function adjustFrame(direction) {
865
+ if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
866
+ const img = currentlySelectedPanel.querySelector('img');
867
+ let filename = img.src.substring(img.src.lastIndexOf('/') + 1).split('?')[0];
868
+ img.style.opacity = '0.5';
869
+ fetch('/regenerate_frame', {
870
+ method: 'POST',
871
+ headers: { 'Content-Type': 'application/json' },
872
+ body: JSON.stringify({ filename, direction })
873
+ })
874
+ .then(res => res.json())
875
+ .then(data => {
876
+ if (data.success) {
877
+ img.src = `/frames/final/${filename}?t=${new Date().getTime()}`;
878
+ } else { alert('Error: ' + data.message); }
879
+ img.style.opacity = '1';
880
+ })
881
+ .catch(() => {
882
+ alert('An error occurred.');
883
+ img.style.opacity = '1';
884
+ });
885
  }
886
 
887
+ function updateImageTransform(img) {
888
+ const zoom = (img.dataset.zoom || 100) / 100;
889
+ const x = img.dataset.translateX || 0;
890
+ const y = img.dataset.translateY || 0;
891
+ img.style.transform = `translateX(${x}px) translateY(${y}px) scale(${zoom})`;
892
+ img.classList.toggle('pannable', zoom > 1);
893
+ }
894
 
895
+ function handleZoom(event) {
896
+ if (!currentlySelectedPanel) return;
897
+ const img = currentlySelectedPanel.querySelector('img');
898
+ img.dataset.zoom = event.target.value;
899
+ updateImageTransform(img);
 
 
 
 
 
900
  }
901
+
902
+ function resetPanelTransform() {
903
+ if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
904
+ const img = currentlySelectedPanel.querySelector('img');
905
+ img.dataset.zoom = 100;
906
+ img.dataset.translateX = 0;
907
+ img.dataset.translateY = 0;
908
+ document.getElementById('zoom-slider').value = 100;
909
+ updateImageTransform(img);
910
  }
911
 
912
+ function startPan(event) {
913
+ if (event.button !== 0) return;
914
+ const img = event.target;
915
+ if (parseFloat(img.dataset.zoom || 100) <= 100) return;
916
+ event.preventDefault();
917
+ isPanning = true;
918
+ img.classList.add('panning');
919
+ panStartX = event.clientX;
920
+ panStartY = event.clientY;
921
+ panStartTranslateX = parseFloat(img.dataset.translateX || 0);
922
+ panStartTranslateY = parseFloat(img.dataset.translateY || 0);
 
 
 
 
 
 
 
 
923
  }
924
 
925
+ function panImage(event) {
926
+ if (!isPanning || !currentlySelectedPanel) return;
927
+ const img = currentlySelectedPanel.querySelector('img');
928
+ img.dataset.translateX = panStartTranslateX + (event.clientX - panStartX);
929
+ img.dataset.translateY = panStartTranslateY + (event.clientY - panStartY);
930
+ updateImageTransform(img);
931
+ }
932
 
933
+ function stopPan() {
934
+ if (!isPanning) return;
935
+ isPanning = false;
936
+ currentlySelectedPanel?.querySelector('img')?.classList.remove('panning');
937
+ }
938
+
939
+ function addBubbleToPanel() {
940
+ if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
941
+ const newBubble = createBubbleElement({
942
+ id: `new-bubble-${Date.now()}`,
943
+ text: 'New Text...',
944
+ left: '10%',
945
+ top: '10%'
946
+ });
947
+ currentlySelectedPanel.appendChild(newBubble);
948
+ initializeBubbleEvents(newBubble);
949
+ selectBubble(newBubble);
950
+ editBubbleText(newBubble);
951
+ }
952
+
953
+ function gotoTimestamp() {
954
+ if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
955
+ const input = document.getElementById('timestamp-input');
956
+ const timeStr = input.value.trim();
957
+ if (!timeStr) return;
958
+ let parsedSeconds = 0;
959
+ if (timeStr.includes(':')) {
960
+ const parts = timeStr.split(':');
961
+ parsedSeconds = parseInt(parts[0], 10) * 60 + parseFloat(parts[1]);
962
  } else {
963
+ parsedSeconds = parseFloat(timeStr);
964
  }
965
+ if (isNaN(parsedSeconds)) { alert("Invalid time format."); return; }
966
+ const img = currentlySelectedPanel.querySelector('img');
967
+ let filename = img.src.substring(img.src.lastIndexOf('/') + 1).split('?')[0];
968
+ img.style.opacity = '0.5';
969
+ fetch('/goto_timestamp', {
970
+ method: 'POST',
971
+ headers: { 'Content-Type': 'application/json' },
972
+ body: JSON.stringify({ filename, timestamp: parsedSeconds })
973
+ })
974
+ .then(res => res.json())
975
+ .then(data => {
976
+ if (data.success) {
977
+ img.src = `/frames/final/${filename}?t=${new Date().getTime()}`;
978
+ input.value = '';
979
+ resetPanelTransform();
980
+ } else { alert('Error: ' + data.message); }
981
+ img.style.opacity = '1';
982
+ })
983
+ .catch(() => {
984
+ alert('An error occurred.');
985
+ img.style.opacity = '1';
986
+ });
987
+ }
988
+ </script>
989
  </body>
990
  </html>'''
991
  with open(os.path.join(self.output_dir, 'page.html'), 'w', encoding='utf-8') as f:
 
999
 
1000
  @app.route('/')
1001
  def index():
1002
+ return INDEX_HTML
1003
 
1004
  @app.route('/uploader', methods=['POST'])
1005
  def upload_file():
1006
  try:
1007
+ if 'file' not in request.files or not request.files['file'].filename:
1008
+ return jsonify({'success': False, 'message': 'No file selected'}), 400
1009
  f = request.files['file']
1010
+ if os.path.exists(comic_generator.video_path): os.remove(comic_generator.video_path)
 
1011
  f.save(comic_generator.video_path)
1012
+ threading.Thread(target=comic_generator.generate_comic).start()
1013
+ return jsonify({'success': True, 'message': 'Generation started.'})
 
 
 
1014
  except Exception as e:
1015
  traceback.print_exc()
1016
+ return jsonify({'success': False, 'message': str(e)}), 500
1017
+
1018
+ @app.route('/status')
1019
+ def status():
1020
+ status_file = os.path.join('output', 'status.json')
1021
+ if os.path.exists(status_file):
1022
+ return send_from_directory('output', 'status.json')
1023
+ return jsonify({'message': 'Initializing...', 'progress': 0})
1024
 
1025
  @app.route('/handle_link', methods=['POST'])
1026
  def handle_link():
1027
+ # This route is disabled in the UI but remains functional
1028
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
 
1029
 
1030
  @app.route('/replace_panel', methods=['POST'])
1031
  def replace_panel():
1032
  try:
1033
+ if 'image' not in request.files: return jsonify({'success': False, 'error': 'No image provided.'})
1034
  file = request.files['image']
1035
+ filename = f"replaced_panel_{int(time.time() * 1000)}.png"
1036
+ file.save(os.path.join(comic_generator.frames_dir, filename))
 
 
 
1037
  return jsonify({'success': True, 'new_filename': filename})
1038
  except Exception as e:
 
1039
  return jsonify({'success': False, 'error': str(e)})
1040
 
1041
  @app.route('/regenerate_frame', methods=['POST'])
1042
  def regenerate_frame_route():
1043
  try:
1044
  data = request.get_json()
1045
+ result = comic_generator.regenerate_frame(data['filename'], data['direction'])
 
 
 
 
 
1046
  return jsonify(result)
1047
  except Exception as e:
 
1048
  return jsonify({'success': False, 'message': str(e)})
1049
 
1050
  @app.route('/goto_timestamp', methods=['POST'])
1051
  def goto_timestamp_route():
1052
  try:
1053
  data = request.get_json()
1054
+ result = comic_generator.get_frame_at_timestamp(data['filename'], float(data['timestamp']))
 
 
 
 
 
1055
  return jsonify(result)
1056
  except Exception as e:
 
1057
  return jsonify({'success': False, 'message': str(e)})
1058
 
1059
  @app.route('/comic')