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

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +798 -692
app_enhanced.py CHANGED
@@ -65,208 +65,12 @@ except Exception as e:
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,7 +79,8 @@ class EnhancedComicGenerator:
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,653 +89,928 @@ class EnhancedComicGenerator:
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):
475
  try:
476
  os.makedirs(self.output_dir, exist_ok=True)
477
- pages_data = [{'panels': [p.__dict__ for p in page.panels], 'bubbles': [b.__dict__ for b in page.bubbles]} for page in pages]
 
 
 
 
 
 
478
  with open(os.path.join(self.output_dir, 'pages.json'), 'w', encoding='utf-8') as f:
479
  json.dump(pages_data, f, indent=2)
480
  self._copy_template_files()
481
  print("✅ Results saved successfully!")
482
  except Exception as e:
483
  print(f"Save results failed: {e}")
 
484
 
485
  def _copy_template_files(self):
 
486
  try:
487
  template_html = '''<!DOCTYPE html>
488
  <html lang="en">
489
  <head>
490
- <meta charset="UTF-8">
491
- <title>Comic Editor</title>
492
- <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
493
- <style>
494
- body { margin: 0; padding: 20px; background: #f0f0f0; font-family: Arial, sans-serif; }
495
- .comic-container { max-width: 1200px; margin: 0 auto; }
496
- .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; }
497
- .comic-grid { display: grid; grid-template-columns: 285px 285px; grid-template-rows: 185px 185px; gap: 10px; width: 100%; height: 100%; }
498
- .page-wrapper { margin: 30px auto; width: 622px; display: flex; flex-direction: column; align-items: center; }
499
- .page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
500
- .panel { position: relative; overflow: hidden; width: 100%; height: 100%; box-sizing: border-box; cursor: pointer; border: 1px solid #333; }
501
- .panel.selected { outline: 3px solid #2196F3; outline-offset: -3px; }
502
- .panel img { width: 100%; height: 100%; object-fit: cover; object-position: center; transition: transform 0.1s ease-out; }
503
- .panel img.pannable { cursor: grab; }
504
- .panel img.panning { cursor: grabbing; }
505
- .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; }
506
- .bubble-text { padding: 2px; word-wrap: break-word; }
507
- .speech-bubble.selected { outline: 2px dashed #4CAF50; }
508
- .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; }
509
- .speech-bubble.speech { background: white; border: 2px solid #333; color: #333; border-radius: 15px; }
510
- .speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
511
- .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%); }
512
- .speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; }
513
- .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%; }
514
- .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; }
515
- .speech-bubble.speech::after { border-top: 10px solid #333; bottom: -9px; left: 20px; }
516
- .speech-bubble.idea::after { border-top: 10px solid #FFA500; bottom: -9px; left: 20px; }
517
- .speech-bubble.thought::after { display: none; }
518
- .thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
519
- .thought-dot-1 { width: 20px; height: 20px; bottom: -20px; left: 15px; }
520
- .thought-dot-2 { width: 12px; height: 12px; bottom: -32px; left: 5px; }
521
- .speech-bubble.flipped.speech::after, .speech-bubble.flipped.idea::after { left: auto; right: 20px; }
522
- .speech-bubble.flipped.thought .thought-dot-1 { left: auto; right: 15px; }
523
- .speech-bubble.flipped.thought .thought-dot-2 { left: auto; right: 5px; }
524
- .speech-bubble.flipped-vertical.speech::after, .speech-bubble.flipped-vertical.idea::after { bottom: auto; top: -9px; transform: rotate(180deg); }
525
- .speech-bubble.flipped-vertical.thought .thought-dot-1 { bottom: auto; top: -20px; }
526
- .speech-bubble.flipped-vertical.thought .thought-dot-2 { bottom: auto; top: -32px; }
527
- .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; }
528
- .edit-controls h4 { margin: 0 0 10px 0; color: #26a69a; text-align: center; }
529
- .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; }
530
- .edit-controls .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
531
- .edit-controls .reset-button { background-color: #e74c3c; }
532
- .edit-controls .action-button { background-color: #4CAF50; }
533
- .edit-controls .secondary-button { background-color: #f39c12; }
534
- .button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
535
- .zoom-controls { display: grid; grid-template-columns: auto 1fr; gap: 5px; align-items: center;}
536
- .timestamp-controls { display: grid; grid-template-columns: 1fr auto; gap: 5px; }
537
- .timestamp-controls input { color: #333; font-weight: normal; }
538
- </style>
 
539
  </head>
540
  <body>
541
- <div class="comic-container">
542
- <h1 class="comic-title">🎬 Generated Comic</h1>
543
- <div id="comic-pages"><div class="loading">Loading comic...</div></div>
 
 
 
 
 
 
 
 
 
 
 
544
  </div>
545
- <input type="file" id="image-uploader" style="display: none;" accept="image/*">
546
- <div class="edit-controls">
547
- <h4>✏️ Interactive Editor</h4>
548
- <div class="control-group">
549
- <label>Bubble Tools:</label>
550
- <select id="bubble-type-select" onchange="changeBubbleType(this.value)">
551
- <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>
552
- </select>
553
- <button onclick="rotateBubbleTail()" class="secondary-button">🔄 Rotate Tail</button>
554
- <button onclick="addBubbleToPanel()" class="action-button">💬 Add Bubble</button>
555
- </div>
556
- <div class="control-group">
557
- <label>Panel Tools (Select Panel):</label>
558
- <button onclick="replacePanelImage()" class="action-button">🖼️ Replace Image</button>
559
- <div class="button-grid">
560
- <button onclick="adjustFrame('backward')" class="secondary-button">⬅️ Prev Frame</button>
561
- <button onclick="adjustFrame('forward')" class="action-button">Next Frame ➡️</button>
562
- </div>
563
- <div class="timestamp-controls">
564
- <input type="text" id="timestamp-input" placeholder="mm:ss or secs">
565
- <button onclick="gotoTimestamp()" class="action-button">Go</button>
566
- </div>
567
  </div>
568
- <div class="control-group">
569
- <label>Zoom & Pan (Select Panel):</label>
570
- <div class="zoom-controls">
571
- <button onclick="resetPanelTransform()" class="secondary-button" style="padding: 4px 6px;">Reset</button>
572
- <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled>
573
- </div>
574
  </div>
575
- <div class="control-group">
576
- <button onclick="exportPagesToPNG()" class="action-button" style="background-color: #2196F3;">🖨️ Export Pages</button>
577
- <button onclick="clearSavedState()" class="reset-button">🔄 Clear Edits & Reset</button>
 
 
 
578
  </div>
579
  </div>
580
- <script>
581
- document.addEventListener('DOMContentLoaded', () => {
582
- fetch('/output/pages.json')
583
- .then(res => res.ok ? res.json() : Promise.reject(new Error('Failed to load pages.json')))
584
- .then(data => { renderComic(data); initializeEditor(); })
585
- .catch(err => { document.getElementById('comic-pages').innerHTML = `<div class="loading">Error: ${err.message}</div>`; });
586
- });
587
-
588
- let currentlyEditing = null, draggedBubble = null, offset = {x: 0, y: 0};
589
- let currentlySelectedBubble = null;
590
- let currentlySelectedPanel = null;
591
- let isPanning = false, panStartX, panStartY, panStartTranslateX, panStartTranslateY;
592
-
593
- function renderComic(data) {
594
- const container = document.getElementById('comic-pages');
595
- container.innerHTML = '';
596
- data.forEach((pageData, pageIndex) => {
597
- const pageWrapper = document.createElement('div');
598
- pageWrapper.className = 'page-wrapper';
599
- const pageTitleEl = document.createElement('h2');
600
- pageTitleEl.className = 'page-title';
601
- pageTitleEl.textContent = `Page ${pageIndex + 1}`;
602
- pageWrapper.appendChild(pageTitleEl);
603
- const pageDiv = document.createElement('div');
604
- pageDiv.className = 'comic-page';
605
- const grid = document.createElement('div');
606
- grid.className = 'comic-grid';
607
- pageData.panels.forEach((panelData, panelIndex) => {
608
- const panelDiv = document.createElement('div');
609
- panelDiv.className = 'panel';
610
- const img = document.createElement('img');
611
- img.src = '/frames/final/' + panelData.image;
612
- panelDiv.appendChild(img);
613
- if (pageData.bubbles && pageData.bubbles[panelIndex] && pageData.bubbles[panelIndex].dialog) {
614
- const bubbleDiv = createBubbleElement({
615
- id: `initial-${pageIndex}-${panelIndex}`,
616
- text: pageData.bubbles[panelIndex].dialog || '',
617
- left: `${pageData.bubbles[panelIndex].bubble_offset_x ?? 50}px`,
618
- top: `${pageData.bubbles[panelIndex].bubble_offset_y ?? 20}px`,
619
- });
620
- panelDiv.appendChild(bubbleDiv);
621
- }
622
- grid.appendChild(panelDiv);
623
- });
624
- pageDiv.appendChild(grid);
625
- pageWrapper.appendChild(pageDiv);
626
- container.appendChild(pageWrapper);
627
- });
628
- }
629
-
630
- function initializeEditor() {
631
- document.querySelectorAll('.panel').forEach(panel => {
632
- panel.addEventListener('click', () => selectPanel(panel));
633
- panel.querySelector('img')?.addEventListener('mousedown', startPan);
634
  });
635
- document.querySelectorAll('.speech-bubble').forEach(initializeBubbleEvents);
636
- document.getElementById('zoom-slider').addEventListener('input', handleZoom);
637
- document.addEventListener('mousemove', e => { if (isPanning) panImage(e); if (draggedBubble) drag(e); });
638
- document.addEventListener('mouseup', e => { if (isPanning) stopPan(e); if (draggedBubble) stopDrag(e); });
639
- document.addEventListener('mouseleave', e => { if (isPanning) stopPan(e); if (draggedBubble) stopDrag(e); });
640
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
641
 
642
- function initializeBubbleEvents(bubble) {
643
- bubble.addEventListener('dblclick', e => { e.stopPropagation(); editBubbleText(bubble); });
644
- bubble.addEventListener('mousedown', e => { e.stopPropagation(); startDrag(e); });
645
- bubble.addEventListener('click', e => { e.stopPropagation(); selectBubble(bubble); });
646
- bubble.addEventListener('wheel', e => {
647
- e.preventDefault();
648
- const currentWidth = parseFloat(bubble.style.width) || bubble.offsetWidth;
649
- const newWidth = currentWidth - (e.deltaY > 0 ? 10 : -10);
650
- if (newWidth >= 60) bubble.style.width = `${newWidth}px`;
651
- }, { passive: false });
652
- }
653
-
654
- function createBubbleElement(data) {
655
- const bubbleDiv = document.createElement('div');
656
- bubbleDiv.dataset.id = data.id;
657
- const textSpan = document.createElement('span');
658
- textSpan.className = 'bubble-text';
659
- textSpan.textContent = data.text;
660
- bubbleDiv.appendChild(textSpan);
661
- bubbleDiv.style.left = data.left;
662
- bubbleDiv.style.top = data.top;
663
- applyBubbleType(bubbleDiv, 'speech');
664
- return bubbleDiv;
665
- }
666
 
667
- function applyBubbleType(bubble, type) {
668
- bubble.querySelectorAll('.thought-dot').forEach(el => el.remove());
669
- let classesToKeep = 'speech-bubble';
670
- if (bubble.classList.contains('selected')) classesToKeep += ' selected';
671
- if (bubble.classList.contains('flipped')) classesToKeep += ' flipped';
672
- if (bubble.classList.contains('flipped-vertical')) classesToKeep += ' flipped-vertical';
673
- bubble.className = classesToKeep;
674
- bubble.classList.add(type);
675
- bubble.dataset.type = type;
676
- if (type === 'thought') {
677
- for (let i = 1; i <= 2; i++) {
678
- const dot = document.createElement('div');
679
- dot.className = `thought-dot thought-dot-${i}`;
680
- bubble.appendChild(dot);
681
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
682
  }
683
  }
684
-
685
- function changeBubbleType(type) {
686
- if (!currentlySelectedBubble) return;
687
- applyBubbleType(currentlySelectedBubble, type);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
688
  }
 
 
 
 
 
 
 
689
 
690
- function rotateBubbleTail() {
691
- if (!currentlySelectedBubble) { alert("Please select a bubble first."); return; }
692
- const isFlippedH = currentlySelectedBubble.classList.contains('flipped');
693
- const isFlippedV = currentlySelectedBubble.classList.contains('flipped-vertical');
694
- if (!isFlippedH && !isFlippedV) { currentlySelectedBubble.classList.add('flipped'); }
695
- else if (isFlippedH && !isFlippedV) { currentlySelectedBubble.classList.add('flipped-vertical'); }
696
- else if (isFlippedH && isFlippedV) { currentlySelectedBubble.classList.remove('flipped'); }
697
- else { currentlySelectedBubble.classList.remove('flipped-vertical'); }
 
 
 
 
 
 
 
698
  }
699
-
700
- function selectPanel(panel) {
701
- document.querySelectorAll('.panel.selected').forEach(p => p.classList.remove('selected'));
702
- panel.classList.add('selected');
703
- currentlySelectedPanel = panel;
704
- selectBubble(null);
705
- const img = currentlySelectedPanel.querySelector('img');
706
- const zoomSlider = document.getElementById('zoom-slider');
707
- zoomSlider.value = img.dataset.zoom || 100;
708
- zoomSlider.disabled = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
709
  }
710
-
711
- function selectBubble(bubble) {
712
- if (currentlySelectedBubble) currentlySelectedBubble.classList.remove('selected');
713
- currentlySelectedBubble = bubble;
714
- if (currentlySelectedBubble) {
715
- currentlySelectedBubble.classList.add('selected');
716
- if (currentlySelectedPanel) currentlySelectedPanel.classList.remove('selected');
717
- currentlySelectedPanel = null;
718
- document.getElementById('bubble-type-select').value = currentlySelectedBubble.dataset.type || 'speech';
719
- document.getElementById('zoom-slider').disabled = true;
 
 
 
 
 
720
  }
721
  }
722
-
723
- function editBubbleText(bubble) {
724
- if (currentlyEditing) return;
725
- currentlyEditing = bubble;
726
- const textSpan = bubble.querySelector('.bubble-text');
727
- const textarea = document.createElement('textarea');
728
- textarea.value = textSpan.textContent;
729
- bubble.appendChild(textarea);
730
- textSpan.style.display = 'none';
731
- textarea.focus();
732
- const finishEditing = () => {
733
- textSpan.textContent = textarea.value;
734
- bubble.removeChild(textarea);
735
- textSpan.style.display = '';
736
- currentlyEditing = null;
737
- };
738
- textarea.addEventListener('blur', finishEditing, { once: true });
739
- textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); }});
740
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741
 
742
- function startDrag(e) {
743
- const bubble = e.target.closest('.speech-bubble');
744
- if (!bubble || currentlyEditing) return;
745
- draggedBubble = bubble;
746
- selectBubble(bubble);
747
- const rect = bubble.getBoundingClientRect();
748
- offset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
749
- }
750
 
751
- function drag(e) {
752
- if (!draggedBubble) return;
753
- const parentRect = draggedBubble.parentElement.getBoundingClientRect();
754
- draggedBubble.style.left = `${e.clientX - parentRect.left - offset.x}px`;
755
- draggedBubble.style.top = `${e.clientY - parentRect.top - offset.y}px`;
756
- }
 
 
 
 
 
 
 
 
 
 
757
 
758
- function stopDrag() { draggedBubble = null; }
 
 
 
 
 
 
 
 
 
 
 
 
759
 
760
- function clearSavedState() {
761
- if (confirm("Reset all edits?")) {
762
- localStorage.removeItem('comicEditorState');
763
- window.location.reload();
764
- }
765
- }
 
 
 
766
 
767
- async function exportPagesToPNG() {
768
- const pages = document.querySelectorAll('.comic-page');
769
- if (pages.length === 0) return alert("No pages found.");
770
- alert(`Starting export of ${pages.length} page(s).`);
771
- for (let i = 0; i < pages.length; i++) {
772
- try {
773
- const canvas = await html2canvas(pages[i], { scale: 2 });
774
- const link = document.createElement('a');
775
- link.download = `comic-page-${i + 1}.png`;
776
- link.href = canvas.toDataURL('image/png');
777
- link.click();
778
- } catch (err) { alert(`Failed to export page ${i + 1}.`); }
779
- }
780
- }
781
-
782
- function replacePanelImage() {
783
- if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
784
  const img = currentlySelectedPanel.querySelector('img');
785
- const uploader = document.getElementById('image-uploader');
786
- uploader.onchange = (event) => {
787
- const file = event.target.files[0];
788
- if (!file) return;
789
- const formData = new FormData();
790
- formData.append('image', file);
791
- img.style.opacity = '0.5';
792
- fetch('/replace_panel', { method: 'POST', body: formData })
793
- .then(response => response.json())
794
- .then(data => {
795
- if (data.success) {
796
- img.src = `/frames/final/${data.new_filename}?t=${new Date().getTime()}`;
797
- } else { alert('Error replacing image: ' + data.error); }
798
- img.style.opacity = '1';
799
- })
800
- .catch(() => {
801
- alert('An error occurred during the upload.');
802
- img.style.opacity = '1';
803
- });
804
- uploader.value = '';
805
- };
806
- uploader.click();
807
- }
808
-
809
- function adjustFrame(direction) {
810
- if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
811
- const img = currentlySelectedPanel.querySelector('img');
812
- let filename = img.src.substring(img.src.lastIndexOf('/') + 1).split('?')[0];
813
- img.style.opacity = '0.5';
814
- fetch('/regenerate_frame', {
815
- method: 'POST',
816
- headers: { 'Content-Type': 'application/json' },
817
- body: JSON.stringify({ filename, direction })
818
- })
819
- .then(res => res.json())
820
- .then(data => {
821
- if (data.success) {
822
- img.src = `/frames/final/${filename}?t=${new Date().getTime()}`;
823
- } else { alert('Error: ' + data.message); }
824
- img.style.opacity = '1';
825
- })
826
- .catch(() => {
827
- alert('An error occurred.');
828
- img.style.opacity = '1';
829
- });
830
  }
 
831
 
832
- function updateImageTransform(img) {
833
- const zoom = (img.dataset.zoom || 100) / 100;
834
- const x = img.dataset.translateX || 0;
835
- const y = img.dataset.translateY || 0;
836
- img.style.transform = `translateX(${x}px) translateY(${y}px) scale(${zoom})`;
837
- img.classList.toggle('pannable', zoom > 1);
838
  }
839
 
840
- function handleZoom(event) {
841
- if (!currentlySelectedPanel) return;
842
- const img = currentlySelectedPanel.querySelector('img');
843
- img.dataset.zoom = event.target.value;
844
- updateImageTransform(img);
845
- }
846
-
847
- function resetPanelTransform() {
848
- if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
849
- const img = currentlySelectedPanel.querySelector('img');
850
- img.dataset.zoom = 100;
851
- img.dataset.translateX = 0;
852
- img.dataset.translateY = 0;
853
- document.getElementById('zoom-slider').value = 100;
854
- updateImageTransform(img);
855
- }
856
 
857
- function startPan(event) {
858
- if (event.button !== 0) return;
859
- const img = event.target;
860
- if (parseFloat(img.dataset.zoom || 100) <= 100) return;
861
- event.preventDefault();
862
- isPanning = true;
863
- img.classList.add('panning');
864
- panStartX = event.clientX;
865
- panStartY = event.clientY;
866
- panStartTranslateX = parseFloat(img.dataset.translateX || 0);
867
- panStartTranslateY = parseFloat(img.dataset.translateY || 0);
868
  }
869
-
870
- function panImage(event) {
871
- if (!isPanning || !currentlySelectedPanel) return;
872
- const img = currentlySelectedPanel.querySelector('img');
873
- img.dataset.translateX = panStartTranslateX + (event.clientX - panStartX);
874
- img.dataset.translateY = panStartTranslateY + (event.clientY - panStartY);
875
- updateImageTransform(img);
876
  }
877
 
878
- function stopPan(event) {
879
- if (!isPanning) return;
880
- isPanning = false;
881
- currentlySelectedPanel?.querySelector('img')?.classList.remove('panning');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
882
  }
883
 
884
- function addBubbleToPanel() {
885
- if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
886
- const newBubble = createBubbleElement({
887
- id: `new-bubble-${Date.now()}`,
888
- text: 'New Text...',
889
- left: '10%',
890
- top: '10%'
891
- });
892
- currentlySelectedPanel.appendChild(newBubble);
893
- initializeBubbleEvents(newBubble);
894
- selectBubble(newBubble);
895
- editBubbleText(newBubble);
896
- }
897
-
898
- function gotoTimestamp() {
899
- if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
900
- const input = document.getElementById('timestamp-input');
901
- const timeStr = input.value.trim();
902
- if (!timeStr) return;
903
- let parsedSeconds = 0;
904
- if (timeStr.includes(':')) {
905
- const parts = timeStr.split(':');
906
- parsedSeconds = parseInt(parts[0], 10) * 60 + parseFloat(parts[1]);
907
  } else {
908
- parsedSeconds = parseFloat(timeStr);
909
  }
910
- if (isNaN(parsedSeconds)) { alert("Invalid time format."); return; }
911
- const img = currentlySelectedPanel.querySelector('img');
912
- let filename = img.src.substring(img.src.lastIndexOf('/') + 1).split('?')[0];
913
- img.style.opacity = '0.5';
914
- fetch('/goto_timestamp', {
915
- method: 'POST',
916
- headers: { 'Content-Type': 'application/json' },
917
- body: JSON.stringify({ filename, timestamp: parsedSeconds })
918
- })
919
- .then(res => res.json())
920
- .then(data => {
921
- if (data.success) {
922
- img.src = `/frames/final/${filename}?t=${new Date().getTime()}`;
923
- input.value = '';
924
- resetPanelTransform();
925
- } else { alert('Error: ' + data.message); }
926
- img.style.opacity = '1';
927
- })
928
- .catch(() => {
929
- alert('An error occurred.');
930
- img.style.opacity = '1';
931
- });
932
- }
933
- </script>
934
  </body>
935
  </html>'''
