jhh6576 commited on
Commit
9a4ac67
·
verified ·
1 Parent(s): adb57a2

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +899 -37
app_enhanced.py CHANGED
@@ -266,6 +266,11 @@ INDEX_HTML = '''
266
  clearInterval(statusInterval);
267
  statusText.textContent = "Comic generation complete!";
268
  document.getElementById('view-comic-link').style.display = 'inline-block';
 
 
 
 
 
269
  }
270
  } catch (error) {
271
  console.error("Error fetching status:", error);
@@ -284,7 +289,6 @@ INDEX_HTML = '''
284
  return;
285
  }
286
 
287
- // Show progress bar and hide form
288
  document.getElementById('progress-container').style.display = 'block';
289
  this.style.display = 'none';
290
  document.querySelector('.separator').style.display = 'none';
@@ -294,7 +298,6 @@ INDEX_HTML = '''
294
  formData.append('file', fileInput.files[0]);
295
 
296
  try {
297
- // Start the upload/generation process
298
  const response = await fetch('/uploader', {
299
  method: 'POST',
300
  body: formData
@@ -304,7 +307,6 @@ INDEX_HTML = '''
304
  throw new Error('Upload failed!');
305
  }
306
 
307
- // If upload is successful, start polling for status
308
  statusInterval = setInterval(checkStatus, 1500);
309
 
310
  } catch (error) {
@@ -321,7 +323,6 @@ os.makedirs('video', exist_ok=True)
321
  os.makedirs('frames/final', exist_ok=True)
322
  os.makedirs('output', exist_ok=True)
323
 
324
- # --- NEW: Helper function to write status updates to a file ---
325
  def update_status(message, progress):
326
  """Writes the current progress to a JSON file."""
327
  status_file = os.path.join('output', 'status.json')
@@ -338,27 +339,230 @@ class EnhancedComicGenerator:
338
  self.video_fps = None
339
 
340
  def cleanup_generated(self):
341
- # (This function is complete and unchanged)
342
- pass
 
 
 
 
 
 
343
 
344
  def detect_eye_state(self, frame_path):
345
- # (This function is complete and unchanged)
346
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
 
348
  def regenerate_frame(self, frame_filename, direction):
349
- # (This function is complete and unchanged)
350
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
 
352
  def get_frame_at_timestamp(self, frame_filename, timestamp_seconds):
353
- # (This function is complete and unchanged)
354
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
 
356
  def generate_keyframes_from_moments(self, video_path, key_moments, max_frames=48):
357
- # (This function is complete and unchanged)
358
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
 
360
  def generate_comic(self, smart_mode=False, emotion_match=False):
361
- """Main comic generation pipeline with progress updates."""
362
  start_time = time.time()
363
  try:
364
  update_status("Cleaning up old files...", 0)
@@ -413,40 +617,650 @@ class EnhancedComicGenerator:
413
  except Exception as e:
414
  print(f"❌ Comic generation failed: {e}")
415
  traceback.print_exc()
416
- update_status(f"Error: {e}", -1) # Use -1 to indicate an error state
417
  return False
418
 
419
  def _enhance_all_images(self, single_image_path=None):
420
- # (This function is complete and unchanged)
421
- pass
 
 
 
 
 
 
 
422
 
423
  def _enhance_quality_colors(self, single_image_path=None):
424
- # (This function is complete and unchanged)
425
- pass
 
 
 
 
 
 
426
 
427
  def _create_ai_bubbles_from_moments(self, black_x, black_y):
428
- # (This function is complete and unchanged)
429
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
 
431
  def _generate_pages(self, bubbles):
432
- # (This function is complete and unchanged)
433
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
 
435
  def _save_results(self, pages):
436
- # (This function is complete and unchanged)
437
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
 
439
  def _copy_template_files(self):
440
  """This function contains the complete HTML, CSS, and JavaScript for the interactive editor."""
441
- # (This function is complete and unchanged)
442
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
 
444
  # --- Flask Routes ---
445
  comic_generator = EnhancedComicGenerator()
446
 
447
  @app.route('/')
448
  def index():
449
- # Serve the new, styled HTML directly
450
  return INDEX_HTML
451
 
452
  @app.route('/uploader', methods=['POST'])
@@ -460,7 +1274,6 @@ def upload_file():
460
  os.remove(comic_generator.video_path)
461
  f.save(comic_generator.video_path)
462
 
463
- # Start the generation in a background thread
464
  thread = threading.Thread(target=comic_generator.generate_comic)
465
  thread.start()
466
 
@@ -472,17 +1285,14 @@ def upload_file():
472
 
473
  @app.route('/status')
474
  def status():
475
- """Endpoint for the frontend to poll for progress updates."""
476
  status_file = os.path.join('output', 'status.json')
477
  if os.path.exists(status_file):
478
  return send_from_directory('output', 'status.json')
479
  else:
480
- # If the file doesn't exist yet, it means the process is initializing
481
  return jsonify({'message': 'Initializing...', 'progress': 0})
482
 
483
  @app.route('/handle_link', methods=['POST'])
484
  def handle_link():
485
- # This function is now disabled on the frontend but kept for future use
486
  try:
487
  link = request.form.get('link', '')
488
  if not link: return "❌ No link provided"
@@ -491,18 +1301,70 @@ def handle_link():
491
  with yt_dlp.YoutubeDL(ydl_opts) as ydl:
492
  ydl.download([link])
493
 
494
- # Start generation in a background thread
495
  thread = threading.Thread(target=comic_generator.generate_comic)
496
  thread.start()
497
 
498
- # This response is for API calls, the main UI will use polling
499
  return "Generation started. Please poll /status for updates."
500
  except Exception as e:
501
  traceback.print_exc()
502
  return f"❌ An unexpected error occurred: {str(e)}"
503
 
504
- # (All other routes: /replace_panel, /regenerate_frame, /goto_timestamp, /comic, etc. are complete and unchanged)
505
- # ...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
 
507
  if __name__ == '__main__':
508
  port = int(os.getenv("PORT", 7860))
 
266
  clearInterval(statusInterval);
267
  statusText.textContent = "Comic generation complete!";
268
  document.getElementById('view-comic-link').style.display = 'inline-block';
269
+ } else if (data.progress < 0) {
270
+ // Handle error state
271
+ clearInterval(statusInterval);
272
+ statusText.textContent = "An error occurred during generation. Please check the logs.";
273
+ progressBar.style.backgroundColor = '#e74c3c'; // Red for error
274
  }
