Testcomic / app_enhanced.py
jhh6576's picture
Update app_enhanced.py
01ee770 verified
import os
import webbrowser
import time
import threading
from flask import Flask, render_template, request, jsonify, send_from_directory, send_file
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
import srt
import json
import shutil
from typing import List
import traceback
from concurrent.futures import ThreadPoolExecutor
# --- ROBUST IMPORTS WITH FALLBACKS ---
try:
from backend.keyframes.keyframes import black_bar_crop
print("✅ Black bar cropping module loaded.")
except Exception as e:
print(f"⚠️ Could not load black_bar_crop: {e}. Cropping will be SKIPPED.")
def black_bar_crop():
return 0, 0, None, None
try:
from backend.simple_color_enhancer import SimpleColorEnhancer
print("✅ SimpleColorEnhancer loaded.")
except Exception as e:
print(f"⚠️ Could not load SimpleColorEnhancer: {e}. This feature will be SKIPPED.")
class SimpleColorEnhancer:
def enhance_batch(self, *args, **kwargs): print("-> Skipping simple color enhancement (module not loaded).")
def enhance_single(self, *args, **kwargs): print("-> Skipping simple color enhancement (module not loaded).")
try:
from backend.quality_color_enhancer import QualityColorEnhancer
print("✅ QualityColorEnhancer loaded.")
except Exception as e:
print(f"⚠️ Could not load QualityColorEnhancer: {e}. This feature will be SKIPPED.")
class QualityColorEnhancer:
def batch_enhance(self, *args, **kwargs): print("-> Skipping quality color enhancement (module not loaded).")
def enhance_single(self, *args, **kwargs): print("-> Skipping quality color enhancement (module not loaded).")
try:
from backend.class_def import bubble, panel, Page
print("✅ Core class definitions (bubble, panel, Page) loaded.")
except Exception as e:
print(f"⚠️ CRITICAL: Could not load core class definitions: {e}. Using fallback definitions.")
def bubble(**kwargs): return kwargs
def panel(**kwargs): return kwargs
class Page:
def __init__(self, panels, bubbles):
self.panels = panels
self.bubbles = bubbles
try:
from backend.ai_enhanced_core import image_processor, comic_styler, face_detector, layout_optimizer
from backend.ai_bubble_placement import ai_bubble_placer
from backend.subtitles.subs_real import get_real_subtitles
from backend.keyframes.keyframes_simple import generate_keyframes_simple
print("✅ Core utility modules loaded.")
except Exception as e:
print(f"⚠️ Could not load a core utility module: {e}")
app = Flask(__name__)
INDEX_HTML = '''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Movie to Comic Generator</title>
<style>
body {
background-color: #fdf6e3;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #3d3d3d;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
}
.container {
max-width: 500px;
width: 100%;
padding: 40px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
text-align: center;
}
h1 {
color: #2c3e50;
margin-bottom: 30px;
font-weight: 600;
}
.file-input { display: none; }
.file-label {
display: block;
padding: 15px 25px;
background-color: #2c3e50;
color: white;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
font-weight: 500;
transition: background-color 0.3s ease, transform 0.2s ease;
}
.file-label:hover {
background-color: #34495e;
transform: translateY(-2px);
}
#file-name {
display: block;
margin-top: 15px;
font-style: italic;
color: #7f8c8d;
}
.submit-btn {
width: 100%;
padding: 15px;
border: none;
border-radius: 8px;
background-color: #e67e22;
color: white;
font-size: 18px;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s ease;
margin-top: 20px;
}
.submit-btn:hover { background-color: #d35400; }
.loading-container {
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
}
.loader {
width: 120px;
height: 20px;
border-radius: 20px;
background:
radial-gradient(circle 10px, #e67e22 100%, transparent 0),
radial-gradient(circle 10px, #e67e22 100%, transparent 0),
radial-gradient(circle 10px, #e67e22 100%, transparent 0);
background-size: 20px 20px;
background-position: 0px 50%, 50px 50%, 100px 50%;
background-repeat: no-repeat;
animation:-ball 2s infinite linear;
}
@keyframes -ball {
0% {background-position: 0px 50% ,50px 50% ,100px 50%}
16% {background-position: 0px 0% ,50px 50% ,100px 50%}
33% {background-position: 0px 100% ,50px 0% ,100px 50%}
50% {background-position: 0px 50% ,50px 100% ,100px 0%}
66% {background-position: 0px 50% ,50px 50% ,100px 100%}
83% {background-position: 0px 50% ,50px 50% ,100px 50%}
100% {background-position: 0px 50% ,50px 50% ,100px 50%}
}
#status-text {
margin-top: 25px;
color: #34495e;
font-weight: 500;
font-size: 18px;
}
#final-message {
display: none;
font-size: 20px;
font-weight: bold;
color: #27ae60;
}
</style>
</head>
<body>
<div class="container" id="main-container">
<div id="upload-view">
<h1>🎬 Movie to Comic Generator</h1>
<form id="upload-form">
<label for="file-upload" class="file-label">Choose Video File</label>
<input id="file-upload" class="file-input" type="file" name="file" onchange="updateFileName(this)">
<span id="file-name">No file selected</span>
<button class="submit-btn" type="submit">Generate Comic</button>
</form>
</div>
<div class="loading-container" id="loading-view">
<div class="loader"></div>
<p id="status-text">Starting...</p>
<p id="final-message">✅ Generation Complete! Opening your comic...</p>
</div>
</div>
<script>
let statusInterval;
function updateFileName(input) {
const fileNameSpan = document.getElementById('file-name');
if (input.files && input.files.length > 0) {
fileNameSpan.textContent = input.files[0].name;
} else {
fileNameSpan.textContent = 'No file selected';
}
}
async function checkStatus() {
try {
const response = await fetch('/status');
const data = await response.json();
const statusText = document.getElementById('status-text');
statusText.textContent = data.message;
if (data.progress >= 100) {
clearInterval(statusInterval);
document.querySelector('.loader').style.display = 'none';
statusText.style.display = 'none';
document.getElementById('final-message').style.display = 'block';
setTimeout(() => {
window.open('/comic', '_blank');
}, 1500);
} else if (data.progress < 0) {
clearInterval(statusInterval);
statusText.textContent = "An error occurred. Please check the logs.";
statusText.style.color = '#e74c3c';
document.querySelector('.loader').style.display = 'none';
}
} catch (error) {
console.error("Error fetching status:", error);
document.getElementById('status-text').textContent = "Error checking status. Retrying...";
}
}
document.getElementById('upload-form').addEventListener('submit', async function(event) {
event.preventDefault();
const fileInput = document.getElementById('file-upload');
if (!fileInput.files || fileInput.files.length === 0) {
alert("Please select a video file first.");
return;
}
document.getElementById('upload-view').style.display = 'none';
document.getElementById('loading-view').style.display = 'flex';
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const response = await fetch('/uploader', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Upload failed!');
}
statusInterval = setInterval(checkStatus, 2000);
} catch (error) {
console.error("Upload error:", error);
document.getElementById('status-text').textContent = "Failed to start generation. Please try again.";
}
});
</script>
</body>
</html>
'''
os.makedirs('video', exist_ok=True)
os.makedirs('frames/final', exist_ok=True)
os.makedirs('output', exist_ok=True)
def update_status(message, progress):
status_file = os.path.join('output', 'status.json')
with open(status_file, 'w') as f:
json.dump({'message': message, 'progress': progress}, f)
class EnhancedComicGenerator:
def __init__(self):
self.video_path = 'video/uploaded.mp4'
self.frames_dir = 'frames/final'
self.output_dir = 'output'
self.apply_comic_style = False
self.video_fps = None
def cleanup_generated(self):
print("🧹 Performing full cleanup...")
if os.path.isdir(self.frames_dir): shutil.rmtree(self.frames_dir)
if os.path.isdir(self.output_dir): shutil.rmtree(self.output_dir)
if os.path.isdir('temp'): shutil.rmtree('temp')
if os.path.exists('test1.srt'): os.remove('test1.srt')
os.makedirs(self.frames_dir, exist_ok=True)
os.makedirs(self.output_dir, exist_ok=True)
print("✅ Cleanup complete.")
def regenerate_frame(self, frame_filename, direction):
try:
if not self.video_fps:
return {"success": False, "message": "Video FPS not found."}
metadata_path = 'frames/frame_metadata.json'
if not os.path.exists(metadata_path):
return {"success": False, "message": "Frame metadata missing."}
with open(metadata_path, 'r') as f:
frame_to_time = json.load(f)
if frame_filename not in frame_to_time:
return {"success": False, "message": "Panel not linked to video."}
current_time = frame_to_time[frame_filename]['time'] if isinstance(frame_to_time[frame_filename], dict) else frame_to_time[frame_filename]
frame_duration = 1.0 / self.video_fps
target_time = current_time + frame_duration if direction == 'forward' else current_time - frame_duration
target_time = max(0, target_time)
cap = cv2.VideoCapture(self.video_path)
if not cap.isOpened(): return {"success": False, "message": "Cannot open video."}
cap.set(cv2.CAP_PROP_POS_MSEC, target_time * 1000)
ret, frame = cap.read()
cap.release()
if not ret or frame is None:
return {"success": False, "message": f"No frame at {target_time:.2f}s."}
new_path = os.path.join(self.frames_dir, frame_filename)
cv2.imwrite(new_path, frame)
print(f"🎨 Applying enhancements to the new frame: {frame_filename}")
self._enhance_all_images(single_image_path=new_path)
self._enhance_quality_colors(single_image_path=new_path)
if isinstance(frame_to_time[frame_filename], dict):
frame_to_time[frame_filename]['time'] = target_time
else:
frame_to_time[frame_filename] = target_time
with open(metadata_path, 'w') as f: json.dump(frame_to_time, f, indent=2)
message = f"Adjusted {direction} to {target_time:.3f}s"
print(f"✅ {message}")
return {"success": True, "message": message, "new_filename": frame_filename}
except Exception as e:
traceback.print_exc()
return {"success": False, "message": str(e)}
def get_frame_at_timestamp(self, frame_filename, timestamp_seconds):
try:
metadata_path = 'frames/frame_metadata.json'
if not os.path.exists(metadata_path): return {"success": False, "message": "Frame metadata missing."}
cap = cv2.VideoCapture(self.video_path)
if not cap.isOpened(): return {"success": False, "message": "Cannot open video."}
fps = cap.get(cv2.CAP_PROP_FPS)
if fps == 0: fps = 25
duration = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) / fps
if timestamp_seconds < 0 or timestamp_seconds > duration:
cap.release()
return {"success": False, "message": f"Timestamp must be between 0 and {duration:.2f}s."}
cap.set(cv2.CAP_PROP_POS_MSEC, timestamp_seconds * 1000)
ret, frame = cap.read()
cap.release()
if not ret or frame is None: return {"success": False, "message": f"Could not retrieve frame at {timestamp_seconds:.2f}s."}
new_path = os.path.join(self.frames_dir, frame_filename)
cv2.imwrite(new_path, frame)
print(f"🎨 Applying enhancements to the new frame from timestamp: {frame_filename}")
self._enhance_all_images(single_image_path=new_path)
self._enhance_quality_colors(single_image_path=new_path)
with open(metadata_path, 'r') as f: frame_to_time = json.load(f)
if frame_filename in frame_to_time:
if isinstance(frame_to_time[frame_filename], dict):
frame_to_time[frame_filename]['time'] = timestamp_seconds
else:
frame_to_time[frame_filename] = timestamp_seconds
with open(metadata_path, 'w') as f: json.dump(frame_to_time, f, indent=2)
message = f"Jumped to timestamp {timestamp_seconds:.3f}s"
print(f"✅ {message}")
return { "success": True, "message": message }
except Exception as e:
traceback.print_exc()
return {"success": False, "message": str(e)}
def generate_keyframes_from_moments(self, video_path, key_moments, max_frames=32):
try:
cap = cv2.VideoCapture(video_path)
if not cap.isOpened(): raise Exception("Cannot open video for keyframe extraction")
fps, total_frames = self.video_fps, int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
duration = total_frames / fps
key_moments.sort(key=lambda x: x['start'])
if len(key_moments) > max_frames: pass # Simplified sampling
frame_metadata, frame_count = {}, 0
for i, moment in enumerate(key_moments):
update_status(f"Extracting frame {i+1}/{len(key_moments)}...", 25 + int(20 * (i / len(key_moments))))
frame_time = (moment['start'] + moment['end']) / 2
if frame_time > duration: continue
frame_number = int(frame_time * fps)
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
ret, frame = cap.read()
if ret:
frame_filename = f"frame_{frame_count:04d}.png"
frame_path = os.path.join(self.frames_dir, frame_filename)
cv2.imwrite(frame_path, frame)
frame_metadata[frame_filename] = { 'time': frame_time, 'dialogue': moment['text'], 'start': moment['start'], 'end': moment['end'] }
frame_count += 1
cap.release()
with open(os.path.join('frames', 'frame_metadata.json'), 'w') as f:
json.dump(frame_metadata, f, indent=2)
print(f"✅ Extracted {frame_count} keyframes from video")
return True
except Exception as e:
print(f"❌ Error extracting keyframes: {e}")
return False
def generate_comic(self):
start_time = time.time()
try:
update_status("Cleaning up...", 0)
self.cleanup_generated()
update_status("Analyzing video...", 5)
cap = cv2.VideoCapture(self.video_path)
if not cap.isOpened(): raise Exception("Cannot open video to get FPS.")
self.video_fps = cap.get(cv2.CAP_PROP_FPS)
if self.video_fps == 0: self.video_fps = 25
cap.release()
print(f"✅ Video FPS detected: {self.video_fps:.2f}")
update_status("Generating subtitles (this may take a while)...", 10)
get_real_subtitles(self.video_path)
with open('test1.srt', 'r', encoding='utf-8') as f:
all_subs = list(srt.parse(f.read()))
key_moments = [{'index': s.index, 'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in all_subs]
if not self.generate_keyframes_from_moments(self.video_path, key_moments, max_frames=32):
raise Exception("Keyframe extraction failed.")
update_status("Cropping black bars...", 45)
black_x, black_y, _, _ = black_bar_crop()
update_status("Enhancing images (in parallel)...", 50)
self._enhance_all_images()
self._enhance_quality_colors()
update_status("Placing speech bubbles (in parallel)...", 75)
bubbles = self._create_ai_bubbles_from_moments(black_x, black_y)
update_status("Assembling comic pages...", 90)
pages = self._generate_pages(bubbles)
update_status("Saving final comic...", 95)
self._save_results(pages)
execution_time = (time.time() - start_time) / 60
print(f"✅ Comic generation completed in {execution_time:.2f} minutes")
update_status("Complete!", 100)
return True
except Exception as e:
print(f"❌ Comic generation failed: {e}")
traceback.print_exc()
update_status(f"Error: {e}", -1)
return False
def _enhance_all_images(self, single_image_path=None):
try:
enhancer = SimpleColorEnhancer()
if single_image_path:
enhancer.enhance_single(single_image_path)
else:
frame_paths = [os.path.join(self.frames_dir, f) for f in os.listdir(self.frames_dir) if f.endswith('.png')]
with ThreadPoolExecutor() as executor:
list(executor.map(enhancer.enhance_single, frame_paths))
except Exception as e:
print(f"❌ Simple enhancement failed: {e}")
def _enhance_quality_colors(self, single_image_path=None):
try:
enhancer = QualityColorEnhancer()
if single_image_path:
enhancer.enhance_single(single_image_path)
else:
frame_paths = [os.path.join(self.frames_dir, f) for f in os.listdir(self.frames_dir) if f.endswith('.png')]
with ThreadPoolExecutor() as executor:
list(executor.map(enhancer.enhance_single, frame_paths))
except Exception as e:
print(f"⚠️ Quality enhancement failed: {e}")
def _process_bubble_for_frame(self, frame_file):
frame_path = os.path.join(self.frames_dir, frame_file)
dialogue = self.frame_metadata.get(frame_file, {}).get('dialogue', "")
try:
faces = face_detector.detect_faces(frame_path)
lip_x, lip_y = face_detector.get_lip_position(frame_path, faces[0]) if faces else (-1, -1)
bubble_x, bubble_y = ai_bubble_placer.place_bubble_ai(frame_path, (lip_x, lip_y))
return bubble(bubble_offset_x=bubble_x, bubble_offset_y=bubble_y, lip_x=lip_x, lip_y=lip_y, dialog=dialogue, emotion='normal')
except Exception as e:
print(f"-> Could not place bubble for {frame_file}: {e}. Using default.")
return bubble(bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, dialog=dialogue, emotion='normal')
def _create_ai_bubbles_from_moments(self, black_x, black_y):
frame_files = sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
metadata_path = 'frames/frame_metadata.json'
if not os.path.exists(metadata_path):
return [bubble(dialog="") for _ in frame_files]
with open(metadata_path, 'r') as f:
self.frame_metadata = json.load(f)
with ThreadPoolExecutor() as executor:
bubbles = list(executor.map(self._process_bubble_for_frame, frame_files))
return bubbles
def _generate_pages(self, bubbles):
try:
from backend.fixed_12_pages_800x1080 import generate_12_pages_800x1080
return generate_12_pages_800x1080(sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')]), bubbles)
except ImportError:
pages, frame_files = [], sorted([f for f in os.listdir(self.frames_dir) if f.endswith('.png')])
num_pages = (len(frame_files) + 3) // 4
for i in range(num_pages):
start, end = i*4, (i+1)*4
page_panels = [panel(image=f) for f in frame_files[start:end]]
page_bubbles = bubbles[start:end]
if page_panels: pages.append(Page(panels=page_panels, bubbles=page_bubbles))
return pages
def _save_results(self, pages):
try:
os.makedirs(self.output_dir, exist_ok=True)
pages_data = []
for page in pages:
panels = [p.__dict__ if hasattr(p, '__dict__') else p for p in page.panels]
bubbles_data = [b.__dict__ if hasattr(b, '__dict__') else b for b in page.bubbles]
pages_data.append({'panels': panels, 'bubbles': bubbles_data})
with open(os.path.join(self.output_dir, 'pages.json'), 'w', encoding='utf-8') as f:
json.dump(pages_data, f, indent=2)
self._copy_template_files()
print("✅ Results saved successfully!")
except Exception as e:
print(f"Save results failed: {e}")
def _copy_template_files(self):
try:
template_html = '''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Comic Editor</title>
<!-- SWAPPED TO html-to-image for PERFECT MASK SUPPORT -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Gloria+Hallelujah&family=Lato&display=swap" rel="stylesheet">
<style>
body { margin: 0; padding: 20px; background: #f0f0f0; font-family: 'Lato', sans-serif; }
.comic-container { max-width: 1200px; margin: 0 auto; }
.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; }
.comic-grid { display: grid; grid-template-columns: 285px 285px; grid-template-rows: 185px 185px; gap: 10px; width: 100%; height: 100%; }
.page-wrapper { margin: 30px auto; width: 622px; display: flex; flex-direction: column; align-items: center; }
.page-title { text-align: center; color: #333; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
.panel { position: relative; overflow: hidden; width: 100%; height: 100%; box-sizing: border-box; cursor: pointer; border: 1px solid #333; }
.panel.selected { outline: 3px solid #2196F3; outline-offset: -3px; }
.panel img { width: 100%; height: 100%; object-fit: cover; object-position: center; transition: transform 0.1s ease-out; }
.panel img.pannable { cursor: grab; }
.panel img.panning { cursor: grabbing; }
/* General Bubble Container */
.speech-bubble {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
width: 150px;
height: 80px;
min-width: 50px;
min-height: 30px;
box-sizing: border-box;
z-index: 10;
cursor: move;
overflow: visible;
font-size: 13px;
font-weight: bold;
text-align: center;
font-family: 'Comic Neue', cursive;
}
.bubble-text { padding: 0.8em; word-wrap: break-word; position: relative; z-index: 5; }
.speech-bubble.selected { outline: 2px dashed #4CAF50; }
.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; }
/* <<< USER REQUESTED EXACT CSS (#5 Shape) with Mask >>> */
/* html-to-image supports CSS masks perfectly */
.speech-bubble.speech {
--b: 3em; /* tail base width */
--h: 1.8em; /* tail height */
--t: .6; /* thickness */
--p: var(--tail-pos, 50%);
--r: 1.2em; /* radius */
--c: var(--bubble-fill-color, #4ECDC4);
background: var(--c);
color: var(--bubble-text-color, #fff);
padding: 1em;
position: absolute;
border-radius: var(--r) var(--r) min(var(--r),100% - var(--p) - (1 - var(--t))*var(--b)/2) min(var(--r),var(--p) - (1 - var(--t))*var(--b)/2)/var(--r);
}
.speech-bubble.speech:before {
content: "";
position: absolute;
width: var(--b);
height: var(--h);
background: inherit;
border-bottom-left-radius: 100%;
z-index: 1;
pointer-events: none;
/* The specific mask requested */
-webkit-mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
mask: radial-gradient(calc(var(--t)*100%) 105% at 100% 0,#0000 99%,#000 101%);
}
/* BOTTOM TAIL (Default) */
.speech-bubble.speech.tail-bottom:before {
top: 100%;
left: clamp(0%,var(--p) - (1 - var(--t))*var(--b)/2,100% - (1 - var(--t))*var(--b));
}
/* TOP TAIL (Flip Vertical) */
.speech-bubble.speech.tail-top {
border-radius: min(var(--r),var(--p) - (1 - var(--t))*var(--b)/2) min(var(--r),100% - var(--p) - (1 - var(--t))*var(--b)/2) var(--r) var(--r)/var(--r);
}
.speech-bubble.speech.tail-top:before {
bottom: 100%;
left: clamp(0%,var(--p) - (1 - var(--t))*var(--b)/2,100% - (1 - var(--t))*var(--b));
transform: scaleY(-1);
}
/* LEFT TAIL (Rotate 90) */
.speech-bubble.speech.tail-left {
border-radius: var(--r);
}
.speech-bubble.speech.tail-left:before {
right: 100%;
top: clamp(0%,var(--p) - (1 - var(--t))*var(--b)/2,100% - (1 - var(--t))*var(--b));
transform: rotate(90deg);
transform-origin: top right;
}
/* RIGHT TAIL (Rotate -90) */
.speech-bubble.speech.tail-right {
border-radius: var(--r);
}
.speech-bubble.speech.tail-right:before {
left: 100%;
top: clamp(0%,var(--p) - (1 - var(--t))*var(--b)/2,100% - (1 - var(--t))*var(--b));
transform: rotate(-90deg);
transform-origin: top left;
}
.speech-bubble.thought { background: white; border: 2px dashed #555; color: #333; border-radius: 50%; }
.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%); }
.speech-bubble.narration { background: #FAFAFA; border: 2px solid #BDBDBD; color: #424242; border-radius: 3px; }
.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%; }
.speech-bubble.thought::after { display: none; }
.thought-dot { position: absolute; background-color: white; border: 2px solid #555; border-radius: 50%; z-index: -1; }
.thought-dot-1 { width: 20px; height: 20px; bottom: -20px; left: 15px; }
.thought-dot-2 { width: 12px; height: 12px; bottom: -32px; left: 5px; }
.speech-bubble.flipped .thought-dot-1 { left: auto; right: 15px; }
.speech-bubble.flipped .thought-dot-2 { left: auto; right: 5px; }
.speech-bubble.flipped-vertical .thought-dot-1 { bottom: auto; top: -20px; }
.speech-bubble.flipped-vertical .thought-dot-2 { bottom: auto; top: -32px; }
.resize-handle { position: absolute; width: 10px; height: 10px; background: #2196F3; border: 1px solid white; border-radius: 50%; display: none; z-index: 11; }
.speech-bubble.selected .resize-handle { display: block; }
.resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
.resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
.resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
.resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
.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; }
.edit-controls h4 { margin: 0 0 10px 0; color: #26a69a; text-align: center; }
.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; }
.edit-controls .control-group { margin-top: 10px; border-top: 1px solid #555; padding-top: 10px; }
.edit-controls .reset-button { background-color: #e74c3c; }
.edit-controls .action-button { background-color: #4CAF50; }
.edit-controls .secondary-button { background-color: #f39c12; }
.button-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
.zoom-controls { display: grid; grid-template-columns: auto 1fr; gap: 5px; align-items: center;}
.timestamp-controls { display: grid; grid-template-columns: 1fr auto; gap: 5px; }
.timestamp-controls input { color: #333; font-weight: normal; }
.color-picker-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.color-picker-grid div { text-align: center; }
.color-picker-grid label { font-size: 11px; }
.color-picker-grid input[type="color"] { height: 25px; padding: 2px; }
.slider-container { display: flex; align-items: center; gap: 5px; margin-top: 5px;}
.slider-container label { font-size: 11px; min-width: 50px;}
</style>
</head>
<body>
<div class="comic-container">
<h1 class="comic-title">🎬 Generated Comic</h1>
<div id="comic-pages"><div class="loading">Loading comic...</div></div>
</div>
<input type="file" id="image-uploader" style="display: none;" accept="image/*">
<div class="edit-controls">
<h4>✏️ Interactive Editor</h4>
<div class="control-group">
<label>Bubble Tools:</label>
<select id="bubble-type-select" onchange="changeBubbleType(this.value)" disabled>
<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>
</select>
<select id="font-select" onchange="changeFont(this.value)" disabled>
<option value="'Comic Neue', cursive">Comic Neue</option>
<option value="'Bangers', cursive">Bangers</option>
<option value="'Gloria Hallelujah', cursive">Gloria</option>
<option value="'Lato', sans-serif">Lato</option>
</select>
<div class="color-picker-grid">
<div>
<label for="bubble-text-color">Text</label>
<input type="color" id="bubble-text-color" value="#ffffff" disabled>
</div>
<div>
<label for="bubble-fill-color">Fill</label>
<input type="color" id="bubble-fill-color" value="#4ECDC4" disabled>
</div>
</div>
<button onclick="addBubbleToPanel()" class="action-button">💬 Add Bubble</button>
<button onclick="deleteBubble()" class="reset-button">🗑️ Delete Bubble</button>
</div>
<div class="control-group" id="tail-controls" style="display: none;">
<label>Tail Adjustment</label>
<button onclick="rotateBubbleTail()" class="secondary-button">🔄 Rotate Side</button>
<div class="slider-container">
<label>Pos:</label>
<input type="range" id="tail-slider" min="10" max="90" value="50" oninput="slideTail(this.value)">
</div>
</div>
<div class="control-group">
<label>Panel Tools (Select Panel):</label>
<button onclick="replacePanelImage()" class="action-button">🖼️ Replace Image</button>
<div class="button-grid">
<button onclick="adjustFrame('backward')" class="secondary-button">⬅️ Prev Frame</button>
<button onclick="adjustFrame('forward')" class="action-button">Next Frame ➡️</button>
</div>
<div class="timestamp-controls">
<input type="text" id="timestamp-input" placeholder="mm:ss or secs">
<button onclick="gotoTimestamp()" class="action-button">Go</button>
</div>
</div>
<div class="control-group">
<label>Zoom & Pan (Select Panel):</label>
<div class="zoom-controls">
<button onclick="resetPanelTransform()" class="secondary-button" style="padding: 4px 6px;">Reset</button>
<input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" disabled>
</div>
</div>
<div class="control-group">
<button onclick="exportPagesToPNG()" class="action-button" style="background-color: #2196F3;">🖨️ Export Pages</button>
<button onclick="clearSavedState()" class="reset-button">🔄 Clear Edits & Reset</button>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
fetch('/output/pages.json')
.then(res => res.ok ? res.json() : Promise.reject(new Error('Failed to load pages.json')))
.then(data => { renderComic(data); initializeEditor(); })
.catch(err => { document.getElementById('comic-pages').innerHTML = `<div class="loading">Error: ${err.message}</div>`; });
});
let currentlyEditing = null, draggedBubble = null, offset = {x: 0, y: 0};
let currentlySelectedBubble = null;
let currentlySelectedPanel = null;
let isPanning = false, panStartX, panStartY, panStartTranslateX, panStartTranslateY;
let isResizing = false, resizeHandle, originalWidth, originalHeight, originalX, originalY, originalMouseX, originalMouseY;
function renderComic(data) {
const container = document.getElementById('comic-pages');
container.innerHTML = '';
data.forEach((pageData, pageIndex) => {
const pageWrapper = document.createElement('div');
pageWrapper.className = 'page-wrapper';
const pageTitleEl = document.createElement('h2');
pageTitleEl.className = 'page-title';
pageTitleEl.textContent = `Page ${pageIndex + 1}`;
pageWrapper.appendChild(pageTitleEl);
const pageDiv = document.createElement('div');
pageDiv.className = 'comic-page';
const grid = document.createElement('div');
grid.className = 'comic-grid';
pageData.panels.forEach((panelData, panelIndex) => {
const panelDiv = document.createElement('div');
panelDiv.className = 'panel';
const img = document.createElement('img');
img.src = '/frames/final/' + panelData.image;
panelDiv.appendChild(img);
if (pageData.bubbles && pageData.bubbles[panelIndex] && pageData.bubbles[panelIndex].dialog) {
const bubbleDiv = createBubbleElement({
id: `initial-${pageIndex}-${panelIndex}`,
text: pageData.bubbles[panelIndex].dialog || '',
left: `${pageData.bubbles[panelIndex].bubble_offset_x ?? 50}px`,
top: `${pageData.bubbles[panelIndex].bubble_offset_y ?? 20}px`,
});
panelDiv.appendChild(bubbleDiv);
}
grid.appendChild(panelDiv);
});
pageDiv.appendChild(grid);
pageWrapper.appendChild(pageDiv);
container.appendChild(pageWrapper);
});
}
function initializeEditor() {
document.querySelectorAll('.panel').forEach(panel => {
panel.addEventListener('click', () => selectPanel(panel));
panel.querySelector('img')?.addEventListener('mousedown', startPan);
});
document.querySelectorAll('.speech-bubble').forEach(initializeBubbleEvents);
document.getElementById('zoom-slider').addEventListener('input', handleZoom);
document.getElementById('bubble-text-color').addEventListener('input', (e) => {
if(currentlySelectedBubble) currentlySelectedBubble.style.setProperty('--bubble-text-color', e.target.value);
});
document.getElementById('bubble-fill-color').addEventListener('input', (e) => {
if(currentlySelectedBubble) {
currentlySelectedBubble.style.setProperty('--bubble-fill-color', e.target.value);
}
});
document.addEventListener('mousemove', e => { if (isPanning) panImage(e); if (draggedBubble) drag(e); if(isResizing) resizeBubble(e); });
document.addEventListener('mouseup', e => { if (isPanning) stopPan(e); if (draggedBubble) stopDrag(e); if(isResizing) stopResize(e);});
document.addEventListener('mouseleave', e => { if (isPanning) stopPan(e); if (draggedBubble) stopDrag(e); if(isResizing) stopResize(e);});
}
function initializeBubbleEvents(bubble) {
bubble.addEventListener('dblclick', e => { e.stopPropagation(); editBubbleText(bubble); });
bubble.addEventListener('mousedown', e => { e.stopPropagation(); startDrag(e); });
bubble.addEventListener('click', e => { e.stopPropagation(); selectBubble(bubble); });
['nw', 'ne', 'sw', 'se'].forEach(dir => {
const handle = document.createElement('div');
handle.className = `resize-handle ${dir}`;
bubble.appendChild(handle);
handle.addEventListener('mousedown', (e) => startResize(e, dir));
});
}
function createBubbleElement(data) {
const bubbleDiv = document.createElement('div');
bubbleDiv.dataset.id = data.id;
const textSpan = document.createElement('span');
textSpan.className = 'bubble-text';
textSpan.textContent = data.text;
bubbleDiv.appendChild(textSpan);
bubbleDiv.style.left = data.left;
bubbleDiv.style.top = data.top;
applyBubbleType(bubbleDiv, 'speech');
return bubbleDiv;
}
const rgbToHex = (rgb) => {
if (!rgb || !rgb.startsWith('rgb')) return '#ffffff';
let sep = rgb.indexOf(",") > -1 ? "," : " ";
rgb = rgb.substr(4).split(")")[0].split(sep);
let r = (+rgb[0]).toString(16), g = (+rgb[1]).toString(16), b = (+rgb[2]).toString(16);
if (r.length == 1) r = "0" + r;
if (g.length == 1) g = "0" + g;
if (b.length == 1) b = "0" + b;
return "#" + r + g + b;
}
function applyBubbleType(bubble, type) {
bubble.querySelectorAll('.thought-dot').forEach(el => el.remove());
let classesToKeep = 'speech-bubble';
if (bubble.classList.contains('selected')) classesToKeep += ' selected';
bubble.className = classesToKeep;
bubble.classList.add(type);
bubble.dataset.type = type;
if (type === 'speech') {
bubble.classList.add('tail-bottom');
bubble.dataset.tailPos = '0';
}
if (type === 'thought') {
for (let i = 2; i <= 2; i++) {
const dot = document.createElement('div');
dot.className = `thought-dot thought-dot-${i}`;
bubble.appendChild(dot);
}
}
}
function changeBubbleType(type) {
if (!currentlySelectedBubble) return;
applyBubbleType(currentlySelectedBubble, type);
selectBubble(currentlySelectedBubble);
}
function changeFont(font) {
if (!currentlySelectedBubble) return;
currentlySelectedBubble.style.fontFamily = font;
}
function rotateBubbleTail() {
if (!currentlySelectedBubble) return;
const bubbleType = currentlySelectedBubble.dataset.type;
if (bubbleType === 'speech') {
const positions = ['tail-bottom', 'tail-right', 'tail-top', 'tail-left'];
let currentPos = parseInt(currentlySelectedBubble.dataset.tailPos || 0);
currentlySelectedBubble.classList.remove(positions[currentPos]);
let nextPos = (currentPos + 1) % positions.length;
currentlySelectedBubble.classList.add(positions[nextPos]);
currentlySelectedBubble.dataset.tailPos = nextPos;
} else if (bubbleType === 'thought') {
const isFlippedH = currentlySelectedBubble.classList.contains('flipped');
const isFlippedV = currentlySelectedBubble.classList.contains('flipped-vertical');
if (!isFlippedH && !isFlippedV) { currentlySelectedBubble.classList.add('flipped'); }
else if (isFlippedH && !isFlippedV) { currentlySelectedBubble.classList.add('flipped-vertical'); }
else if (isFlippedH && isFlippedV) { currentlySelectedBubble.classList.remove('flipped'); }
else { currentlySelectedBubble.classList.remove('flipped-vertical'); }
} else {
alert("Tail rotation is only available for Speech and Thought bubbles.");
}
}
function slideTail(value) {
if(!currentlySelectedBubble) return;
currentlySelectedBubble.style.setProperty('--tail-pos', value + '%');
}
function selectPanel(panel) {
document.querySelectorAll('.panel.selected').forEach(p => p.classList.remove('selected'));
panel.classList.add('selected');
currentlySelectedPanel = panel;
selectBubble(null);
}
function selectBubble(bubble) {
if (currentlySelectedBubble) currentlySelectedBubble.classList.remove('selected');
currentlySelectedBubble = bubble;
const bubbleControls = ['bubble-text-color', 'bubble-fill-color', 'bubble-type-select', 'font-select'];
const tailControls = document.getElementById('tail-controls');
const tailSlider = document.getElementById('tail-slider');
if (currentlySelectedBubble) {
currentlySelectedBubble.classList.add('selected');
if (currentlySelectedPanel) currentlySelectedPanel.classList.remove('selected');
currentlySelectedPanel = null;
const styles = window.getComputedStyle(currentlySelectedBubble);
const textColorPicker = document.getElementById('bubble-text-color');
const currentTextColor = styles.getPropertyValue('--bubble-text-color').trim();
textColorPicker.value = currentTextColor ? currentTextColor : rgbToHex(styles.color);
const fillColorPicker = document.getElementById('bubble-fill-color');
fillColorPicker.disabled = false;
const currentFillColor = styles.getPropertyValue('--bubble-fill-color').trim();
fillColorPicker.value = currentFillColor ? currentFillColor : rgbToHex(styles.backgroundColor);
document.getElementById('bubble-type-select').value = currentlySelectedBubble.dataset.type || 'speech';
document.getElementById('font-select').value = styles.fontFamily.split(',')[0].replace(/"/g, "").replace(/'/g, "").trim();
// Read current tail pos
const currentTailPos = styles.getPropertyValue('--tail-pos').trim();
if(currentTailPos.includes('%')) {
tailSlider.value = parseInt(currentTailPos);
} else {
tailSlider.value = 50;
}
document.getElementById('zoom-slider').disabled = true;
bubbleControls.forEach(id => document.getElementById(id).disabled = false);
const bubbleType = currentlySelectedBubble.dataset.type;
if (bubbleType === 'speech' || bubbleType === 'thought') {
tailControls.style.display = 'block';
} else {
tailControls.style.display = 'none';
}
} else {
bubbleControls.forEach(id => document.getElementById(id).disabled = true);
tailControls.style.display = 'none';
if(currentlySelectedPanel) document.getElementById('zoom-slider').disabled = false;
}
}
function editBubbleText(bubble) {
if (currentlyEditing) return;
currentlyEditing = bubble;
const textSpan = bubble.querySelector('.bubble-text');
const textarea = document.createElement('textarea');
const originalHeight = bubble.offsetHeight;
bubble.style.height = `${originalHeight}px`;
textarea.value = textSpan.textContent;
bubble.appendChild(textarea);
textSpan.style.display = 'none';
textarea.focus();
const finishEditing = () => {
textSpan.textContent = textarea.value;
bubble.removeChild(textarea);
textSpan.style.display = '';
currentlyEditing = null;
};
textarea.addEventListener('blur', finishEditing, { once: true });
textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); }});
}
function startDrag(e) {
const bubble = e.target.closest('.speech-bubble');
if (!bubble || currentlyEditing) return;
draggedBubble = bubble;
selectBubble(bubble);
const rect = bubble.getBoundingClientRect();
offset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function drag(e) {
if (!draggedBubble) return;
const parentRect = draggedBubble.parentElement.getBoundingClientRect();
draggedBubble.style.left = `${e.clientX - parentRect.left - offset.x}px`;
draggedBubble.style.top = `${e.clientY - parentRect.top - offset.y}px`;
}
function stopDrag() { draggedBubble = null; }
function deleteBubble() {
if (!currentlySelectedBubble) {
alert("Please select a bubble to delete.");
return;
}
if (confirm("Are you sure you want to delete this bubble?")) {
currentlySelectedBubble.remove();
currentlySelectedBubble = null;
selectBubble(null);
}
}
function startResize(e, dir) {
e.preventDefault();
e.stopPropagation();
isResizing = true;
resizeHandle = dir;
const bubble = currentlySelectedBubble;
const rect = bubble.getBoundingClientRect();
originalWidth = rect.width;
originalHeight = rect.height;
originalX = bubble.offsetLeft;
originalY = bubble.offsetTop;
originalMouseX = e.clientX;
originalMouseY = e.clientY;
}
function resizeBubble(e) {
if (!isResizing || !currentlySelectedBubble) return;
const dx = e.clientX - originalMouseX;
const dy = e.clientY - originalMouseY;
const bubble = currentlySelectedBubble;
if (resizeHandle.includes('e')) bubble.style.width = `${originalWidth + dx}px`;
if (resizeHandle.includes('w')) {
bubble.style.width = `${originalWidth - dx}px`;
bubble.style.left = `${originalX + dx}px`;
}
if (resizeHandle.includes('s')) bubble.style.height = `${originalHeight + dy}px`;
if (resizeHandle.includes('n')) {
bubble.style.height = `${originalHeight - dy}px`;
bubble.style.top = `${originalY + dy}px`;
}
}
function stopResize() { isResizing = false; }
function clearSavedState() {
if (confirm("Reset all edits?")) {
localStorage.removeItem('comicEditorState');
window.location.reload();
}
}
async function exportPagesToPNG() {
const pages = document.querySelectorAll('.comic-page');
if (pages.length === 0) return alert("No pages found.");
// 1. FREEZE DIMENSIONS BEFORE EXPORT (Prevents size reset bug)
const bubbles = document.querySelectorAll('.speech-bubble');
bubbles.forEach(b => {
const rect = b.getBoundingClientRect();
b.style.width = rect.width + 'px';
b.style.height = rect.height + 'px';
b.style.minWidth = rect.width + 'px';
b.style.maxWidth = rect.width + 'px';
b.style.minHeight = rect.height + 'px';
b.style.maxHeight = rect.height + 'px';
});
alert(`Starting export of ${pages.length} page(s).`);
for (let i = 0; i < pages.length; i++) {
try {
// SWITCHED TO html-to-image FOR BETTER MASK SUPPORT & PIXEL RATIO
const dataUrl = await htmlToImage.toPng(pages[i], { pixelRatio: 3 });
const link = document.createElement('a');
link.download = `comic-page-${i + 1}.png`;
link.href = dataUrl;
link.click();
} catch (err) {
console.error(err);
alert(`Failed to export page ${i + 1}.`);
}
}
// 2. UNFREEZE DIMENSIONS (Restore normal behavior)
bubbles.forEach(b => {
b.style.minWidth = '50px';
b.style.minHeight = '30px';
b.style.maxWidth = '';
b.style.maxHeight = '';
});
}
function replacePanelImage() {
if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
const img = currentlySelectedPanel.querySelector('img');
const uploader = document.getElementById('image-uploader');
uploader.onchange = (event) => {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('image', file);
img.style.opacity = '0.5';
fetch('/replace_panel', { method: 'POST', body: formData })
.then(response => response.json())
.then(data => {
if (data.success) {
img.src = `/frames/final/${data.new_filename}?t=${new Date().getTime()}`;
img.style.objectFit = 'contain';
resetPanelTransform();
} else { alert('Error replacing image: ' + data.error); }
img.style.opacity = '1';
})
.catch(() => {
alert('An error occurred during the upload.');
img.style.opacity = '1';
});
uploader.value = '';
};
uploader.click();
}
function adjustFrame(direction) {
if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
const img = currentlySelectedPanel.querySelector('img');
let filename = img.src.substring(img.src.lastIndexOf('/') + 1).split('?')[0];
img.style.opacity = '0.5';
fetch('/regenerate_frame', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename, direction })
})
.then(res => res.json())
.then(data => {
if (data.success) {
img.src = `/frames/final/${filename}?t=${new Date().getTime()}`;
} else { alert('Error: ' + data.message); }
img.style.opacity = '1';
})
.catch(() => {
alert('An error occurred.');
img.style.opacity = '1';
});
}
function updateImageTransform(img) {
const zoom = (img.dataset.zoom || 100) / 100;
const x = img.dataset.translateX || 0;
const y = img.dataset.translateY || 0;
img.style.transform = `translateX(${x}px) translateY(${y}px) scale(${zoom})`;
img.classList.toggle('pannable', zoom > 1);
}
function handleZoom(event) {
if (!currentlySelectedPanel) return;
const img = currentlySelectedPanel.querySelector('img');
img.dataset.zoom = event.target.value;
updateImageTransform(img);
}
function resetPanelTransform() {
if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
const img = currentlySelectedPanel.querySelector('img');
img.dataset.zoom = 100;
img.dataset.translateX = 0;
img.dataset.translateY = 0;
document.getElementById('zoom-slider').value = 100;
updateImageTransform(img);
}
function startPan(event) {
if (event.button !== 0) return;
const img = event.target;
if (parseFloat(img.dataset.zoom || 100) <= 100) return;
event.preventDefault();
isPanning = true;
img.classList.add('panning');
panStartX = event.clientX;
panStartY = event.clientY;
panStartTranslateX = parseFloat(img.dataset.translateX || 0);
panStartTranslateY = parseFloat(img.dataset.translateY || 0);
}
function panImage(event) {
if (!isPanning || !currentlySelectedPanel) return;
const img = currentlySelectedPanel.querySelector('img');
img.dataset.translateX = panStartTranslateX + (event.clientX - panStartX);
img.dataset.translateY = panStartTranslateY + (event.clientY - panStartY);
updateImageTransform(img);
}
function stopPan() {
if (!isPanning) return;
isPanning = false;
currentlySelectedPanel?.querySelector('img')?.classList.remove('panning');
}
function addBubbleToPanel() {
if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
const newBubble = createBubbleElement({
id: `new-bubble-${Date.now()}`,
text: 'New Text...',
left: '10%',
top: '10%'
});
currentlySelectedPanel.appendChild(newBubble);
initializeBubbleEvents(newBubble);
selectBubble(newBubble);
editBubbleText(newBubble);
}
function gotoTimestamp() {
if (!currentlySelectedPanel) { alert("Please select a panel first."); return; }
const input = document.getElementById('timestamp-input');
const timeStr = input.value.trim();
if (!timeStr) return;
let parsedSeconds = 0;
if (timeStr.includes(':')) {
const parts = timeStr.split(':');
parsedSeconds = parseInt(parts[0], 10) * 60 + parseFloat(parts[1]);
} else {
parsedSeconds = parseFloat(timeStr);
}
if (isNaN(parsedSeconds)) { alert("Invalid time format."); return; }
const img = currentlySelectedPanel.querySelector('img');
let filename = img.src.substring(img.src.lastIndexOf('/') + 1).split('?')[0];
img.style.opacity = '0.5';
fetch('/goto_timestamp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename, timestamp: parsedSeconds })
})
.then(res => res.json())
.then(data => {
if (data.success) {
img.src = `/frames/final/${filename}?t=${new Date().getTime()}`;
input.value = '';
resetPanelTransform();
} else { alert('Error: ' + data.message); }
img.style.opacity = '1';
})
.catch(() => {
alert('An error occurred.');
img.style.opacity = '1';
});
}
</script>
</body>
</html>'''
# --- CORRECTED INDENTATION ---
with open(os.path.join(self.output_dir, 'page.html'), 'w', encoding='utf-8') as f:
f.write(template_html)
print("📄 Template files copied successfully!")
except Exception as e:
print(f"Template copy failed: {e}")
# --- Flask Routes ---
comic_generator = EnhancedComicGenerator()
@app.route('/')
def index():
return INDEX_HTML
@app.route('/uploader', methods=['POST'])
def upload_file():
try:
if 'file' not in request.files or not request.files['file'].filename:
return jsonify({'success': False, 'message': 'No file selected'}), 400
f = request.files['file']
if os.path.exists(comic_generator.video_path): os.remove(comic_generator.video_path)
f.save(comic_generator.video_path)
threading.Thread(target=comic_generator.generate_comic).start()
return jsonify({'success': True, 'message': 'Generation started.'})
except Exception as e:
traceback.print_exc()
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/status')
def status():
status_file = os.path.join('output', 'status.json')
if os.path.exists(status_file):
return send_from_directory('output', 'status.json')
return jsonify({'message': 'Initializing...', 'progress': 0})
@app.route('/handle_link', methods=['POST'])
def handle_link():
# This route is disabled in the UI but remains functional
pass
@app.route('/replace_panel', methods=['POST'])
def replace_panel():
try:
if 'image' not in request.files: return jsonify({'success': False, 'error': 'No image provided.'})
file = request.files['image']
filename = f"replaced_panel_{int(time.time() * 1000)}.png"
file.save(os.path.join(comic_generator.frames_dir, filename))
return jsonify({'success': True, 'new_filename': filename})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@app.route('/regenerate_frame', methods=['POST'])
def regenerate_frame_route():
try:
data = request.get_json()
result = comic_generator.regenerate_frame(data['filename'], data['direction'])
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'message': str(e)})
@app.route('/goto_timestamp', methods=['POST'])
def goto_timestamp_route():
try:
data = request.get_json()
result = comic_generator.get_frame_at_timestamp(data['filename'], float(data['timestamp']))
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'message': str(e)})
@app.route('/comic')
def view_comic():
return send_from_directory('output', 'page.html')
@app.route('/output/<path:filename>')
def output_file(filename):
return send_from_directory('output', filename)
@app.route('/frames/final/<path:filename>')
def frame_file(filename):
return send_from_directory('frames/final', filename)
if __name__ == '__main__':
port = int(os.getenv("PORT", 7860))
print(f"🚀 Starting Enhanced Comic Generator on host 0.0.0.0, port {port}")
app.run(debug=False, host='0.0.0.0', port=port)