936
  with open(os.path.join(self.output_dir, 'page.html'), 'w', encoding='utf-8') as f:
@@ -944,61 +1024,87 @@ comic_generator = EnhancedComicGenerator()
944
 
945
  @app.route('/')
946
  def index():
947
- return INDEX_HTML
948
 
949
  @app.route('/uploader', methods=['POST'])
950
  def upload_file():
951
  try:
952
- if 'file' not in request.files or not request.files['file'].filename:
953
- return jsonify({'success': False, 'message': 'No file selected'}), 400
954
  f = request.files['file']
955
- if os.path.exists(comic_generator.video_path): os.remove(comic_generator.video_path)
 
956
  f.save(comic_generator.video_path)
957
- threading.Thread(target=comic_generator.generate_comic).start()
958
- return jsonify({'success': True, 'message': 'Generation started.'})
 
 
 
959
  except Exception as e:
960
  traceback.print_exc()
961
- return jsonify({'success': False, 'message': str(e)}), 500
962
-
963
- @app.route('/status')
964
- def status():
965
- status_file = os.path.join('output', 'status.json')
966
- if os.path.exists(status_file):
967
- return send_from_directory('output', 'status.json')
968
- return jsonify({'message': 'Initializing...', 'progress': 0})
969
 
970
  @app.route('/handle_link', methods=['POST'])