275
  } catch (error) {
276
  console.error("Error fetching status:", error);
 
289
  return;
290
  }
291
 
 
292
  document.getElementById('progress-container').style.display = 'block';
293
  this.style.display = 'none';
294
  document.querySelector('.separator').style.display = 'none';
 
298
  formData.append('file', fileInput.files[0]);
299
 
300
  try {
 
301
  const response = await fetch('/uploader', {
302
  method: 'POST',
303
  body: formData
 
307
  throw new Error('Upload failed!');
308
  }
309
 
 
310
  statusInterval = setInterval(checkStatus, 1500);
311
 
312
  } catch (error) {
 
323
  os.makedirs('frames/final', exist_ok=True)
324
  os.makedirs('output', exist_ok=True)
325
 
 
326
  def update_status(message, progress):
327
  """Writes the current progress to a JSON file."""
328
  status_file = os.path.join('output', 'status.json')
 
339
  self.video_fps = None
340
 
341
  def cleanup_generated(self):
342
+ print("🧹 Performing full cleanup of previous run...")
343
+ if os.path.isdir(self.frames_dir): shutil.rmtree(self.frames_dir)
344
+ if os.path.isdir(self.output_dir): shutil.rmtree(self.output_dir)
345
+ if os.path.isdir('temp'): shutil.rmtree('temp')
346
+ if os.path.exists('test1.srt'): os.remove('test1.srt')
347
+ os.makedirs(self.frames_dir, exist_ok=True)
348
+ os.makedirs(self.output_dir, exist_ok=True)
349
+ print("✅ Cleanup complete.")
350
 
351
  def detect_eye_state(self, frame_path):
352
+ try:
353
+ img = cv2.imread(frame_path)
354
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
355
+ face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
356
+ eye_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_eye.xml')
357
+ faces = face_cascade.detectMultiScale(gray, 1.3, 5)
358
+ for (x, y, w, h) in faces:
359
+ roi_gray = gray[y:y+h, x:x+w]
360
+ eyes = eye_cascade.detectMultiScale(roi_gray)
361
+ if len(eyes) == 0:
362
+ return 'closed'
363
+ elif len(eyes) == 1:
364
+ return 'semi-closed'
365
+ for (ex, ey, ew, eh) in eyes:
366
+ eye_region = roi_gray[ey:ey+eh, ex:ex+ew]
367
+ vert_var = np.var(eye_region, axis=0).mean()
368
+ if vert_var < 500:
369
+ return 'semi-closed'
370
+ return 'open'
371
+ except:
372
+ return 'open'
373
 
374
  def regenerate_frame(self, frame_filename, direction):
375
+ try:
376
+ if not self.video_fps:
377
+ return {"success": False, "message": "Video FPS not found. Please regenerate the comic first."}
378
+
379
+ metadata_path = 'frames/frame_metadata.json'
380
+ if not os.path.exists(metadata_path):
381
+ return {"success": False, "message": "Frame metadata missing."}
382
+
383
+ with open(metadata_path, 'r') as f:
384
+ frame_to_time = json.load(f)
385
+
386
+ if frame_filename not in frame_to_time:
387
+ return {"success": False, "message": "Panel not linked to original video."}
388
+
389
+ if isinstance(frame_to_time[frame_filename], dict):
390
+ current_time = frame_to_time[frame_filename]['time']
391
+ else:
392
+ current_time = frame_to_time[frame_filename]
393
+
394
+ frame_duration = 1.0 / self.video_fps
395
+
396
+ if direction == 'forward':
397
+ target_time = current_time + frame_duration
398
+ elif direction == 'backward':
399
+ target_time = current_time - frame_duration
400
+ else:
401
+ return {"success": False, "message": "Invalid direction specified."}
402
+
403
+ target_time = max(0, target_time)
404
+
405
+ cap = cv2.VideoCapture(self.video_path)
406
+ if not cap.isOpened():
407
+ return {"success": False, "message": "Cannot open video."}
408
+
409
+ cap.set(cv2.CAP_PROP_POS_MSEC, target_time * 1000)
410
+ ret, frame = cap.read()
411
+ cap.release()
412
+
413
+ if not ret or frame is None:
414
+ return {"success": False, "message": f"No frame available at {target_time:.2f}s."}
415
+
416
+ new_path = os.path.join(self.frames_dir, frame_filename)
417
+ cv2.imwrite(new_path, frame)
418
+
419
+ if isinstance(frame_to_time[frame_filename], dict):
420
+ frame_to_time[frame_filename]['time'] = target_time
421
+ else:
422
+ frame_to_time[frame_filename] = target_time
423
+
424
+ with open(metadata_path, 'w') as f:
425
+ json.dump(frame_to_time, f, indent=2)
426
+
427
+ message = f"Adjusted {direction} to {target_time:.3f}s"
428
+ print(f"✅ {message}")
429
+
430
+ return {
431
+ "success": True,
432
+ "message": message,
433
+ "new_filename": frame_filename
434
+ }
435
+
436
+ except Exception as e:
437
+ traceback.print_exc()
438
+ return {"success": False, "message": str(e)}
439
 
440
  def get_frame_at_timestamp(self, frame_filename, timestamp_seconds):
441
+ try:
442
+ metadata_path = 'frames/frame_metadata.json'
443
+ if not os.path.exists(metadata_path):
444
+ return {"success": False, "message": "Frame metadata missing."}
445
+
446
+ cap = cv2.VideoCapture(self.video_path)
447
+ if not cap.isOpened():
448
+ return {"success": False, "message": "Cannot open video."}
449
+
450
+ fps = cap.get(cv2.CAP_PROP_FPS)
451
+ if fps == 0: fps = 25
452
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
453
+ duration = total_frames / fps
454
+ if timestamp_seconds < 0 or timestamp_seconds > duration:
455
+ cap.release()
456
+ return {"success": False, "message": f"Timestamp must be between 0 and {duration:.2f} seconds."}
457
+
458
+ cap.set(cv2.CAP_PROP_POS_MSEC, timestamp_seconds * 1000)
459
+ ret, frame = cap.read()
460
+ cap.release()
461
+
462
+ if not ret or frame is None:
463
+ return {"success": False, "message": f"Could not retrieve frame at {timestamp_seconds:.2f}s."}
464
+
465
+ new_path = os.path.join(self.frames_dir, frame_filename)
466
+ cv2.imwrite(new_path, frame)
467
+
468
+ with open(metadata_path, 'r') as f:
469
+ frame_to_time = json.load(f)
470
+
471
+ if frame_filename in frame_to_time:
472
+ if isinstance(frame_to_time[frame_filename], dict):
473
+ frame_to_time[frame_filename]['time'] = timestamp_seconds
474
+ else:
475
+ frame_to_time[frame_filename] = timestamp_seconds
476
+
477
+ with open(metadata_path, 'w') as f:
478
+ json.dump(frame_to_time, f, indent=2)
479
+
480
+ message = f"Jumped to timestamp {timestamp_seconds:.3f}s"
481
+ print(f"✅ {message}")
482
+
483
+ return { "success": True, "message": message }
484
+
485
+ except Exception as e:
486
+ traceback.print_exc()
487
+ return {"success": False, "message": str(e)}
488
 
489
  def generate_keyframes_from_moments(self, video_path, key_moments, max_frames=48):
490
+ try:
491
+ cap = cv2.VideoCapture(video_path)
492
+ if not cap.isOpened():
493
+ print("❌ Cannot open video for keyframe extraction")
494
+ return False
495
+
496
+ fps = self.video_fps
497
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
498
+ duration = total_frames / fps
499
+
500
+ key_moments.sort(key=lambda x: x['start'])
501
+
502
+ if len(key_moments) > max_frames:
503
+ first_count = min(5, max_frames // 4)
504
+ last_count = min(5, max_frames // 4)
505
+ middle_count = max_frames - first_count - last_count
506
+
507
+ if middle_count > 0 and len(key_moments) > (first_count + last_count):
508
+ first_moments = key_moments[:first_count]
509
+ last_moments = key_moments[-last_count:]
510
+ middle_moments = key_moments[first_count:-last_count]
511
+
512
+ if len(middle_moments) > middle_count:
513
+ step = len(middle_moments) / middle_count
514
+ middle_sampled = [middle_moments[int(i * step)] for i in range(middle_count)]
515
+ else:
516
+ middle_sampled = middle_moments
517
+
518
+ key_moments = first_moments + middle_sampled + last_moments
519
+ else:
520
+ step = len(key_moments) / max_frames
521
+ key_moments = [key_moments[int(i * step)] for i in range(max_frames)]
522
+
523
+ frame_metadata = {}
524
+ frame_count = 0
525
+
526
+ for i, moment in enumerate(key_moments):
527
+ update_status(f"Extracting frame {i+1}/{len(key_moments)}...", 25 + int(20 * (i / len(key_moments))))
528
+
529
+ frame_time = (moment['start'] + moment['end']) / 2
530
+
531
+ if frame_time > duration:
532
+ continue
533
+
534
+ frame_number = int(frame_time * fps)
535
+
536
+ cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
537
+ ret, frame = cap.read()
538
+
539
+ if ret:
540
+ frame_filename = f"frame_{frame_count:04d}.png"
541
+ frame_path = os.path.join(self.frames_dir, frame_filename)
542
+ cv2.imwrite(frame_path, frame)
543
+
544
+ frame_metadata[frame_filename] = {
545
+ 'time': frame_time,
546
+ 'dialogue': moment['text'],
547
+ 'start': moment['start'],
548
+ 'end': moment['end']
549
+ }
550
+ frame_count += 1
551
+
552
+ cap.release()
553
+
554
+ with open(os.path.join('frames', 'frame_metadata.json'), 'w') as f:
555
+ json.dump(frame_metadata, f, indent=2)
556
+
557
+ print(f"✅ Extracted {frame_count} keyframes from video")
558
+ return True
559
+
560
+ except Exception as e:
561
+ print(f"❌ Error extracting keyframes: {e}")
562
+ traceback.print_exc()
563
+ return False
564
 
565
  def generate_comic(self, smart_mode=False, emotion_match=False):
 
566
  start_time = time.time()
567
  try:
568
  update_status("Cleaning up old files...", 0)
 
617
  except Exception as e:
618
  print(f"❌ Comic generation failed: {e}")
619
  traceback.print_exc()
620
+ update_status(f"Error: {e}", -1)
621
  return False
622
 
623
  def _enhance_all_images(self, single_image_path=None):
624
+ target_dir = self.frames_dir
625
+ if single_image_path:
626
+ target_dir = os.path.dirname(single_image_path)
627
+ if not os.path.exists(target_dir): return
628
+ try:
629
+ enhancer = SimpleColorEnhancer()
630
+ enhancer.enhance_batch(target_dir)
631
+ except Exception as e:
632
+ print(f"❌ Simple enhancement failed during execution: {e}")
633
 
634
  def _enhance_quality_colors(self, single_image_path=None):
635
+ target_dir = self.frames_dir
636
+ if single_image_path:
637
+ target_dir = os.path.dirname(single_image_path)
638
+ try:
639
+ enhancer = QualityColorEnhancer()
640
+ enhancer.batch_enhance(target_dir)
641
+ except Exception as e:
642
+ print(f"⚠️ Quality enhancement failed during execution: {e}")
643
 
644
  def _create_ai_bubbles_from_moments(self, black_x, black_y):
645
+ bubbles = []
646
+ frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
647
+
648
+ metadata_path = 'frames/frame_metadata.json'
649
+ if not os.path.exists(metadata_path):
650
+ print("⚠️ Frame metadata not found, creating empty bubbles.")
651
+ return [bubble(dialog="") for _ in frame_files]
652
+
653
+ with open(metadata_path, 'r') as f:
654
+ frame_metadata = json.load(f)
655
+
656
+ for i, frame_file in enumerate(frame_files):
657
+ update_status(f"Placing bubble {i+1}/{len(frame_files)}...", 75 + int(15 * (i / len(frame_files))))
658
+ frame_path = os.path.join(self.frames_dir, frame_file)
659
+ dialogue = ""
660
+
661
+ if frame_file in frame_metadata:
662
+ dialogue = frame_metadata[frame_file]['dialogue']
663
+
664
+ try:
665
+ lip_x, lip_y = -1, -1
666
+ faces = face_detector.detect_faces(frame_path)
667
+ if faces:
668
+ lip_x, lip_y = face_detector.get_lip_position(frame_path, faces[0])
669
+
670
+ bubble_x, bubble_y = ai_bubble_placer.place_bubble_ai(frame_path, (lip_x, lip_y))
671
+ bubbles.append(bubble(
672
+ bubble_offset_x=bubble_x, bubble_offset_y=bubble_y,
673
+ lip_x=lip_x, lip_y=lip_y, dialog=dialogue, emotion='normal'
674
+ ))
675
+ except Exception as e:
676
+ print(f"-> Could not place bubble for {frame_file} due to error: {e}. Using default.")
677
+ bubbles.append(bubble(
678
+ bubble_offset_x=50, bubble_offset_y=20,
679
+ lip_x=-1, lip_y=-1, dialog=dialogue, emotion='normal'
680
+ ))
681
+ return bubbles
682
 
683
  def _generate_pages(self, bubbles):
684
+ try:
685
+ from backend.fixed_12_pages_800x1080 import generate_12_pages_800x1080
686
+ frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
687
+ return generate_12_pages_800x1080(frame_files, bubbles)
688
+ except ImportError:
689
+ pages = []
690
+ frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
691
+ frames_per_page = 4
692
+ num_pages = (len(frame_files) + frames_per_page - 1) // frames_per_page
693
+ frame_counter = 0
694
+ for i in range(num_pages):
695
+ page_panels, page_bubbles = [], []
696
+ for _ in range(frames_per_page):
697
+ if frame_counter < len(frame_files):
698
+ page_panels.append(panel(
699
+ image=frame_files[frame_counter], row_span=6, col_span=6
700
+ ))
701
+ page_bubbles.append(bubbles[frame_counter] if frame_counter < len(bubbles) else bubble(dialog=""))
702
+ frame_counter += 1
703
+ if page_panels:
704
+ pages.append(Page(panels=page_panels, bubbles=page_bubbles))
705
+ return pages
706
 
707
  def _save_results(self, pages):
708
+ try:
709
+ os.makedirs(self.output_dir, exist_ok=True)
710
+ pages_data = []
711
+ for page in pages:
712
+ page_dict = {
713
+ 'panels': [p if isinstance(p, dict) else p.__dict__ for p in page.panels],
714
+ 'bubbles': [b if isinstance(b, dict) else b.__dict__ for b in page.bubbles]
715
+ }
716
+ pages_data.append(page_dict)
717
+ with open(os.path.join(self.output_dir, 'pages.json'), 'w', encoding='utf-8') as f:
718
+ json.dump(pages_data, f, indent=2)
719
+ self._copy_template_files()
720
+ print("✅ Results saved successfully!")
721
+ except Exception as e:
722
+ print(f"Save results failed: {e}")
723
+ traceback.print_exc()
724
 
725
  def _copy_template_files(self):
726
  """This function contains the complete HTML, CSS, and JavaScript for the interactive editor."""
727
+ try:
728
+ template_html = '''<!DOCTYPE html>
729
+ <html lang="en">
730
+ <head>
731
+ <meta charset="UTF-8">
732
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
733
+ <title>Generated Comic - Interactive Editor</title>
734
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
735
+ <style>
736
+ body { margin: 0; padding: 20px; background: #f0f0f0; font-family: Arial, sans-serif; }
737
+ .comic-container { max-width: 1200px; margin: 0 auto; }
738
+ .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; }
739
+ .comic-grid { display: grid; grid-template-columns: 285px 285px; grid-template-rows: 185px 185px; gap: 10px; width: 100%; height: 100%; }
740
+ .page-wrapper { margin: 30px auto; width: 622px; display: flex; flex-direction: column; align-items: center; }
741
+ .page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
742
+ .panel { position: relative; overflow: hidden; width: 100%; height: 100%; box-sizing: border-box; cursor: pointer; border: 1px solid #333; }
743
+ .panel.selected { outline: 3px solid #2196F3; outline-offset: -3px; }
744
+ .panel img { width: 100%; height: 100%; object-fit: cover; object-position: center; transition: transform 0.1s ease-out; }
745
+ .panel img.pannable { cursor: grab; }
746
+ .panel img.panning { cursor: grabbing; }
747
+ .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; }
748
+ .bubble-text { padding: 2px; word-wrap: break-word; }
749
+ .speech-bubble.selected { outline: 2px dashed #4CAF50; }
750
+ .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; }
751
+ .speech-bubble.speech { background: white; border: 2px solid #333; color: #333; border-radius: 15px; }
752
+ .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
753
+ .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%); }
754
+ .speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; }
755
+ .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%; }
756
+ .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; }
757
+ .speech-bubble.speech::after { border-top: 10px solid #333; bottom: -9px; left: 20px; }
758
+ .speech-bubble.idea::after { border-top: 10px solid #FFA500; bottom: -9px; left: 20px; }
759
+ .speech-bubble.thought::after { display: none; }
760
+ .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
761
+ .thought-dot-1 { width: 20px; height: 20px; bottom: -20px; left: 15px; }
762
+ .thought-dot-2 { width: 12px; height: 12px; bottom: -32px; left: 5px; }
763
+ .speech-bubble.flipped.speech::after, .speech-bubble.flipped.idea::after { left: auto; right: 20px; }
764
+ .speech-bubble.flipped.thought .thought-dot-1 { left: auto; right: 15px; }
765
+ .speech-bubble.flipped.thought .thought-dot-2 { left: auto; right: 5px; }
766
+ .speech-bubble.flipped-vertical.speech::after, .speech-bubble.flipped-vertical.idea::after { bottom: auto; top: -9px; transform: rotate(180deg); }
767
+ .speech-bubble.flipped-vertical.thought .thought-dot-1 { bottom: auto; top: -20px; }
768
+ .speech-bubble.flipped-vertical.thought .thought-dot-2 { bottom: auto; top: -32px; }
769
+ .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; }
770
+ .edit-controls h4 { margin: 0 0 10px 0; color: #26a69a; text-align: center; }
771
+ .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; }
772
+ .edit-controls .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
773
+ .edit-controls .reset-button { background-color: #e74c3c; }
774
+ .edit-controls .action-button { background-color: #4CAF50; }
775
+ .edit-controls .secondary-button { background-color: #f39c12; }
776
+ .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
777
+ .zoom-controls { display: grid; grid-template-columns: auto 1fr; gap: 5px; align-items: center;}
778
+ .timestamp-controls { display: grid; grid-template-columns: 1fr auto; gap: 5px; }
779
+ .timestamp-controls input { color: #333; font-weight: normal; }
780
+ </style>
781
+ </head>
782
+ <body>
783
+ <div class="comic-container">
784
+ <h1 class="comic-title">🎬 Generated Comic</h1>
785
+ <div id="comic-pages"><div class="loading">Loading comic...</div></div>
786
+ </div>
787
+ <input type="file" id="image-uploader" style="display: none;" accept="image/*">
788
+ <div class="edit-controls">
789
+ <h4>✏️ Interactive Editor</h4>
790
+ <div class="control-group">
791
+ <label>Bubble Tools:</label>
792
+ <select id="bubble-type-select" onchange="changeBubbleType(this.value)">
793
+ <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>
794
+ </select>
795
+ <button onclick="rotateBubbleTail()" class="secondary-button">🔄 Rotate Tail</button>
796
+ <button onclick="addBubbleToPanel()" class="action-button">💬 Add Bubble</button>
797
+ </div>
798
+ <div class="control-group">
799
+ <label>Panel Tools (Select Panel):</label>
800
+ <button onclick="replacePanelImage()" class="action-button">🖼️ Replace Image</button>
801
+ <div class="button-grid">
802
+ <button onclick="adjustFrame('backward')" class="secondary-button">⬅️ Prev Frame</button>
803
+ <button onclick="adjustFrame('forward')" class="action-button">Next Frame ➡️</button>
804
+ </div>
805
+ <div class="timestamp-controls">
806
+ <input type="text" id="timestamp-input" placeholder="mm:ss or secs">
807
+ <button onclick="gotoTimestamp()" class="action-button">Go</button>
808
+ </div>
809
+ </div>
810
+ <div class="control-group">
811
+ <label>Zoom & Pan (Select Panel):</label>
812
+ <div class="zoom-controls">
813
+ <button onclick="resetPanelTransform()" class="secondary-button" style="padding: 4px 6px;">Reset</button>
814
+ <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled>
815
+ </div>
816
+ </div>
817
+ <div class="control-group">
818
+ <button onclick="exportPagesToPNG()" class="action-button" style="background-color: #2196F3;">🖨️ Export Pages</button>
819
+ <button onclick="clearSavedState()" class="reset-button">🔄 Clear Edits & Reset</button>
820
+ </div>
821
+ </div>
822
+ <script>
823
+ document.addEventListener('DOMContentLoaded', () => {
824
+ fetch('/output/pages.json')
825
+ .then(res => res.ok ? res.json() : Promise.reject(new Error('Failed to load pages.json')))
826
+ .then(data => { renderComic(data); initializeEditor(); })
827
+ .catch(err => { document.getElementById('comic-pages').innerHTML = `<div class="loading">Error: ${err.message}</div>`; });
828
+ });
829
+
830
+ function renderComic(data) {
831
+ const container = document.getElementById('comic-pages');
832
+ container.innerHTML = '';
833
+ if (!data || data.length === 0) return;
834
+ data.forEach((pageData, pageIndex) => {
835
+ const pageWrapper = document.createElement('div');
836
+ pageWrapper.className = 'page-wrapper';
837
+ const pageTitleEl = document.createElement('h2');
838
+ pageTitleEl.className = 'page-title';
839
+ pageTitleEl.textContent = `Page ${pageIndex + 1}`;
840
+ pageWrapper.appendChild(pageTitleEl);
841
+ const pageDiv = document.createElement('div');
842
+ pageDiv.className = 'comic-page';
843
+ const grid = document.createElement('div');
844
+ grid.className = 'comic-grid';
845
+ pageData.panels.forEach((panelData, panelIndex) => {
846
+ const panelDiv = document.createElement('div');
847
+ panelDiv.className = 'panel';
848
+ const img = document.createElement('img');
849
+ img.src = '/frames/final/' + panelData.image;
850
+ panelDiv.appendChild(img);
851
+ if (pageData.bubbles && pageData.bubbles[panelIndex] && pageData.bubbles[panelIndex].dialog) {
852
+ const bubbleData = pageData.bubbles[panelIndex];
853
+ const bubbleDiv = createBubbleElement({
854
+ id: `initial-${pageIndex}-${panelIndex}`,
855
+ text: bubbleData.dialog || '',
856
+ left: `${bubbleData.bubble_offset_x ?? 50}px`,
857
+ top: `${bubbleData.bubble_offset_y ?? 20}px`,
858
+ });
859
+ panelDiv.appendChild(bubbleDiv);
860
+ }
861
+ grid.appendChild(panelDiv);
862
+ });
863
+ pageDiv.appendChild(grid);
864
+ pageWrapper.appendChild(pageDiv);
865
+ container.appendChild(pageWrapper);
866
+ });
867
+ }
868
+
869
+ let currentlyEditing = null, draggedBubble = null, offset = {x: 0, y: 0};
870
+ let currentlySelectedBubble = null;
871
+ let currentlySelectedPanel = null;
872
+ let isPanning = false, panStartX, panStartY, panStartTranslateX, panStartTranslateY;
873
+
874
+ function initializeEditor() {
875
+ document.querySelectorAll('.panel').forEach(panel => {
876
+ panel.addEventListener('click', e => selectPanel(panel));
877
+ const img = panel.querySelector('img');
878
+ if(img) initializePanelImageEvents(img);
879
+ });
880
+ document.querySelectorAll('.speech-bubble').forEach(b => initializeBubbleEvents(b));
881
+ document.getElementById('zoom-slider').addEventListener('input', handleZoom);
882
+
883
+ document.addEventListener('mousemove', (e) => {
884
+ if (isPanning) panImage(e);
885
+ if (draggedBubble) drag(e);
886
+ });
887
+ document.addEventListener('mouseup', (e) => {
888
+ if (isPanning) stopPan(e);
889
+ if (draggedBubble) stopDrag(e);
890
+ });
891
+ document.addEventListener('mouseleave', (e) => {
892
+ if (isPanning) stopPan(e);
893
+ if (draggedBubble) stopDrag(e);
894
+ });
895
+ }
896
+
897
+ function initializePanelImageEvents(img) {
898
+ img.addEventListener('mousedown', startPan);
899
+ }
900
+
901
+ function initializeBubbleEvents(bubble) {
902
+ bubble.addEventListener('dblclick', e => { e.stopPropagation(); editBubbleText(bubble); });
903
+ bubble.addEventListener('mousedown', e => { e.stopPropagation(); startDrag(e); });
904
+ bubble.addEventListener('click', e => { e.stopPropagation(); selectBubble(bubble); });
905
+ bubble.addEventListener('wheel', e => {
906
+ e.preventDefault();
907
+ const currentWidth = parseFloat(bubble.style.width) || bubble.offsetWidth;
908
+ const newWidth = currentWidth - (e.deltaY > 0 ? 10 : -10);
909
+ if (newWidth >= 60) {
910
+ bubble.style.width = `${newWidth}px`;
911
+ bubble.style.height = 'auto';
912
+ }
913
+ }, { passive: false });
914
+ }
915
+
916
+ function createBubbleElement(data) {
917
+ const bubbleDiv = document.createElement('div');
918
+ bubbleDiv.dataset.id = data.id;
919
+ const textSpan = document.createElement('span');
920
+ textSpan.className = 'bubble-text';
921
+ textSpan.textContent = data.text;
922
+ bubbleDiv.appendChild(textSpan);
923
+ bubbleDiv.style.left = data.left;
924
+ bubbleDiv.style.top = data.top;
925
+ applyBubbleType(bubbleDiv, 'speech');
926
+ return bubbleDiv;
927
+ }
928
+
929
+ function applyBubbleType(bubble, type) {
930
+ bubble.querySelectorAll('.thought-dot').forEach(el => el.remove());
931
+ let classesToKeep = 'speech-bubble';
932
+ if (bubble.classList.contains('selected')) classesToKeep += ' selected';
933
+ if (bubble.classList.contains('flipped')) classesToKeep += ' flipped';
934
+ if (bubble.classList.contains('flipped-vertical')) classesToKeep += ' flipped-vertical';
935
+ bubble.className = classesToKeep;
936
+ bubble.classList.add(type);
937
+ bubble.dataset.type = type;
938
+ if (type === 'thought') {
939
+ for (let i = 1; i <= 2; i++) {
940
+ const dot = document.createElement('div');
941
+ dot.className = `thought-dot thought-dot-${i}`;
942
+ bubble.appendChild(dot);
943
+ }
944
+ }
945
+ }
946
+
947
+ function changeBubbleType(type) {
948
+ if (!currentlySelectedBubble) return;
949
+ applyBubbleType(currentlySelectedBubble, type);
950
+ }
951
+
952
+ function rotateBubbleTail() {
953
+ if (!currentlySelectedBubble) { alert("Please select a bubble first."); return; }
954
+ const isFlippedH = currentlySelectedBubble.classList.contains('flipped');
955
+ const isFlippedV = currentlySelectedBubble.classList.contains('flipped-vertical');
956
+ if (!isFlippedH && !isFlippedV) {
957
+ currentlySelectedBubble.classList.add('flipped');
958
+ } else if (isFlippedH && !isFlippedV) {
959
+ currentlySelectedBubble.classList.add('flipped-vertical');
960
+ } else if (isFlippedH && isFlippedV) {
961
+ currentlySelectedBubble.classList.remove('flipped');
962
+ } else {
963
+ currentlySelectedBubble.classList.remove('flipped-vertical');
964
+ }
965
+ }
966
+
967
+ function selectPanel(panel) {
968
+ document.querySelectorAll('.panel.selected').forEach(p => p.classList.remove('selected'));
969
+ panel.classList.add('selected');
970
+ currentlySelectedPanel = panel;
971
+ selectBubble(null);
972
+
973
+ const img = currentlySelectedPanel.querySelector('img');
974
+ const zoomSlider = document.getElementById('zoom-slider');
975
+ zoomSlider.value = img.dataset.zoom || 100;
976
+ zoomSlider.disabled = false;
977
+ }
978
+
979
+ function selectBubble(bubble) {
980
+ if (currentlySelectedBubble) currentlySelectedBubble.classList.remove('selected');
981
+ currentlySelectedBubble = bubble;
982
+ if (currentlySelectedBubble) {
983
+ currentlySelectedBubble.classList.add('selected');
984
+ if(currentlySelectedPanel) currentlySelectedPanel.classList.remove('selected');
985
+ currentlySelectedPanel = null;
986
+ document.getElementById('bubble-type-select').value = currentlySelectedBubble.dataset.type || 'speech';
987
+ document.getElementById('zoom-slider').disabled = true;
988
+ }
989
+ }
990
+
991
+ function editBubbleText(bubble) {
992
+ if (currentlyEditing) return;
993
+ currentlyEditing = bubble;
994
+ const textSpan = bubble.querySelector('.bubble-text');
995
+ const currentText = textSpan.textContent;
996
+ textSpan.style.display = 'none';
997
+ bubble.style.height = 'auto';
998
+ const textarea = document.createElement('textarea');
999
+ textarea.value = currentText;
1000
+ bubble.appendChild(textarea);
1001
+ textarea.focus();
1002
+ const finishEditing = () => {
1003
+ textSpan.textContent = textarea.value;
1004
+ bubble.removeChild(textarea);
1005
+ textSpan.style.display = '';
1006
+ currentlyEditing = null;
1007
+ bubble.style.height = 'auto';
1008
+ };
1009
+ textarea.addEventListener('blur', finishEditing, { once: true });
1010
+ textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); }});
1011
+ }
1012
+
1013
+ function startDrag(e) {
1014
+ const bubble = e.target.closest('.speech-bubble');
1015
+ if (!bubble || currentlyEditing) return;
1016
+ draggedBubble = bubble;
1017
+ selectBubble(bubble);
1018
+ const rect = bubble.getBoundingClientRect();
1019
+ offset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
1020
+ }
1021
+
1022
+ function drag(e) {
1023
+ if (!draggedBubble) return;
1024
+ const parentRect = draggedBubble.parentElement.getBoundingClientRect();
1025
+ let x = e.clientX - parentRect.left - offset.x;
1026
+ let y = e.clientY - parentRect.top - offset.y;
1027
+ draggedBubble.style.left = `${x}px`;
1028
+ draggedBubble.style.top = `${y}px`;
1029
+ }
1030
+
1031
+ function stopDrag() {
1032
+ draggedBubble = null;
1033
+ }
1034
+
1035
+ function clearSavedState() {
1036
+ if (confirm("Reset all edits to the original AI-generated comic?")) {
1037
+ localStorage.removeItem('comicEditorState');
1038
+ window.location.reload();
1039
+ }
1040
+ }
1041
+
1042
+ async function exportPagesToPNG() {
1043
+ const pages = document.querySelectorAll('.comic-page');
1044
+ if (pages.length === 0) return alert("No pages found.");
1045
+ alert(`Starting export of ${pages.length} page(s).`);
1046
+ for (let i = 0; i < pages.length; i++) {
1047
+ try {
1048
+ const canvas = await html2canvas(pages[i], { scale: 2 });
1049
+ const link = document.createElement('a');
1050
+ link.download = `comic-page-${i + 1}.png`;
1051
+ link.href = canvas.toDataURL('image/png');
1052
+ link.click();
1053
+ } catch (err) {
1054
+ alert(`Failed to export page ${i + 1}.`);
1055
+ }
1056
+ }
1057
+ }
1058
+
1059
+ function replacePanelImage() {
1060
+ if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
1061
+ const img = currentlySelectedPanel.querySelector('img');
1062
+ const uploader = document.getElementById('image-uploader');
1063
+ const oneTimeListener = (event) => {
1064
+ const file = event.target.files[0];
1065
+ if (!file) return;
1066
+ const formData = new FormData();
1067
+ formData.append('image', file);
1068
+ img.style.opacity = '0.5';
1069
+ fetch('/replace_panel', { method: 'POST', body: formData })
1070
+ .then(response => response.json())
1071
+ .then(data => {
1072
+ if (data.success) {
1073
+ img.src = `/frames/final/${data.new_filename}?t=${new Date().getTime()}`;
1074
+ } else { alert('Error replacing image: ' + data.error); }
1075
+ img.style.opacity = '1';
1076
+ })
1077
+ .catch(error => {
1078
+ alert('An error occurred during the upload.');
1079
+ img.style.opacity = '1';
1080
+ });
1081
+ uploader.removeEventListener('change', oneTimeListener);
1082
+ uploader.value = '';
1083
+ };
1084
+ uploader.addEventListener('change', oneTimeListener, { once: true });
1085
+ uploader.click();
1086
+ }
1087
+
1088
+ function adjustFrame(direction) {
1089
+ if (!currentlySelectedPanel) { alert("Please select a panel first to adjust its frame."); return; }
1090
+ const img = currentlySelectedPanel.querySelector('img');
1091
+ const currentSrc = img.src;
1092
+ let filename = currentSrc.substring(currentSrc.lastIndexOf('/') + 1);
1093
+ if (filename.includes('?')) { filename = filename.split('?')[0]; }
1094
+ img.style.opacity = '0.5';
1095
+ fetch('/regenerate_frame', {
1096
+ method: 'POST',
1097
+ headers: { 'Content-Type': 'application/json' },
1098
+ body: JSON.stringify({ filename: filename, direction: direction })
1099
+ })
1100
+ .then(response => response.json())
1101
+ .then(data => {
1102
+ if (data.success) {
1103
+ img.src = `/frames/final/${filename}?t=${new Date().getTime()}`;
1104
+ console.log(data.message);
1105
+ } else { alert('Error: ' + data.message); }
1106
+ img.style.opacity = '1';
1107
+ })
1108
+ .catch(error => {
1109
+ alert('An error occurred during frame adjustment.');
1110
+ img.style.opacity = '1';
1111
+ });
1112
+ }
1113
+
1114
+ function updateImageTransform(img) {
1115
+ const zoom = (img.dataset.zoom || 100) / 100;
1116
+ const x = img.dataset.translateX || 0;
1117
+ const y = img.dataset.translateY || 0;
1118
+ img.style.transform = `translateX(${x}px) translateY(${y}px) scale(${zoom})`;
1119
+ if (zoom > 1) { img.classList.add('pannable'); } else { img.classList.remove('pannable'); }
1120
+ }
1121
+
1122
+ function handleZoom(event) {
1123
+ if (!currentlySelectedPanel) return;
1124
+ const img = currentlySelectedPanel.querySelector('img');
1125
+ img.dataset.zoom = event.target.value;
1126
+ updateImageTransform(img);
1127
+ }
1128
+
1129
+ function resetPanelTransform() {
1130
+ if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
1131
+ const img = currentlySelectedPanel.querySelector('img');
1132
+ img.dataset.zoom = 100;
1133
+ img.dataset.translateX = 0;
1134
+ img.dataset.translateY = 0;
1135
+ document.getElementById('zoom-slider').value = 100;
1136
+ updateImageTransform(img);
1137
+ }
1138
+
1139
+ function startPan(event) {
1140
+ if (event.button !== 0) return;
1141
+ const img = event.target;
1142
+ const zoom = parseFloat(img.dataset.zoom || 100);
1143
+ if (zoom <= 100) return;
1144
+ event.preventDefault();
1145
+ isPanning = true;
1146
+ img.classList.add('panning');
1147
+ panStartX = event.clientX;
1148
+ panStartY = event.clientY;
1149
+ panStartTranslateX = parseFloat(img.dataset.translateX || 0);
1150
+ panStartTranslateY = parseFloat(img.dataset.translateY || 0);
1151
+ }
1152
+
1153
+ function panImage(event) {
1154
+ if (!isPanning || !currentlySelectedPanel) return;
1155
+ const img = currentlySelectedPanel.querySelector('img');
1156
+ const dx = event.clientX - panStartX;
1157
+ const dy = event.clientY - panStartY;
1158
+ img.dataset.translateX = panStartTranslateX + dx;
1159
+ img.dataset.translateY = panStartTranslateY + dy;
1160
+ updateImageTransform(img);
1161
+ }
1162
+
1163
+ function stopPan(event) {
1164
+ if (!isPanning) return;
1165
+ isPanning = false;
1166
+ if (currentlySelectedPanel) {
1167
+ const img = currentlySelectedPanel.querySelector('img');
1168
+ if(img) img.classList.remove('panning');
1169
+ }
1170
+ }
1171
+
1172
+ function addBubbleToPanel() {
1173
+ if (!currentlySelectedPanel) {
1174
+ alert("Please select a panel first to add a bubble to it.");
1175
+ return;
1176
+ }
1177
+
1178
+ const newBubble = createBubbleElement({
1179
+ id: `new-bubble-${Date.now()}`,
1180
+ text: 'New Text...',
1181
+ left: '10%',
1182
+ top: '10%'
1183
+ });
1184
+
1185
+ currentlySelectedPanel.appendChild(newBubble);
1186
+ initializeBubbleEvents(newBubble);
1187
+ selectBubble(newBubble);
1188
+ editBubbleText(newBubble);
1189
+ }
1190
+
1191
+ function gotoTimestamp() {
1192
+ if (!currentlySelectedPanel) {
1193
+ alert("Please select a panel first to jump to a timestamp.");
1194
+ return;
1195
+ }
1196
+ const input = document.getElementById('timestamp-input');
1197
+ const timeStr = input.value.trim();
1198
+ if (!timeStr) {
1199
+ alert("Please enter a timestamp (e.g., '1:32' or '92.5').");
1200
+ return;
1201
+ }
1202
+
1203
+ let parsedSeconds = 0;
1204
+ if (timeStr.includes(':')) {
1205
+ const parts = timeStr.split(':');
1206
+ try {
1207
+ const minutes = parseInt(parts[0], 10);
1208
+ const seconds = parseFloat(parts[1]);
1209
+ if (!isNaN(minutes) && !isNaN(seconds)) {
1210
+ parsedSeconds = (minutes * 60) + seconds;
1211
+ } else { throw new Error(); }
1212
+ } catch {
1213
+ alert("Invalid time format. Please use 'mm:ss'.");
1214
+ return;
1215
+ }
1216
+ } else {
1217
+ parsedSeconds = parseFloat(timeStr);
1218
+ if (isNaN(parsedSeconds)) {
1219
+ alert("Invalid time format. Please enter seconds as a number.");
1220
+ return;
1221
+ }
1222
+ }
1223
+
1224
+ const img = currentlySelectedPanel.querySelector('img');
1225
+ let filename = img.src.substring(img.src.lastIndexOf('/') + 1);
1226
+ if (filename.includes('?')) { filename = filename.split('?')[0]; }
1227
+
1228
+ img.style.opacity = '0.5';
1229
+ fetch('/goto_timestamp', {
1230
+ method: 'POST',
1231
+ headers: { 'Content-Type': 'application/json' },
1232
+ body: JSON.stringify({ filename: filename, timestamp: parsedSeconds })
1233
+ })
1234
+ .then(response => response.json())
1235
+ .then(data => {
1236
+ if (data.success) {
1237
+ img.src = `/frames/final/${filename}?t=${new Date().getTime()}`;
1238
+ input.value = '';
1239
+ resetPanelTransform();
1240
+ } else {
1241
+ alert('Error: ' + data.message);
1242
+ }
1243
+ img.style.opacity = '1';
1244
+ })
1245
+ .catch(error => {
1246
+ alert('An error occurred while fetching the timestamp.');
1247
+ img.style.opacity = '1';
1248
+ });
1249
+ }
1250
+ </script>
1251
+ </body>
1252
+ </html>'''
1253
+ with open(os.path.join(self.output_dir, 'page.html'), 'w', encoding='utf-8') as f:
1254
+ f.write(template_html)
1255
+ print("📄 Template files copied successfully!")
1256
+ except Exception as e:
1257
+ print(f"Template copy failed: {e}")
1258
 
1259
  # --- Flask Routes ---
1260
  comic_generator = EnhancedComicGenerator()
1261
 
1262
  @app.route('/')
1263
  def index():
 
1264
  return INDEX_HTML
1265
 
1266
  @app.route('/uploader', methods=['POST'])
 
1274
  os.remove(comic_generator.video_path)
1275
  f.save(comic_generator.video_path)
1276
 
 
1277
  thread = threading.Thread(target=comic_generator.generate_comic)
1278
  thread.start()
1279
 
 
1285
 
1286
  @app.route('/status')
1287
  def status():
 
1288
  status_file = os.path.join('output', 'status.json')
1289
  if os.path.exists(status_file):
1290
  return send_from_directory('output', 'status.json')
1291
  else:
 
1292
  return jsonify({'message': 'Initializing...', 'progress': 0})
1293
 
1294
  @app.route('/handle_link', methods=['POST'])
1295
  def handle_link():
 
1296
  try:
1297
  link = request.form.get('link', '')
1298
  if not link: return "❌ No link provided"
 
1301
  with yt_dlp.YoutubeDL(ydl_opts) as ydl:
1302
  ydl.download([link])
1303
 
 
1304
  thread = threading.Thread(target=comic_generator.generate_comic)
1305
  thread.start()
1306
 
 
1307
  return "Generation started. Please poll /status for updates."
1308
  except Exception as e:
1309
  traceback.print_exc()
1310
  return f"❌ An unexpected error occurred: {str(e)}"
1311
 
1312
+ @app.route('/replace_panel', methods=['POST'])
1313
+ def replace_panel():
1314
+ try:
1315
+ if 'image' not in request.files: return jsonify({'success': False, 'error': 'No image file provided.'})
1316
+ file = request.files['image']
1317
+ timestamp = int(time.time() * 1000)
1318
+ filename = f"replaced_panel_{timestamp}.png"
1319
+ save_path = os.path.join(comic_generator.frames_dir, filename)
1320
+ file.save(save_path)
1321
+ print(f"✅ Replaced panel with '{filename}' without applying color enhancement.")
1322
+ return jsonify({'success': True, 'new_filename': filename})
1323
+ except Exception as e:
1324
+ traceback.print_exc()
1325
+ return jsonify({'success': False, 'error': str(e)})
1326
+
1327
+ @app.route('/regenerate_frame', methods=['POST'])
1328
+ def regenerate_frame_route():
1329
+ try:
1330
+ data = request.get_json()
1331
+ filename = data.get('filename')
1332
+ direction = data.get('direction')
1333
+ if not filename or not direction:
1334
+ return jsonify({'success': False, 'message': 'Filename or direction missing.'})
1335
+
1336
+ result = comic_generator.regenerate_frame(filename, direction)
1337
+ return jsonify(result)
1338
+ except Exception as e:
1339
+ traceback.print_exc()
1340
+ return jsonify({'success': False, 'message': str(e)})
1341
+
1342
+ @app.route('/goto_timestamp', methods=['POST'])
1343
+ def goto_timestamp_route():
1344
+ try:
1345
+ data = request.get_json()
1346
+ filename = data.get('filename')
1347
+ timestamp = data.get('timestamp')
1348
+ if filename is None or timestamp is None:
1349
+ return jsonify({'success': False, 'message': 'Filename or timestamp missing.'})
1350
+
1351
+ result = comic_generator.get_frame_at_timestamp(filename, float(timestamp))
1352
+ return jsonify(result)
1353
+ except Exception as e:
1354
+ traceback.print_exc()
1355
+ return jsonify({'success': False, 'message': str(e)})
1356
+
1357
+ @app.route('/comic')
1358
+ def view_comic():
1359
+ return send_from_directory('output', 'page.html')
1360
+
1361
+ @app.route('/output/<path:filename>')
1362
+ def output_file(filename):
1363
+ return send_from_directory('output', filename)
1364
+
1365
+ @app.route('/frames/final/<path:filename>')
1366
+ def frame_file(filename):
1367
+ return send_from_directory('frames/final', filename)
1368
 
1369
  if __name__ == '__main__':
1370
  port = int(os.getenv("PORT", 7860))