Update app_enhanced.py
Browse files- app_enhanced.py +129 -173
app_enhanced.py
CHANGED
|
@@ -36,36 +36,30 @@ os.makedirs(BASE_USER_DIR, exist_ok=True)
|
|
| 36 |
# ======================================================
|
| 37 |
|
| 38 |
def create_placeholder_image(text, filename, output_dir):
|
| 39 |
-
|
| 40 |
-
img =
|
| 41 |
-
img[:] = (30, 30, 30) # Dark grey
|
| 42 |
-
|
| 43 |
-
# Text
|
| 44 |
font = cv2.FONT_HERSHEY_SIMPLEX
|
| 45 |
-
cv2.putText(img, text, (50,
|
| 46 |
-
cv2.rectangle(img, (0,0), (800,
|
| 47 |
-
|
| 48 |
path = os.path.join(output_dir, filename)
|
| 49 |
cv2.imwrite(path, img)
|
| 50 |
return filename
|
| 51 |
|
| 52 |
@spaces.GPU(duration=120)
|
| 53 |
def generate_comic_gpu(video_path, frames_dir, target_pages):
|
| 54 |
-
"""Extracts
|
| 55 |
if os.path.exists(frames_dir): shutil.rmtree(frames_dir)
|
| 56 |
os.makedirs(frames_dir, exist_ok=True)
|
| 57 |
|
| 58 |
cap = cv2.VideoCapture(video_path)
|
| 59 |
-
|
| 60 |
-
duration = 0
|
| 61 |
-
|
| 62 |
-
if cap.isOpened():
|
| 63 |
-
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 64 |
-
fps = cap.get(cv2.CAP_PROP_FPS) or 25
|
| 65 |
-
duration = total_frames / fps
|
| 66 |
-
else:
|
| 67 |
print("❌ Video load failed.")
|
|
|
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
panels_per_page = 4
|
| 70 |
total_panels_needed = int(target_pages) * panels_per_page
|
| 71 |
frame_files_ordered = []
|
|
@@ -76,14 +70,22 @@ def generate_comic_gpu(video_path, frames_dir, target_pages):
|
|
| 76 |
cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000)
|
| 77 |
ret, frame = cap.read()
|
| 78 |
fname = f"frame_{i:04d}.png"
|
|
|
|
| 79 |
if ret and frame is not None:
|
| 80 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
h, w = frame.shape[:2]
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
frame = cv2.resize(frame, (800,
|
|
|
|
| 87 |
cv2.imwrite(os.path.join(frames_dir, fname), frame)
|
| 88 |
frame_files_ordered.append(fname)
|
| 89 |
else:
|
|
@@ -110,7 +112,7 @@ def generate_comic_gpu(video_path, frames_dir, target_pages):
|
|
| 110 |
pg_panels = [{'image': f} for f in p_frames]
|
| 111 |
pg_bubbles = []
|
| 112 |
if i == 0:
|
| 113 |
-
pg_bubbles.append({'dialog': "Red
|
| 114 |
|
| 115 |
pages_data.append({
|
| 116 |
'panels': pg_panels,
|
|
@@ -132,7 +134,7 @@ class ComicGenHost:
|
|
| 132 |
|
| 133 |
def run(self, pages):
|
| 134 |
try:
|
| 135 |
-
self.write_status("Processing
|
| 136 |
data = generate_comic_gpu(self.video_path, self.frames_dir, pages)
|
| 137 |
with open(os.path.join(self.output_dir, 'data.json'), 'w') as f:
|
| 138 |
json.dump(data, f)
|
|
@@ -154,22 +156,20 @@ INDEX_HTML = '''
|
|
| 154 |
<head>
|
| 155 |
<meta charset="UTF-8">
|
| 156 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 157 |
-
<title>
|
| 158 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
|
| 159 |
<link href="https://fonts.googleapis.com/css2?family=Bangers&display=swap" rel="stylesheet">
|
| 160 |
<style>
|
| 161 |
-
body { background: #
|
| 162 |
|
| 163 |
-
/* LAYOUT */
|
| 164 |
#upload-view { padding: 50px; text-align: center; }
|
| 165 |
-
.box { background: #
|
| 166 |
-
button { background: #e74c3c; border: none; padding:
|
| 167 |
-
button:hover {
|
| 168 |
|
| 169 |
#editor-view { display: none; padding: 20px; text-align: center; }
|
| 170 |
-
.comic-container { display: flex; flex-wrap: wrap; justify-content: center; gap: 40px; margin-top: 20px; padding-bottom:
|
| 171 |
|
| 172 |
-
/* PAGE */
|
| 173 |
.comic-page {
|
| 174 |
width: 600px; height: 800px;
|
| 175 |
background: white;
|
|
@@ -179,104 +179,113 @@ INDEX_HTML = '''
|
|
| 179 |
overflow: hidden;
|
| 180 |
}
|
| 181 |
|
| 182 |
-
/* GRID
|
| 183 |
.comic-grid {
|
| 184 |
width: 100%; height: 100%;
|
| 185 |
position: relative;
|
| 186 |
background: #000;
|
|
|
|
| 187 |
--x: 50%; /* Center X */
|
| 188 |
--y: 50%; /* Center Y */
|
| 189 |
-
--xt: 50%; /* Top X
|
| 190 |
-
--xb: 50%; /* Bottom X
|
| 191 |
-
|
|
|
|
| 192 |
}
|
| 193 |
|
| 194 |
.panel {
|
| 195 |
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
| 196 |
-
overflow: hidden;
|
| 197 |
-
background: #222;
|
| 198 |
}
|
| 199 |
|
| 200 |
-
/* IMAGE ZOOM/PAN */
|
| 201 |
.panel img {
|
| 202 |
width: 100%; height: 100%;
|
| 203 |
-
object-fit: cover;
|
| 204 |
transform-origin: center;
|
| 205 |
cursor: grab;
|
| 206 |
}
|
| 207 |
.panel img:active { cursor: grabbing; }
|
| 208 |
|
| 209 |
-
/* CLIP PATHS
|
| 210 |
/* Top Left */
|
| 211 |
.panel:nth-child(1) {
|
| 212 |
-
clip-path: polygon(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
z-index: 1;
|
| 214 |
}
|
| 215 |
/* Top Right */
|
| 216 |
.panel:nth-child(2) {
|
| 217 |
-
clip-path: polygon(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
z-index: 1;
|
| 219 |
}
|
| 220 |
/* Bottom Left */
|
| 221 |
.panel:nth-child(3) {
|
| 222 |
-
clip-path: polygon(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
z-index: 1;
|
| 224 |
}
|
| 225 |
/* Bottom Right */
|
| 226 |
.panel:nth-child(4) {
|
| 227 |
-
clip-path: polygon(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
z-index: 1;
|
| 229 |
}
|
| 230 |
|
| 231 |
-
/* HANDLES */
|
| 232 |
-
.handle
|
| 233 |
-
position: absolute;
|
| 234 |
-
width: 20px; height: 20px;
|
| 235 |
-
background: #e74c3c; /* RED */
|
| 236 |
border: 2px solid white; border-radius: 50%;
|
| 237 |
-
left: var(--x); top: var(--y);
|
| 238 |
transform: translate(-50%, -50%);
|
| 239 |
-
|
| 240 |
-
box-shadow: 0
|
| 241 |
-
}
|
| 242 |
-
|
| 243 |
-
.handle-tilt {
|
| 244 |
-
position: absolute;
|
| 245 |
-
width: 16px; height: 16px;
|
| 246 |
-
background: #3498db; /* BLUE */
|
| 247 |
-
border: 2px solid white; border-radius: 50%;
|
| 248 |
-
left: var(--xt); top: 0%;
|
| 249 |
-
transform: translate(-50%, 50%); /* Just below top edge */
|
| 250 |
-
cursor: ew-resize; z-index: 999;
|
| 251 |
-
box-shadow: 0 0 5px black;
|
| 252 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
|
| 254 |
/* BUBBLES */
|
| 255 |
.bubble {
|
| 256 |
-
position: absolute;
|
| 257 |
-
|
| 258 |
-
padding: 8px 12px; border-radius: 15px;
|
| 259 |
font-family: 'Bangers', cursive; letter-spacing: 1px;
|
| 260 |
border: 2px solid black; z-index: 100; cursor: move;
|
| 261 |
-
transform: translate(-50%, -50%);
|
| 262 |
-
|
| 263 |
-
box-shadow: 4px 4px 0 rgba(0,0,0,0.2);
|
| 264 |
}
|
| 265 |
|
| 266 |
.toolbar {
|
| 267 |
-
position: fixed; bottom:
|
| 268 |
-
background: #
|
| 269 |
-
display: flex; gap:
|
|
|
|
| 270 |
}
|
| 271 |
-
.info-tip { color: #888; margin-top: 10px; font-size: 0.9em; }
|
| 272 |
</style>
|
| 273 |
</head>
|
| 274 |
<body>
|
| 275 |
|
| 276 |
<div id="upload-view">
|
| 277 |
<div class="box">
|
| 278 |
-
<h1>🎞️
|
| 279 |
-
<p>
|
| 280 |
<input type="file" id="fileIn" accept="video/*"><br><br>
|
| 281 |
<label>Pages:</label> <input type="number" id="pgCount" value="1" min="1" max="5" style="width:50px;">
|
| 282 |
<br><br>
|
|
@@ -288,22 +297,17 @@ INDEX_HTML = '''
|
|
| 288 |
<div id="editor-view">
|
| 289 |
<div class="comic-container" id="container"></div>
|
| 290 |
<div class="toolbar">
|
| 291 |
-
<button onclick="addBubble()">💬 Text</button>
|
| 292 |
-
<button onclick="downloadAll()">💾
|
| 293 |
<button style="background:#555" onclick="location.reload()">↺ Reset</button>
|
| 294 |
</div>
|
| 295 |
-
<div class="info-tip">
|
| 296 |
-
<b>Controls:</b> Red Dot = Resize Center | Blue Dot = Tilt | Scroll Image = Zoom | Drag Image = Pan
|
| 297 |
-
</div>
|
| 298 |
</div>
|
| 299 |
|
| 300 |
<script>
|
| 301 |
let sid = 'S' + Date.now();
|
| 302 |
-
let dragType = null;
|
| 303 |
let activeObj = null;
|
| 304 |
let dragStart = {x:0, y:0};
|
| 305 |
-
|
| 306 |
-
// IMAGE STATE STORAGE: { imgId: {scale:1, tx:0, ty:0} }
|
| 307 |
let imgStates = new Map();
|
| 308 |
|
| 309 |
async function startUpload() {
|
|
@@ -337,61 +341,54 @@ INDEX_HTML = '''
|
|
| 337 |
data.forEach(pg => {
|
| 338 |
let pDiv = document.createElement('div');
|
| 339 |
pDiv.className = 'comic-page';
|
| 340 |
-
|
| 341 |
let grid = document.createElement('div');
|
| 342 |
grid.className = 'comic-grid';
|
| 343 |
|
| 344 |
// Panels
|
| 345 |
-
pg.panels.forEach((pan
|
| 346 |
let div = document.createElement('div');
|
| 347 |
div.className = 'panel';
|
| 348 |
let img = document.createElement('img');
|
| 349 |
img.src = `/frames/${pan.image}?sid=${sid}`;
|
| 350 |
img.id = `img-${Math.random().toString(36).substr(2,9)}`;
|
| 351 |
|
| 352 |
-
// Initialize State
|
| 353 |
imgStates.set(img.id, {s: 1, tx: 0, ty: 0});
|
| 354 |
-
|
| 355 |
-
// Zoom Listener
|
| 356 |
img.onwheel = (e) => handleZoom(e, img);
|
| 357 |
-
// Pan Listener (Start)
|
| 358 |
img.onmousedown = (e) => {
|
| 359 |
e.preventDefault(); e.stopPropagation();
|
| 360 |
-
dragType = 'pan';
|
| 361 |
-
activeObj = img;
|
| 362 |
dragStart = {x: e.clientX, y: e.clientY};
|
| 363 |
};
|
| 364 |
-
|
| 365 |
div.appendChild(img);
|
| 366 |
grid.appendChild(div);
|
| 367 |
});
|
| 368 |
|
| 369 |
-
//
|
| 370 |
-
|
| 371 |
-
hc
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
grid.appendChild(hc);
|
| 377 |
-
|
| 378 |
-
// Tilt Handle (Blue)
|
| 379 |
-
let ht = document.createElement('div');
|
| 380 |
-
ht.className = 'handle-tilt';
|
| 381 |
-
ht.onmousedown = (e) => {
|
| 382 |
-
e.stopPropagation();
|
| 383 |
-
dragType = 'tilt'; activeObj = grid;
|
| 384 |
-
};
|
| 385 |
-
grid.appendChild(ht);
|
| 386 |
-
|
| 387 |
-
// Bubbles
|
| 388 |
-
if(pg.bubbles) pg.bubbles.forEach(b => createBubble(b.dialog, grid));
|
| 389 |
|
|
|
|
|
|
|
|
|
|
| 390 |
pDiv.appendChild(grid);
|
| 391 |
con.appendChild(pDiv);
|
| 392 |
});
|
| 393 |
}
|
| 394 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
function createBubble(txt, parent) {
|
| 396 |
let b = document.createElement('div');
|
| 397 |
b.className = 'bubble';
|
|
@@ -408,13 +405,11 @@ INDEX_HTML = '''
|
|
| 408 |
window.addBubble = () => createBubble("New Text");
|
| 409 |
|
| 410 |
// === INTERACTION LOGIC ===
|
| 411 |
-
|
| 412 |
-
// Zoom
|
| 413 |
function handleZoom(e, img) {
|
| 414 |
e.preventDefault();
|
| 415 |
let st = imgStates.get(img.id);
|
| 416 |
let delta = e.deltaY * -0.001;
|
| 417 |
-
st.s = Math.min(Math.max(0.5, st.s + delta), 5);
|
| 418 |
updateImgTransform(img);
|
| 419 |
}
|
| 420 |
|
|
@@ -423,49 +418,39 @@ INDEX_HTML = '''
|
|
| 423 |
img.style.transform = `translate(${st.tx}px, ${st.ty}px) scale(${st.s})`;
|
| 424 |
}
|
| 425 |
|
| 426 |
-
// Global Move
|
| 427 |
document.addEventListener('mousemove', (e) => {
|
| 428 |
if(!dragType) return;
|
| 429 |
|
| 430 |
-
// 1.
|
| 431 |
if(dragType === 'center') {
|
| 432 |
let rect = activeObj.getBoundingClientRect();
|
| 433 |
let x = (e.clientX - rect.left) / rect.width * 100;
|
| 434 |
let y = (e.clientY - rect.top) / rect.height * 100;
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
x = Math.max(0, Math.min(100, x));
|
| 438 |
-
y = Math.max(0, Math.min(100, y));
|
| 439 |
-
|
| 440 |
-
activeObj.style.setProperty('--x', x + '%');
|
| 441 |
-
activeObj.style.setProperty('--y', y + '%');
|
| 442 |
-
|
| 443 |
-
// Recalculate Tilt Bottom (Geometry)
|
| 444 |
-
updateTiltGeometry(activeObj);
|
| 445 |
}
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
else if(dragType === 'tilt') {
|
| 449 |
let rect = activeObj.getBoundingClientRect();
|
| 450 |
let x = (e.clientX - rect.left) / rect.width * 100;
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
activeObj.style.setProperty('--xt', x + '%');
|
| 454 |
-
updateTiltGeometry(activeObj);
|
| 455 |
}
|
| 456 |
-
|
| 457 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
else if(dragType === 'pan') {
|
| 459 |
let dx = e.clientX - dragStart.x;
|
| 460 |
let dy = e.clientY - dragStart.y;
|
| 461 |
let st = imgStates.get(activeObj.id);
|
| 462 |
-
st.tx += dx;
|
| 463 |
-
st.ty += dy;
|
| 464 |
dragStart = {x: e.clientX, y: e.clientY};
|
| 465 |
updateImgTransform(activeObj);
|
| 466 |
}
|
| 467 |
-
|
| 468 |
-
// 4. Move Bubble
|
| 469 |
else if(dragType === 'bubble') {
|
| 470 |
let rect = activeObj.parentElement.getBoundingClientRect();
|
| 471 |
activeObj.style.left = (e.clientX - rect.left) + 'px';
|
|
@@ -474,47 +459,18 @@ INDEX_HTML = '''
|
|
| 474 |
});
|
| 475 |
|
| 476 |
document.addEventListener('mouseup', () => { dragType = null; activeObj = null; });
|
| 477 |
-
|
| 478 |
-
// Calculates the Bottom X based on Top X and Center X to form a straight line
|
| 479 |
-
function updateTiltGeometry(grid) {
|
| 480 |
-
// Need to read computed styles roughly or rely on inline styles
|
| 481 |
-
// Since we set properties on the style attribute, we can parse them
|
| 482 |
-
let cx = parseFloat(grid.style.getPropertyValue('--x')) || 50;
|
| 483 |
-
let cy = parseFloat(grid.style.getPropertyValue('--y')) || 50;
|
| 484 |
-
let xt = parseFloat(grid.style.getPropertyValue('--xt')) || 50;
|
| 485 |
-
|
| 486 |
-
// Math: We have Point Top (xt, 0) and Point Center (cx, cy).
|
| 487 |
-
// We want Point Bottom (xb, 100) to be on the same line.
|
| 488 |
-
// Slope m = (cy - 0) / (cx - xt) = cy / (cx - xt)
|
| 489 |
-
// Line eq: y - 0 = m * (x - xt) => y = m(x - xt)
|
| 490 |
-
// At bottom, y = 100.
|
| 491 |
-
// 100 = (cy / (cx - xt)) * (xb - xt)
|
| 492 |
-
// 100 * (cx - xt) / cy = xb - xt
|
| 493 |
-
// xb = xt + (100/cy) * (cx - xt)
|
| 494 |
-
|
| 495 |
-
if(cy === 0) cy = 0.1; // Avoid divide by zero
|
| 496 |
-
|
| 497 |
-
let xb = xt + (100 / cy) * (cx - xt);
|
| 498 |
-
|
| 499 |
-
// Clamp visually so it doesn't fly off screen too wildly
|
| 500 |
-
// xb = Math.max(-50, Math.min(150, xb));
|
| 501 |
-
|
| 502 |
-
grid.style.setProperty('--xb', xb + '%');
|
| 503 |
-
}
|
| 504 |
|
| 505 |
window.downloadAll = async () => {
|
| 506 |
let pgs = document.querySelectorAll('.comic-page');
|
| 507 |
for(let i=0; i<pgs.length; i++) {
|
| 508 |
-
|
| 509 |
-
let handles = pgs[i].querySelectorAll('.handle-center, .handle-tilt');
|
| 510 |
handles.forEach(h => h.style.display='none');
|
| 511 |
-
|
| 512 |
let url = await htmlToImage.toPng(pgs[i]);
|
| 513 |
let a = document.createElement('a');
|
| 514 |
a.download = `comic_page_${i+1}.png`;
|
| 515 |
a.href = url;
|
| 516 |
a.click();
|
| 517 |
-
|
| 518 |
handles.forEach(h => h.style.display='block');
|
| 519 |
}
|
| 520 |
};
|
|
|
|
| 36 |
# ======================================================
|
| 37 |
|
| 38 |
def create_placeholder_image(text, filename, output_dir):
|
| 39 |
+
img = np.zeros((1200, 800, 3), dtype=np.uint8)
|
| 40 |
+
img[:] = (30, 30, 30)
|
|
|
|
|
|
|
|
|
|
| 41 |
font = cv2.FONT_HERSHEY_SIMPLEX
|
| 42 |
+
cv2.putText(img, text, (50, 600), font, 2, (200, 200, 200), 4, cv2.LINE_AA)
|
| 43 |
+
cv2.rectangle(img, (0,0), (800,1200), (100,100,100), 10)
|
|
|
|
| 44 |
path = os.path.join(output_dir, filename)
|
| 45 |
cv2.imwrite(path, img)
|
| 46 |
return filename
|
| 47 |
|
| 48 |
@spaces.GPU(duration=120)
|
| 49 |
def generate_comic_gpu(video_path, frames_dir, target_pages):
|
| 50 |
+
"""Extracts frames WITHOUT cropping them (Preserves content)."""
|
| 51 |
if os.path.exists(frames_dir): shutil.rmtree(frames_dir)
|
| 52 |
os.makedirs(frames_dir, exist_ok=True)
|
| 53 |
|
| 54 |
cap = cv2.VideoCapture(video_path)
|
| 55 |
+
if not cap.isOpened():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
print("❌ Video load failed.")
|
| 57 |
+
return []
|
| 58 |
|
| 59 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 60 |
+
fps = cap.get(cv2.CAP_PROP_FPS) or 25
|
| 61 |
+
duration = total_frames / fps
|
| 62 |
+
|
| 63 |
panels_per_page = 4
|
| 64 |
total_panels_needed = int(target_pages) * panels_per_page
|
| 65 |
frame_files_ordered = []
|
|
|
|
| 70 |
cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000)
|
| 71 |
ret, frame = cap.read()
|
| 72 |
fname = f"frame_{i:04d}.png"
|
| 73 |
+
|
| 74 |
if ret and frame is not None:
|
| 75 |
+
# FIX: Do NOT crop to square. Just resize to reasonable resolution.
|
| 76 |
+
# Maintains visual data.
|
| 77 |
+
# We resize to a standard width (e.g. 800) but keep aspect ratio relative
|
| 78 |
+
# or just force a high res fit.
|
| 79 |
+
|
| 80 |
+
# Let's resize to 800xWidth to ensure it fits nicely in panels
|
| 81 |
+
# but we won't cut pixels off.
|
| 82 |
h, w = frame.shape[:2]
|
| 83 |
+
|
| 84 |
+
# Simple Resize (Distortion is usually okay for comics,
|
| 85 |
+
# but keeping aspect ratio is better. Let's just standard resize
|
| 86 |
+
# so the frontend Object-Fit handles the rest).
|
| 87 |
+
frame = cv2.resize(frame, (800, 1000))
|
| 88 |
+
|
| 89 |
cv2.imwrite(os.path.join(frames_dir, fname), frame)
|
| 90 |
frame_files_ordered.append(fname)
|
| 91 |
else:
|
|
|
|
| 112 |
pg_panels = [{'image': f} for f in p_frames]
|
| 113 |
pg_bubbles = []
|
| 114 |
if i == 0:
|
| 115 |
+
pg_bubbles.append({'dialog': "Red: Center\nBlue: Top Tilt\nGreen: Bottom Tilt", 'x': '50%', 'y': '50%'})
|
| 116 |
|
| 117 |
pages_data.append({
|
| 118 |
'panels': pg_panels,
|
|
|
|
| 134 |
|
| 135 |
def run(self, pages):
|
| 136 |
try:
|
| 137 |
+
self.write_status("Processing...", 20)
|
| 138 |
data = generate_comic_gpu(self.video_path, self.frames_dir, pages)
|
| 139 |
with open(os.path.join(self.output_dir, 'data.json'), 'w') as f:
|
| 140 |
json.dump(data, f)
|
|
|
|
| 156 |
<head>
|
| 157 |
<meta charset="UTF-8">
|
| 158 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 159 |
+
<title>Ultimate Comic Editor</title>
|
| 160 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
|
| 161 |
<link href="https://fonts.googleapis.com/css2?family=Bangers&display=swap" rel="stylesheet">
|
| 162 |
<style>
|
| 163 |
+
body { background: #1a1a1a; color: #eee; font-family: sans-serif; margin: 0; user-select: none; }
|
| 164 |
|
|
|
|
| 165 |
#upload-view { padding: 50px; text-align: center; }
|
| 166 |
+
.box { background: #2d2d2d; display: inline-block; padding: 40px; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.5); }
|
| 167 |
+
button { background: #e74c3c; border: none; padding: 12px 24px; font-weight: bold; cursor: pointer; border-radius: 6px; font-size: 16px; color: white; margin: 5px; transition: 0.2s; }
|
| 168 |
+
button:hover { transform: scale(1.05); }
|
| 169 |
|
| 170 |
#editor-view { display: none; padding: 20px; text-align: center; }
|
| 171 |
+
.comic-container { display: flex; flex-wrap: wrap; justify-content: center; gap: 40px; margin-top: 20px; padding-bottom: 120px; }
|
| 172 |
|
|
|
|
| 173 |
.comic-page {
|
| 174 |
width: 600px; height: 800px;
|
| 175 |
background: white;
|
|
|
|
| 179 |
overflow: hidden;
|
| 180 |
}
|
| 181 |
|
| 182 |
+
/* === GRID CSS === */
|
| 183 |
.comic-grid {
|
| 184 |
width: 100%; height: 100%;
|
| 185 |
position: relative;
|
| 186 |
background: #000;
|
| 187 |
+
/* Coordinate System */
|
| 188 |
--x: 50%; /* Center X */
|
| 189 |
--y: 50%; /* Center Y */
|
| 190 |
+
--xt: 50%; /* Top Edge X */
|
| 191 |
+
--xb: 50%; /* Bottom Edge X */
|
| 192 |
+
|
| 193 |
+
--gap: 3px; /* Gap thickness */
|
| 194 |
}
|
| 195 |
|
| 196 |
.panel {
|
| 197 |
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
| 198 |
+
overflow: hidden; background: #222;
|
|
|
|
| 199 |
}
|
| 200 |
|
|
|
|
| 201 |
.panel img {
|
| 202 |
width: 100%; height: 100%;
|
| 203 |
+
object-fit: cover; /* Ensures image fills panel. User zooms/pans to adjust. */
|
| 204 |
transform-origin: center;
|
| 205 |
cursor: grab;
|
| 206 |
}
|
| 207 |
.panel img:active { cursor: grabbing; }
|
| 208 |
|
| 209 |
+
/* === DYNAMIC CLIP PATHS === */
|
| 210 |
/* Top Left */
|
| 211 |
.panel:nth-child(1) {
|
| 212 |
+
clip-path: polygon(
|
| 213 |
+
0 0,
|
| 214 |
+
calc(var(--xt) - var(--gap)) 0,
|
| 215 |
+
calc(var(--x) - var(--gap)) calc(var(--y) - var(--gap)),
|
| 216 |
+
0 calc(var(--y) - var(--gap))
|
| 217 |
+
);
|
| 218 |
z-index: 1;
|
| 219 |
}
|
| 220 |
/* Top Right */
|
| 221 |
.panel:nth-child(2) {
|
| 222 |
+
clip-path: polygon(
|
| 223 |
+
calc(var(--xt) + var(--gap)) 0,
|
| 224 |
+
100% 0,
|
| 225 |
+
100% calc(var(--y) - var(--gap)),
|
| 226 |
+
calc(var(--x) + var(--gap)) calc(var(--y) - var(--gap))
|
| 227 |
+
);
|
| 228 |
z-index: 1;
|
| 229 |
}
|
| 230 |
/* Bottom Left */
|
| 231 |
.panel:nth-child(3) {
|
| 232 |
+
clip-path: polygon(
|
| 233 |
+
0 calc(var(--y) + var(--gap)),
|
| 234 |
+
calc(var(--x) - var(--gap)) calc(var(--y) + var(--gap)),
|
| 235 |
+
calc(var(--xb) - var(--gap)) 100%,
|
| 236 |
+
0 100%
|
| 237 |
+
);
|
| 238 |
z-index: 1;
|
| 239 |
}
|
| 240 |
/* Bottom Right */
|
| 241 |
.panel:nth-child(4) {
|
| 242 |
+
clip-path: polygon(
|
| 243 |
+
calc(var(--x) + var(--gap)) calc(var(--y) + var(--gap)),
|
| 244 |
+
100% calc(var(--y) + var(--gap)),
|
| 245 |
+
100% 100%,
|
| 246 |
+
calc(var(--xb) + var(--gap)) 100%
|
| 247 |
+
);
|
| 248 |
z-index: 1;
|
| 249 |
}
|
| 250 |
|
| 251 |
+
/* === HANDLES === */
|
| 252 |
+
.handle {
|
| 253 |
+
position: absolute; width: 22px; height: 22px;
|
|
|
|
|
|
|
| 254 |
border: 2px solid white; border-radius: 50%;
|
|
|
|
| 255 |
transform: translate(-50%, -50%);
|
| 256 |
+
z-index: 999; cursor: pointer;
|
| 257 |
+
box-shadow: 0 2px 5px rgba(0,0,0,0.8);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
}
|
| 259 |
+
.handle:hover { transform: translate(-50%, -50%) scale(1.2); }
|
| 260 |
+
|
| 261 |
+
.h-center { background: #e74c3c; left: var(--x); top: var(--y); cursor: move; }
|
| 262 |
+
.h-top { background: #3498db; left: var(--xt); top: 1%; cursor: ew-resize; }
|
| 263 |
+
.h-bottom { background: #2ecc71; left: var(--xb); top: 99%; cursor: ew-resize; }
|
| 264 |
|
| 265 |
/* BUBBLES */
|
| 266 |
.bubble {
|
| 267 |
+
position: absolute; background: white; color: black;
|
| 268 |
+
padding: 8px 12px; border-radius: 12px;
|
|
|
|
| 269 |
font-family: 'Bangers', cursive; letter-spacing: 1px;
|
| 270 |
border: 2px solid black; z-index: 100; cursor: move;
|
| 271 |
+
transform: translate(-50%, -50%); text-align: center;
|
| 272 |
+
box-shadow: 3px 3px 0 rgba(0,0,0,0.2);
|
|
|
|
| 273 |
}
|
| 274 |
|
| 275 |
.toolbar {
|
| 276 |
+
position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%);
|
| 277 |
+
background: #222; padding: 12px 25px; border-radius: 50px;
|
| 278 |
+
display: flex; gap: 15px; z-index: 1000; box-shadow: 0 5px 20px rgba(0,0,0,0.6);
|
| 279 |
+
border: 1px solid #444;
|
| 280 |
}
|
|
|
|
| 281 |
</style>
|
| 282 |
</head>
|
| 283 |
<body>
|
| 284 |
|
| 285 |
<div id="upload-view">
|
| 286 |
<div class="box">
|
| 287 |
+
<h1>🎞️ Full Control Comic Maker</h1>
|
| 288 |
+
<p>Independent Upper/Lower Tilt + Full Image View</p>
|
| 289 |
<input type="file" id="fileIn" accept="video/*"><br><br>
|
| 290 |
<label>Pages:</label> <input type="number" id="pgCount" value="1" min="1" max="5" style="width:50px;">
|
| 291 |
<br><br>
|
|
|
|
| 297 |
<div id="editor-view">
|
| 298 |
<div class="comic-container" id="container"></div>
|
| 299 |
<div class="toolbar">
|
| 300 |
+
<button onclick="addBubble()">💬 Add Text</button>
|
| 301 |
+
<button onclick="downloadAll()">💾 Download</button>
|
| 302 |
<button style="background:#555" onclick="location.reload()">↺ Reset</button>
|
| 303 |
</div>
|
|
|
|
|
|
|
|
|
|
| 304 |
</div>
|
| 305 |
|
| 306 |
<script>
|
| 307 |
let sid = 'S' + Date.now();
|
| 308 |
+
let dragType = null;
|
| 309 |
let activeObj = null;
|
| 310 |
let dragStart = {x:0, y:0};
|
|
|
|
|
|
|
| 311 |
let imgStates = new Map();
|
| 312 |
|
| 313 |
async function startUpload() {
|
|
|
|
| 341 |
data.forEach(pg => {
|
| 342 |
let pDiv = document.createElement('div');
|
| 343 |
pDiv.className = 'comic-page';
|
|
|
|
| 344 |
let grid = document.createElement('div');
|
| 345 |
grid.className = 'comic-grid';
|
| 346 |
|
| 347 |
// Panels
|
| 348 |
+
pg.panels.forEach((pan) => {
|
| 349 |
let div = document.createElement('div');
|
| 350 |
div.className = 'panel';
|
| 351 |
let img = document.createElement('img');
|
| 352 |
img.src = `/frames/${pan.image}?sid=${sid}`;
|
| 353 |
img.id = `img-${Math.random().toString(36).substr(2,9)}`;
|
| 354 |
|
|
|
|
| 355 |
imgStates.set(img.id, {s: 1, tx: 0, ty: 0});
|
|
|
|
|
|
|
| 356 |
img.onwheel = (e) => handleZoom(e, img);
|
|
|
|
| 357 |
img.onmousedown = (e) => {
|
| 358 |
e.preventDefault(); e.stopPropagation();
|
| 359 |
+
dragType = 'pan'; activeObj = img;
|
|
|
|
| 360 |
dragStart = {x: e.clientX, y: e.clientY};
|
| 361 |
};
|
|
|
|
| 362 |
div.appendChild(img);
|
| 363 |
grid.appendChild(div);
|
| 364 |
});
|
| 365 |
|
| 366 |
+
// Handles
|
| 367 |
+
// 1. Center (Red)
|
| 368 |
+
let hc = createHandle('h-center', grid, 'center');
|
| 369 |
+
// 2. Top (Blue)
|
| 370 |
+
let ht = createHandle('h-top', grid, 'top');
|
| 371 |
+
// 3. Bottom (Green)
|
| 372 |
+
let hb = createHandle('h-bottom', grid, 'bottom');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
|
| 374 |
+
grid.append(hc, ht, hb);
|
| 375 |
+
|
| 376 |
+
if(pg.bubbles) pg.bubbles.forEach(b => createBubble(b.dialog, grid));
|
| 377 |
pDiv.appendChild(grid);
|
| 378 |
con.appendChild(pDiv);
|
| 379 |
});
|
| 380 |
}
|
| 381 |
|
| 382 |
+
function createHandle(cls, grid, type) {
|
| 383 |
+
let h = document.createElement('div');
|
| 384 |
+
h.className = `handle ${cls}`;
|
| 385 |
+
h.onmousedown = (e) => {
|
| 386 |
+
e.stopPropagation();
|
| 387 |
+
dragType = type; activeObj = grid;
|
| 388 |
+
};
|
| 389 |
+
return h;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
function createBubble(txt, parent) {
|
| 393 |
let b = document.createElement('div');
|
| 394 |
b.className = 'bubble';
|
|
|
|
| 405 |
window.addBubble = () => createBubble("New Text");
|
| 406 |
|
| 407 |
// === INTERACTION LOGIC ===
|
|
|
|
|
|
|
| 408 |
function handleZoom(e, img) {
|
| 409 |
e.preventDefault();
|
| 410 |
let st = imgStates.get(img.id);
|
| 411 |
let delta = e.deltaY * -0.001;
|
| 412 |
+
st.s = Math.min(Math.max(0.5, st.s + delta), 5);
|
| 413 |
updateImgTransform(img);
|
| 414 |
}
|
| 415 |
|
|
|
|
| 418 |
img.style.transform = `translate(${st.tx}px, ${st.ty}px) scale(${st.s})`;
|
| 419 |
}
|
| 420 |
|
|
|
|
| 421 |
document.addEventListener('mousemove', (e) => {
|
| 422 |
if(!dragType) return;
|
| 423 |
|
| 424 |
+
// 1. Center (Moves Intersection)
|
| 425 |
if(dragType === 'center') {
|
| 426 |
let rect = activeObj.getBoundingClientRect();
|
| 427 |
let x = (e.clientX - rect.left) / rect.width * 100;
|
| 428 |
let y = (e.clientY - rect.top) / rect.height * 100;
|
| 429 |
+
activeObj.style.setProperty('--x', clamp(x)+'%');
|
| 430 |
+
activeObj.style.setProperty('--y', clamp(y)+'%');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
}
|
| 432 |
+
// 2. Top Tilt (Moves Top Edge X)
|
| 433 |
+
else if(dragType === 'top') {
|
|
|
|
| 434 |
let rect = activeObj.getBoundingClientRect();
|
| 435 |
let x = (e.clientX - rect.left) / rect.width * 100;
|
| 436 |
+
activeObj.style.setProperty('--xt', clamp(x)+'%');
|
|
|
|
|
|
|
|
|
|
| 437 |
}
|
| 438 |
+
// 3. Bottom Tilt (Moves Bottom Edge X)
|
| 439 |
+
else if(dragType === 'bottom') {
|
| 440 |
+
let rect = activeObj.getBoundingClientRect();
|
| 441 |
+
let x = (e.clientX - rect.left) / rect.width * 100;
|
| 442 |
+
activeObj.style.setProperty('--xb', clamp(x)+'%');
|
| 443 |
+
}
|
| 444 |
+
// 4. Pan Image
|
| 445 |
else if(dragType === 'pan') {
|
| 446 |
let dx = e.clientX - dragStart.x;
|
| 447 |
let dy = e.clientY - dragStart.y;
|
| 448 |
let st = imgStates.get(activeObj.id);
|
| 449 |
+
st.tx += dx; st.ty += dy;
|
|
|
|
| 450 |
dragStart = {x: e.clientX, y: e.clientY};
|
| 451 |
updateImgTransform(activeObj);
|
| 452 |
}
|
| 453 |
+
// 5. Bubble
|
|
|
|
| 454 |
else if(dragType === 'bubble') {
|
| 455 |
let rect = activeObj.parentElement.getBoundingClientRect();
|
| 456 |
activeObj.style.left = (e.clientX - rect.left) + 'px';
|
|
|
|
| 459 |
});
|
| 460 |
|
| 461 |
document.addEventListener('mouseup', () => { dragType = null; activeObj = null; });
|
| 462 |
+
function clamp(val) { return Math.max(0, Math.min(100, val)); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
|
| 464 |
window.downloadAll = async () => {
|
| 465 |
let pgs = document.querySelectorAll('.comic-page');
|
| 466 |
for(let i=0; i<pgs.length; i++) {
|
| 467 |
+
let handles = pgs[i].querySelectorAll('.handle');
|
|
|
|
| 468 |
handles.forEach(h => h.style.display='none');
|
|
|
|
| 469 |
let url = await htmlToImage.toPng(pgs[i]);
|
| 470 |
let a = document.createElement('a');
|
| 471 |
a.download = `comic_page_${i+1}.png`;
|
| 472 |
a.href = url;
|
| 473 |
a.click();
|
|
|
|
| 474 |
handles.forEach(h => h.style.display='block');
|
| 475 |
}
|
| 476 |
};
|