971
  def handle_link():
972
- # This route is disabled in the UI but remains functional
973
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
974
 
975
  @app.route('/replace_panel', methods=['POST'])
976
  def replace_panel():
977
  try:
978
- if 'image' not in request.files: return jsonify({'success': False, 'error': 'No image provided.'})
979
  file = request.files['image']
980
- filename = f"replaced_panel_{int(time.time() * 1000)}.png"
981
- file.save(os.path.join(comic_generator.frames_dir, filename))
 
 
 
982
  return jsonify({'success': True, 'new_filename': filename})
983
  except Exception as e:
 
984
  return jsonify({'success': False, 'error': str(e)})
985
 
986
  @app.route('/regenerate_frame', methods=['POST'])
987
  def regenerate_frame_route():
988
  try:
989
  data = request.get_json()
990
- result = comic_generator.regenerate_frame(data['filename'], data['direction'])
 
 
 
 
 
991
  return jsonify(result)
992
  except Exception as e:
 
993
  return jsonify({'success': False, 'message': str(e)})
994
 
995
  @app.route('/goto_timestamp', methods=['POST'])
996
  def goto_timestamp_route():
997
  try:
998
  data = request.get_json()
999
- result = comic_generator.get_frame_at_timestamp(data['filename'], float(data['timestamp']))
 
 
 
 
 
1000
  return jsonify(result)
1001
  except Exception as e:
 
1002
  return jsonify({'success': False, 'message': str(e)})
1003
 
1004
  @app.route('/comic')
 
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
  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
  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):
471
  try:
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
 
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')