|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
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>''' |
|
|
|
|
|
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}") |
|
|
|
|
|
|
|
|
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(): |
|
|
|
|
|
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) |