|
|
import spaces |
|
|
import os |
|
|
import time |
|
|
import threading |
|
|
import json |
|
|
import traceback |
|
|
import shutil |
|
|
import cv2 |
|
|
import numpy as np |
|
|
import srt |
|
|
from flask import Flask, jsonify, request, send_from_directory, send_file |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@spaces.GPU |
|
|
def gpu_warmup(): |
|
|
import torch |
|
|
return torch.cuda.is_available() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
BASE_STORAGE_PATH = '/data' if os.path.exists('/data') else '.' |
|
|
BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata") |
|
|
os.makedirs(BASE_USER_DIR, exist_ok=True) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sanitize_json(obj): |
|
|
if isinstance(obj, dict): |
|
|
return {k: sanitize_json(v) for k, v in obj.items()} |
|
|
elif isinstance(obj, list): |
|
|
return [sanitize_json(v) for v in obj] |
|
|
elif isinstance(obj, (np.int64, np.int32, np.int16)): |
|
|
return int(obj) |
|
|
elif isinstance(obj, (np.float64, np.float32)): |
|
|
return float(obj) |
|
|
elif isinstance(obj, np.ndarray): |
|
|
return sanitize_json(obj.tolist()) |
|
|
return obj |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@spaces.GPU(duration=300) |
|
|
def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages): |
|
|
|
|
|
from backend.keyframes.keyframes import black_bar_crop |
|
|
from backend.simple_color_enhancer import SimpleColorEnhancer |
|
|
from backend.subtitles.subs_real import get_real_subtitles |
|
|
from backend.ai_bubble_placement import ai_bubble_placer |
|
|
from backend.ai_enhanced_core import face_detector |
|
|
|
|
|
cap = cv2.VideoCapture(video_path) |
|
|
fps = cap.get(cv2.CAP_PROP_FPS) or 25 |
|
|
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) |
|
|
duration = total_frames / fps |
|
|
cap.release() |
|
|
|
|
|
|
|
|
user_srt = os.path.join(user_dir, 'subs.srt') |
|
|
try: |
|
|
get_real_subtitles(video_path) |
|
|
if os.path.exists('test1.srt'): shutil.move('test1.srt', user_srt) |
|
|
except: |
|
|
with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:05,000\nDialogue...\n") |
|
|
|
|
|
with open(user_srt, 'r', encoding='utf-8') as f: |
|
|
try: all_subs = list(srt.parse(f.read())) |
|
|
except: all_subs = [] |
|
|
|
|
|
|
|
|
panels_per_page = 5 |
|
|
target_pages = int(target_pages) |
|
|
total_needed = target_pages * panels_per_page |
|
|
|
|
|
if not all_subs: |
|
|
times = np.linspace(1, max(1.1, duration-1), total_needed) |
|
|
moments = [{'text': '', 'start': t} for t in times] |
|
|
elif len(all_subs) <= total_needed: |
|
|
moments = [{'text': s.content, 'start': s.start.total_seconds()} for s in all_subs] |
|
|
while len(moments) < total_needed: moments.append({'text': '', 'start': duration/2}) |
|
|
else: |
|
|
indices = np.linspace(0, len(all_subs) - 1, total_needed, dtype=int) |
|
|
moments = [{'text': all_subs[i].content, 'start': all_subs[i].start.total_seconds()} for i in indices] |
|
|
|
|
|
|
|
|
frame_metadata = {} |
|
|
cap = cv2.VideoCapture(video_path) |
|
|
frame_files = [] |
|
|
for i, m in enumerate(moments): |
|
|
cap.set(cv2.CAP_PROP_POS_MSEC, m['start'] * 1000) |
|
|
ret, frame = cap.read() |
|
|
if ret: |
|
|
fname = f"frame_{i:04d}.png" |
|
|
p = os.path.join(frames_dir, fname) |
|
|
cv2.imwrite(p, frame) |
|
|
frame_metadata[fname] = {'dialogue': m['text'], 'time': m['start']} |
|
|
frame_files.append(fname) |
|
|
cap.release() |
|
|
|
|
|
with open(metadata_path, 'w') as f: |
|
|
json.dump(sanitize_json(frame_metadata), f) |
|
|
|
|
|
try: black_bar_crop() |
|
|
except: pass |
|
|
|
|
|
|
|
|
se = SimpleColorEnhancer() |
|
|
pages_data = [] |
|
|
for p_idx in range(target_pages): |
|
|
p_p, p_b = [], [] |
|
|
start = p_idx * 5 |
|
|
for i in range(start, start + 5): |
|
|
if i >= len(frame_files): break |
|
|
f_name = frame_files[i] |
|
|
img_p = os.path.join(frames_dir, f_name) |
|
|
try: se.enhance_single(img_p, img_p) |
|
|
except: pass |
|
|
|
|
|
txt = frame_metadata[f_name]['dialogue'] |
|
|
try: |
|
|
faces = face_detector.detect_faces(img_p) |
|
|
lip = face_detector.get_lip_position(img_p, faces[0]) if faces else (-1, -1) |
|
|
bx, by = ai_bubble_placer.place_bubble_ai(img_p, lip) |
|
|
item = {'dialog': txt, 'x': bx, 'y': by} |
|
|
except: |
|
|
item = {'dialog': txt, 'x': 50, 'y': 25} |
|
|
|
|
|
p_p.append({'image': f_name}) |
|
|
p_b.append(item) |
|
|
pages_data.append({'panels': p_p, 'bubbles': p_b}) |
|
|
|
|
|
return sanitize_json(pages_data) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app = Flask(__name__) |
|
|
|
|
|
@app.route('/') |
|
|
def index(): return INDEX_HTML |
|
|
|
|
|
@app.route('/uploader', methods=['POST']) |
|
|
def uploader(): |
|
|
sid = request.args.get('sid') |
|
|
u_dir = os.path.join(BASE_USER_DIR, sid) |
|
|
f_dir = os.path.join(u_dir, 'frames'); o_dir = os.path.join(u_dir, 'output') |
|
|
os.makedirs(f_dir, exist_ok=True); os.makedirs(o_dir, exist_ok=True) |
|
|
vid_p = os.path.join(u_dir, 'video.mp4') |
|
|
request.files['file'].save(vid_p) |
|
|
pages = request.form.get('pages', 2) |
|
|
|
|
|
def task(): |
|
|
try: |
|
|
with open(os.path.join(o_dir, 'status.json'), 'w') as f: |
|
|
json.dump({'message': 'Generating Panels...', 'progress': 30}, f) |
|
|
data = generate_comic_gpu(vid_p, u_dir, f_dir, os.path.join(f_dir, 'meta.json'), pages) |
|
|
with open(os.path.join(o_dir, 'pages.json'), 'w') as f: json.dump(data, f) |
|
|
with open(os.path.join(o_dir, 'status.json'), 'w') as f: |
|
|
json.dump({'message': 'Complete', 'progress': 100}, f) |
|
|
except Exception as e: |
|
|
with open(os.path.join(o_dir, 'status.json'), 'w') as f: |
|
|
json.dump({'message': f'Error: {str(e)}', 'progress': -1}, f) |
|
|
|
|
|
threading.Thread(target=task).start() |
|
|
return jsonify({'success': True}) |
|
|
|
|
|
@app.route('/status') |
|
|
def status(): |
|
|
sid = request.args.get('sid') |
|
|
p = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json') |
|
|
if os.path.exists(p): return send_file(p) |
|
|
return jsonify({'progress': 0}) |
|
|
|
|
|
@app.route('/frames/<sid>/<path:filename>') |
|
|
def get_frame(sid, filename): |
|
|
return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename) |
|
|
|
|
|
@app.route('/output/<sid>/<path:filename>') |
|
|
def get_output(sid, filename): |
|
|
return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
INDEX_HTML = ''' |
|
|
<!DOCTYPE html><html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8"><title>Elite Geometric Comic Maker</title> |
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Comic+Neue:wght@700&family=Lato:wght@400;900&display=swap" rel="stylesheet"> |
|
|
<style> |
|
|
/* COORDINATES INJECTED FROM YOUR TOOL */ |
|
|
:root { |
|
|
--w: 1000px; |
|
|
--h: 700px; |
|
|
--tierY: 350px; |
|
|
--gut: 5.25px; /* Half of 10.5px Gutter */ |
|
|
|
|
|
--r1-topX: 641.2px; |
|
|
--r1-botX: 588.2px; |
|
|
|
|
|
--r2L-topX: 284.2px; |
|
|
--r2L-botX: 314.2px; |
|
|
|
|
|
--r2R-topX: 618.2px; |
|
|
--r2R-botX: 678.2px; |
|
|
} |
|
|
|
|
|
body { background: #111; font-family: 'Lato', sans-serif; margin: 0; padding: 20px; color: white; } |
|
|
.setup-box { max-width: 450px; margin: 80px auto; background: white; padding: 40px; border-radius: 12px; color: black; text-align: center; } |
|
|
|
|
|
.comic-page { |
|
|
background: white; width: var(--w); height: var(--h); margin: 40px auto; |
|
|
border: 12px solid black; position: relative; overflow: hidden; |
|
|
} |
|
|
|
|
|
.panel { position: absolute; background: #000; overflow: hidden; cursor: pointer; } |
|
|
.panel img { width: 120%; height: 120%; object-fit: cover; position: absolute; top: -10%; left: -10%; pointer-events: none; } |
|
|
|
|
|
/* β‘ YOUR PRECISE GEOMETRY β‘ */ |
|
|
/* Panel 1 (Top Left) */ |
|
|
.p1 { top: 0; left: 0; width: 100%; height: var(--tierY); clip-path: polygon(0 0, calc(var(--r1-topX) - var(--gut)) 0, calc(var(--r1-botX) - var(--gut)) var(--tierY), 0 var(--tierY)); } |
|
|
/* Panel 2 (Top Right) */ |
|
|
.p2 { top: 0; left: 0; width: 100%; height: var(--tierY); clip-path: polygon(calc(var(--r1-topX) + var(--gut)) 0, var(--w) 0, var(--w) var(--tierY), calc(var(--r1-botX) + var(--gut)) var(--tierY)); } |
|
|
|
|
|
/* Panel 3 (Bottom Left) */ |
|
|
.p3 { top: calc(var(--tierY) + var(--gut)); left: 0; width: 100%; height: calc(var(--h) - var(--tierY)); clip-path: polygon(0 0, calc(var(--r2L-topX) - var(--gut)) 0, calc(var(--r2L-botX) - var(--gut)) 100%, 0 100%); } |
|
|
/* Panel 4 (Bottom Center) */ |
|
|
.p4 { top: calc(var(--tierY) + var(--gut)); left: 0; width: 100%; height: calc(var(--h) - var(--tierY)); clip-path: polygon(calc(var(--r2L-topX) + var(--gut)) 0, calc(var(--r2R-topX) - var(--gut)) 0, calc(var(--r2R-botX) - var(--gut)) 100%, calc(var(--r2L-botX) + var(--gut)) 100%); } |
|
|
/* Panel 5 (Bottom Right) */ |
|
|
.p5 { top: calc(var(--tierY) + var(--gut)); left: 0; width: 100%; height: calc(var(--h) - var(--tierY)); clip-path: polygon(calc(var(--r2R-topX) + var(--gut)) 0, var(--w) 0, var(--w) 100%, calc(var(--r2R-botX) + var(--gut)) 100%); } |
|
|
|
|
|
.bubble { |
|
|
position: absolute; background: white; border: 2.5px solid black; border-radius: 25px; |
|
|
padding: 10px 18px; font-family: 'Comic Neue'; font-weight: bold; font-size: 15px; |
|
|
color: black; min-width: 100px; text-align: center; cursor: move; z-index: 10; |
|
|
} |
|
|
|
|
|
.controls { position: fixed; bottom: 20px; right: 20px; background: #000; padding: 20px; border-radius: 12px; width: 220px; border: 2px solid #333; } |
|
|
button { width: 100%; padding: 10px; margin-top: 10px; cursor: pointer; font-weight: bold; border-radius: 6px; border: none; } |
|
|
.hidden { display: none; } |
|
|
.loader { border: 5px solid #333; border-top: 5px solid #00d2ff; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 20px auto; } |
|
|
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div id="u-zone" class="setup-box"> |
|
|
<h1>π¬ Elite Comic Maker</h1> |
|
|
<p>Using Final Dragged Coordinates</p> |
|
|
<input type="file" id="vid" accept="video/mp4"><br><br> |
|
|
<label>Total Pages: </label><input type="number" id="pg" value="2" style="width:50px"> |
|
|
<br><br> |
|
|
<button onclick="start()" style="background:#00d2ff; color:black;">π GENERATE COMIC</button> |
|
|
<div id="loading" class="hidden"><div class="loader"></div><p id="st">Acquiring GPU...</p></div> |
|
|
</div> |
|
|
|
|
|
<div id="editor-zone" class="hidden"> |
|
|
<div id="output"></div> |
|
|
<div class="controls"> |
|
|
<h4 style="margin:0; color:#00d2ff;">EDITOR</h4> |
|
|
<button onclick="addB()" style="background:#2ecc71; color:white;">π¬ Add Bubble</button> |
|
|
<button onclick="exportPNG()" style="background:#3498db; color:white;">π₯ Download PNGs</button> |
|
|
<button onclick="location.reload()" style="background:#e74c3c; color:white;">π Reset</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
let sid = 's' + Math.random().toString(36).substr(2,9); |
|
|
let selP = null; |
|
|
|
|
|
async function start() { |
|
|
const f = document.getElementById('vid').files[0]; |
|
|
if(!f) return alert("Select a video!"); |
|
|
document.getElementById('loading').classList.remove('hidden'); |
|
|
const fd = new FormData(); fd.append('file', f); fd.append('pages', document.getElementById('pg').value); |
|
|
await fetch(`/uploader?sid=${sid}`, {method: 'POST', body: fd}); |
|
|
const itv = setInterval(async () => { |
|
|
const r = await fetch(`/status?sid=${sid}`); const d = await r.json(); |
|
|
document.getElementById('st').innerText = d.message || "Working..."; |
|
|
if(d.progress >= 100) { clearInterval(itv); load(); } |
|
|
}, 2000); |
|
|
} |
|
|
|
|
|
async function load() { |
|
|
const r = await fetch(`/output/${sid}/pages.json`); const pages = await r.json(); |
|
|
document.getElementById('u-zone').classList.add('hidden'); |
|
|
document.getElementById('editor-zone').classList.remove('hidden'); |
|
|
const out = document.getElementById('output'); |
|
|
pages.forEach(p => { |
|
|
const pgDiv = document.createElement('div'); pgDiv.className = 'comic-page'; |
|
|
p.panels.forEach((pan, i) => { |
|
|
const pDiv = document.createElement('div'); pDiv.className = 'panel p' + (i+1); |
|
|
pDiv.onclick = (e) => { e.stopPropagation(); selP=pDiv; }; |
|
|
const img = document.createElement('img'); img.src = `/frames/${sid}/${pan.image}`; |
|
|
pDiv.appendChild(img); |
|
|
if(p.bubbles[i]) pDiv.appendChild(createB(p.bubbles[i].dialog, p.bubbles[i].x, p.bubbles[i].y)); |
|
|
pgDiv.appendChild(pDiv); |
|
|
}); |
|
|
out.appendChild(pgDiv); |
|
|
}); |
|
|
} |
|
|
|
|
|
function createB(txt, x, y) { |
|
|
const b = document.createElement('div'); b.className = 'bubble'; |
|
|
b.innerText = txt || '...'; b.style.left = (x || 50) + 'px'; b.style.top = (y || 20) + 'px'; |
|
|
b.onmousedown = (e) => { |
|
|
e.stopPropagation(); |
|
|
let ox = e.clientX - b.offsetLeft, oy = e.clientY - b.offsetTop; |
|
|
document.onmousemove = (ev) => { b.style.left=(ev.clientX-ox)+'px'; b.style.top=(ev.clientY-oy)+'px'; }; |
|
|
document.onmouseup = () => { document.onmousemove = null; }; |
|
|
}; |
|
|
b.ondblclick = () => { let n = prompt("Edit text:", b.innerText); if(n) b.innerText = n; }; |
|
|
return b; |
|
|
} |
|
|
|
|
|
function addB() { if(selP) selP.appendChild(createB("Dialogue", 60, 60)); } |
|
|
|
|
|
async function exportPNG() { |
|
|
const pgs = document.querySelectorAll('.comic-page'); |
|
|
for(let pg of pgs) { |
|
|
const url = await htmlToImage.toPng(pg, {pixelRatio: 2}); |
|
|
const l = document.createElement('a'); l.download='ComicPage.png'; l.href=url; l.click(); |
|
|
} |
|
|
} |
|
|
</script> |
|
|
</body></html> |
|
|
''' |
|
|
|
|
|
if __name__ == '__main__': |
|
|
try: gpu_warmup() |
|
|
except: pass |
|
|
app.run(host='0.0.0.0', port=7860) |