Spaces:
Running on Zero
Running on Zero
Upload app.py with huggingface_hub
Browse files
app.py
CHANGED
|
@@ -1,45 +1,47 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
"""
|
| 13 |
|
| 14 |
from __future__ import annotations
|
| 15 |
|
|
|
|
|
|
|
| 16 |
import os
|
|
|
|
| 17 |
import subprocess
|
| 18 |
import tempfile
|
| 19 |
import time
|
| 20 |
import traceback
|
| 21 |
from functools import cache
|
| 22 |
-
from html import escape
|
| 23 |
|
| 24 |
# Before anything that could initialize CUDA: `import spaces` patches `torch.cuda` so the 72 GiB load happens at
|
| 25 |
# startup rather than on GPU time.
|
| 26 |
import spaces
|
| 27 |
import gradio as gr
|
| 28 |
|
| 29 |
-
VERSION = "comic-orbit/
|
| 30 |
|
| 31 |
MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
|
| 32 |
CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
|
| 33 |
-
#
|
| 34 |
-
#
|
| 35 |
IMAGE_SPACE = os.environ.get("H3_IMAGE_SPACE", "").strip()
|
| 36 |
IMAGE_API = os.environ.get("H3_IMAGE_API", "/predict").strip()
|
|
|
|
| 37 |
|
| 38 |
PLACEMENT = os.environ.get("H3_PLACEMENT", "pack").lower()
|
| 39 |
ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
|
| 40 |
GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
|
| 41 |
|
| 42 |
-
# ── canvases (labels must match the conditioner's table; the label goes over the wire) ───────────────────────────
|
| 43 |
CANVASES = {
|
| 44 |
"960x544 · 16:9 fast": (544, 960),
|
| 45 |
"1344x768 · 16:9 full": (768, 1344),
|
|
@@ -52,20 +54,11 @@ CANVASES = {
|
|
| 52 |
"576x768 · 3:4 fast": (768, 576),
|
| 53 |
"768x1024 · 3:4 full": (1024, 768),
|
| 54 |
}
|
| 55 |
-
|
| 56 |
-
ORBIT_CANVASES = (
|
| 57 |
-
"768x768 · 1:1 full",
|
| 58 |
-
"1344x768 · 16:9 full",
|
| 59 |
-
"768x1344 · 9:16 full",
|
| 60 |
-
"1024x768 · 4:3 full",
|
| 61 |
-
"768x1024 · 3:4 full",
|
| 62 |
-
)
|
| 63 |
|
| 64 |
FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
|
| 65 |
-
# 73 = 17*4 + 5, ~3.04 s at 24 fps: the orbit LoRA's trained clip length (one full turn).
|
| 66 |
ORBIT_FRAMES = int(os.environ.get("H3_ORBIT_FRAMES", "73"))
|
| 67 |
|
| 68 |
-
# ── the orbit prompt (verbatim from the LoRA card; use as-is) ────────────────────────────────────────────────────
|
| 69 |
ORBIT_PROMPT = (
|
| 70 |
"One frozen instant. Only the camera moves. In a continuous 360 orbit. Preserve every person and object in "
|
| 71 |
"exactly the same world position, orientation, shape and pose throughout the shot. Airborne objects remain "
|
|
@@ -79,17 +72,10 @@ MOTIONS = {
|
|
| 79 |
"🎥 orbit · slow, cinematic": ORBIT_PROMPT + " A slow, smooth, cinematic orbit.",
|
| 80 |
}
|
| 81 |
DEFAULT_MOTION = list(MOTIONS)[0]
|
| 82 |
-
|
| 83 |
-
# ── speed presets: (turbo LoRA, denoise steps). Quality turns turbo off for the full 28-step base. ───────────────
|
| 84 |
-
SPEEDS = {
|
| 85 |
-
"⚡ Fast · 6 steps": ("larry", 6),
|
| 86 |
-
"✨ Balanced · 8 steps": ("lightx8", 8),
|
| 87 |
-
"💎 Quality · 28 steps": ("off", 28),
|
| 88 |
-
}
|
| 89 |
DEFAULT_SPEED = list(SPEEDS)[0]
|
| 90 |
|
| 91 |
-
|
| 92 |
-
PAGE_W, PAGE_H = 1000, 1500 # the exported page canvas (2:3 portrait, manga-ish)
|
| 93 |
LAYOUTS = {
|
| 94 |
1: [(0, 0, 1, 1)],
|
| 95 |
2: [(0, 0, 1, 0.52), (0, 0.52, 1, 0.48)],
|
|
@@ -102,7 +88,6 @@ MAX_PANELS = 6
|
|
| 102 |
|
| 103 |
|
| 104 |
def layout_for(n: int):
|
| 105 |
-
"""Cells for n panels. 1–6 use the hand-tuned manga templates; more fall back to a 2-column grid."""
|
| 106 |
if n in LAYOUTS:
|
| 107 |
return LAYOUTS[n]
|
| 108 |
rows = (n + 1) // 2
|
|
@@ -113,22 +98,21 @@ def layout_for(n: int):
|
|
| 113 |
if len(left) == 1:
|
| 114 |
cells.append((0.0, r * rh, 1.0, rh))
|
| 115 |
else:
|
| 116 |
-
cells
|
| 117 |
-
cells.append((0.5, r * rh, 0.5, rh))
|
| 118 |
return cells
|
| 119 |
|
| 120 |
|
| 121 |
OUTPUT_DIR = os.path.join(tempfile.gettempdir(), "comic-orbit-outputs")
|
| 122 |
PANEL_DIR = os.path.join(tempfile.gettempdir(), "comic-orbit-panels")
|
|
|
|
|
|
|
| 123 |
|
| 124 |
-
PIPE = None
|
| 125 |
-
MANAGER = None
|
| 126 |
LOAD_ERROR: str | None = None
|
| 127 |
LOADED_IN: float | None = None
|
| 128 |
LORA_STATUS: str | None = None
|
| 129 |
|
| 130 |
|
| 131 |
-
# ── frame-count helpers ──────────────────────────────────────────────────────
|
| 132 |
def snap_frames(seconds: float) -> int:
|
| 133 |
frames = max(1, round(float(seconds) * FPS))
|
| 134 |
while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
|
|
@@ -142,13 +126,10 @@ def lower_duration_floor(seconds: float = 2) -> None:
|
|
| 142 |
MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
|
| 143 |
|
| 144 |
|
| 145 |
-
# ── model load ───────────────────────────────────────────────────────────────
|
| 146 |
def load_models() -> str | None:
|
| 147 |
global PIPE, MANAGER, LOAD_ERROR, LOADED_IN, LORA_STATUS
|
| 148 |
-
|
| 149 |
if PIPE is not None or LOAD_ERROR is not None:
|
| 150 |
return LOAD_ERROR
|
| 151 |
-
|
| 152 |
started = time.time()
|
| 153 |
try:
|
| 154 |
import torch
|
|
@@ -168,7 +149,6 @@ def load_models() -> str | None:
|
|
| 168 |
LORA_STATUS = h3_lora.apply_lora(pipe.transformer)
|
| 169 |
if LORA_STATUS:
|
| 170 |
print(f"[{VERSION}] {LORA_STATUS}", flush=True)
|
| 171 |
-
|
| 172 |
pipe.transformer.set_attention_backend(ATTENTION)
|
| 173 |
|
| 174 |
if PLACEMENT == "pack":
|
|
@@ -182,7 +162,7 @@ def load_models() -> str | None:
|
|
| 182 |
print(f"[{VERSION}] ready in {LOADED_IN:.0f}s", flush=True)
|
| 183 |
except Exception as error:
|
| 184 |
traceback.print_exc()
|
| 185 |
-
LOAD_ERROR = f"
|
| 186 |
return LOAD_ERROR
|
| 187 |
|
| 188 |
|
|
@@ -200,7 +180,6 @@ def _arm_decode_hooks(pipe):
|
|
| 200 |
module.decode = armed
|
| 201 |
|
| 202 |
|
| 203 |
-
# ── conditioner (text encoder) over the gradio API ───────────────────────────
|
| 204 |
@cache
|
| 205 |
def conditioner():
|
| 206 |
from gradio_client import Client
|
|
@@ -225,7 +204,6 @@ def encode_remote(prompt, image_path, last_image_path, canvas, num_frames):
|
|
| 225 |
return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), handle.metadata(), plan
|
| 226 |
|
| 227 |
|
| 228 |
-
# ── GPU-time budget ──────────────────────────────────────────────────────────
|
| 229 |
_DUR_B, _DUR_C = 1.1745e-4, 3.8396e-9
|
| 230 |
_DECODE_BASE, _DECODE_PER_DEFAULT_CANVAS, _DEFAULT_CANVAS_PIXELS = 15, 15, 960 * 544 * 124
|
| 231 |
_PLACEMENT_ALLOWANCE, _PAD = 12, 10
|
|
@@ -248,13 +226,11 @@ def _spin(prompt_embeds, text_token_tags, image, last_image, height, width, num_
|
|
| 248 |
import h3_lora
|
| 249 |
|
| 250 |
active_lora = h3_lora.set_active(PIPE.transformer, lora)
|
| 251 |
-
|
| 252 |
if PLACEMENT == "lazy":
|
| 253 |
PIPE.to("cuda")
|
| 254 |
elif PLACEMENT == "pack":
|
| 255 |
PIPE.vae.to("cuda")
|
| 256 |
PIPE.audio_vae.to("cuda")
|
| 257 |
-
|
| 258 |
state = PIPE(
|
| 259 |
prompt_embeds=prompt_embeds.to("cuda"),
|
| 260 |
text_token_tags=text_token_tags,
|
|
@@ -270,7 +246,6 @@ def _spin(prompt_embeds, text_token_tags, image, last_image, height, width, num_
|
|
| 270 |
|
| 271 |
|
| 272 |
def fit_panel(image_path: str) -> tuple[str, str]:
|
| 273 |
-
"""Cover-crop the panel to the nearest 768-class orbit canvas and return (cropped path, canvas label)."""
|
| 274 |
from PIL import Image, ImageOps
|
| 275 |
|
| 276 |
img = ImageOps.exif_transpose(Image.open(image_path)).convert("RGB")
|
|
@@ -280,90 +255,39 @@ def fit_panel(image_path: str) -> tuple[str, str]:
|
|
| 280 |
target = w / h
|
| 281 |
if abs(aspect - target) > 1e-3:
|
| 282 |
if aspect > target:
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
img = img.crop((left, 0, left + new_w, img.height))
|
| 286 |
else:
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
os.makedirs(PANEL_DIR, exist_ok=True)
|
| 291 |
-
out = os.path.join(PANEL_DIR, f"panel-fit-{int(time.time() * 1000)}.png")
|
| 292 |
img.save(out)
|
| 293 |
return out, label
|
| 294 |
|
| 295 |
|
| 296 |
def spin_panel(panel_path, scene_hint, speed, motion, seed) -> str:
|
| 297 |
-
"""Spin one panel into a 360° orbit clip. Returns the clip path."""
|
| 298 |
if LOAD_ERROR:
|
| 299 |
-
raise gr.Error(LOAD_ERROR
|
| 300 |
if PIPE is None:
|
| 301 |
-
raise gr.Error("The studio is still warming up —
|
| 302 |
-
|
| 303 |
from PIL import Image, ImageOps
|
| 304 |
from diffusers.utils import encode_video
|
| 305 |
|
| 306 |
lora, steps = SPEEDS.get(speed, SPEEDS[DEFAULT_SPEED])
|
| 307 |
base = MOTIONS.get(motion, ORBIT_PROMPT)
|
| 308 |
prompt = f"{base} The scene: {scene_hint.strip()}." if (scene_hint and scene_hint.strip()) else base
|
| 309 |
-
|
| 310 |
panel, canvas = fit_panel(panel_path)
|
| 311 |
num_frames = snap_frames(ORBIT_FRAMES / FPS)
|
| 312 |
prompt_embeds, text_token_tags, metadata, plan = encode_remote(prompt, panel, panel, canvas, num_frames)
|
| 313 |
-
height, width, num_frames = (int(metadata[
|
| 314 |
keyframe = ImageOps.exif_transpose(Image.open(panel)).convert("RGB")
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
frames,
|
| 318 |
-
|
| 319 |
-
)
|
| 320 |
-
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 321 |
-
path = os.path.join(OUTPUT_DIR, f"orbit-{int(time.time() * 1000)}.mp4")
|
| 322 |
-
encode_video(frames, fps=FPS, output_path=path) # silent: an orbit is a frozen scene
|
| 323 |
-
print(f"[{VERSION}] panel spun {width}x{height} {num_frames}f {steps}st turbo={active_lora} in {time.time()-started:.0f}s", flush=True)
|
| 324 |
return path
|
| 325 |
|
| 326 |
|
| 327 |
-
# ── panel sources: sketch / describe / upload ────────────────────────────────
|
| 328 |
-
def save_panel(img) -> str:
|
| 329 |
-
from PIL import Image
|
| 330 |
-
|
| 331 |
-
os.makedirs(PANEL_DIR, exist_ok=True)
|
| 332 |
-
path = os.path.join(PANEL_DIR, f"panel-{int(time.time() * 1000)}.png")
|
| 333 |
-
(img if isinstance(img, Image.Image) else Image.open(img)).convert("RGB").save(path)
|
| 334 |
-
return path
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
def sketch_to_panel(editor_value, refine: bool, prompt: str) -> str:
|
| 338 |
-
from PIL import Image
|
| 339 |
-
|
| 340 |
-
if not editor_value:
|
| 341 |
-
raise gr.Error("Draw something on the canvas first.")
|
| 342 |
-
base = editor_value.get("composite") or editor_value.get("background")
|
| 343 |
-
if base is None:
|
| 344 |
-
raise gr.Error("The canvas is empty.")
|
| 345 |
-
flat = base.convert("RGB") if isinstance(base, Image.Image) else Image.open(base).convert("RGB")
|
| 346 |
-
for layer in editor_value.get("layers") or []:
|
| 347 |
-
lay = layer if isinstance(layer, Image.Image) else Image.open(layer)
|
| 348 |
-
if lay.mode == "RGBA":
|
| 349 |
-
flat.paste(lay, (0, 0), lay)
|
| 350 |
-
path = save_panel(flat)
|
| 351 |
-
if refine and IMAGE_SPACE:
|
| 352 |
-
path = image_space_call(prompt or "clean comic panel line art, inked, high detail", init_image=path)
|
| 353 |
-
return path
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
def describe_to_panel(prompt: str) -> str:
|
| 357 |
-
if not IMAGE_SPACE:
|
| 358 |
-
raise gr.Error(
|
| 359 |
-
"The Describe tab needs an image Space. Set the `H3_IMAGE_SPACE` secret to a gradio t2i Space, "
|
| 360 |
-
"or use the Sketch / Upload tabs."
|
| 361 |
-
)
|
| 362 |
-
if not prompt or not prompt.strip():
|
| 363 |
-
raise gr.Error("Describe the panel you want.")
|
| 364 |
-
return image_space_call(f"comic book panel, bold ink lines, dynamic composition — {prompt.strip()}")
|
| 365 |
-
|
| 366 |
-
|
| 367 |
def image_space_call(prompt: str, init_image: str | None = None) -> str:
|
| 368 |
from gradio_client import Client, handle_file
|
| 369 |
|
|
@@ -375,52 +299,63 @@ def image_space_call(prompt: str, init_image: str | None = None) -> str:
|
|
| 375 |
result = client.predict(**kwargs, api_name=IMAGE_API)
|
| 376 |
except Exception as error:
|
| 377 |
traceback.print_exc()
|
| 378 |
-
raise gr.Error(f"
|
| 379 |
while isinstance(result, (list, tuple)) and result:
|
| 380 |
result = result[0]
|
| 381 |
if isinstance(result, dict):
|
| 382 |
result = result.get("path") or result.get("image") or result.get("url")
|
| 383 |
if not isinstance(result, str):
|
| 384 |
-
raise gr.Error(f"Could not read an image
|
| 385 |
-
return
|
| 386 |
|
| 387 |
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
return f"/gradio_api/file={path}"
|
| 391 |
|
|
|
|
|
|
|
|
|
|
| 392 |
|
| 393 |
-
EMPTY_PAGE = """<div class="comic-stage"><div class="comic-empty">
|
| 394 |
-
<div class="comic-empty-inner"><span class="big">Your comic page</span>
|
| 395 |
-
<span>Add panels on the left, then hit <b>Render the comic</b>.<br>Each panel will come alive with a 360° orbit.</span></div>
|
| 396 |
-
</div></div>"""
|
| 397 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
media = (
|
| 412 |
-
f'<video src="{_file_url(clip)}" autoplay loop muted playsinline '
|
| 413 |
-
f'onclick="this.paused?this.play():this.pause()"></video>'
|
| 414 |
-
)
|
| 415 |
-
tag = ""
|
| 416 |
-
elif still:
|
| 417 |
-
media = f'<img src="{_file_url(still)}">'
|
| 418 |
-
tag = '<span class="wait">press Render</span>'
|
| 419 |
else:
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
|
| 425 |
|
| 426 |
def _even(v: float) -> int:
|
|
@@ -429,11 +364,9 @@ def _even(v: float) -> int:
|
|
| 429 |
|
| 430 |
|
| 431 |
def composite_page_video(panels: list[dict]) -> str:
|
| 432 |
-
"""Burn the whole page into one MP4 — every panel playing its orbit at once, in the manga layout. Panels not yet
|
| 433 |
-
spun are shown as a 3 s still. Returns the mp4 path (or raises gr.Error)."""
|
| 434 |
panels = [p for p in (panels or []) if p.get("clip") or p.get("still")]
|
| 435 |
if not panels:
|
| 436 |
-
raise gr.Error("
|
| 437 |
rects = layout_for(len(panels))
|
| 438 |
dur = ORBIT_FRAMES / FPS
|
| 439 |
args = ["ffmpeg", "-y", "-f", "lavfi", "-i", f"color=c=#f4ead6:s={PAGE_W}x{PAGE_H}:d={dur:.3f}:r={FPS}"]
|
|
@@ -442,203 +375,257 @@ def composite_page_video(panels: list[dict]) -> str:
|
|
| 442 |
args += ["-i", p["clip"]]
|
| 443 |
else:
|
| 444 |
args += ["-loop", "1", "-t", f"{dur:.3f}", "-i", p["still"]]
|
| 445 |
-
b = 7
|
| 446 |
fc, prev = "", "[0:v]"
|
| 447 |
for i, (p, (x, y, w, h)) in enumerate(zip(panels, rects)):
|
| 448 |
-
cw, ch = _even(w * PAGE_W), _even(h * PAGE_H)
|
| 449 |
-
cx, cy = int(x * PAGE_W), int(y * PAGE_H)
|
| 450 |
iw, ih = cw - 2 * b, ch - 2 * b
|
| 451 |
-
fc +=
|
| 452 |
-
f"[{i+1}:v]scale={iw}:{ih}:force_original_aspect_ratio=increase,"
|
| 453 |
-
f"crop={iw}:{ih},setsar=1[v{i}];"
|
| 454 |
-
)
|
| 455 |
out = f"[o{i}]"
|
| 456 |
fc += f"{prev}[v{i}]overlay={cx+b}:{cy+b}{out};"
|
| 457 |
prev = out
|
| 458 |
for i, (x, y, w, h) in enumerate(rects):
|
| 459 |
-
cw, ch = _even(w * PAGE_W), _even(h * PAGE_H)
|
| 460 |
-
cx, cy = int(x * PAGE_W), int(y * PAGE_H)
|
| 461 |
out = f"[b{i}]"
|
| 462 |
fc += f"{prev}drawbox=x={cx}:y={cy}:w={cw}:h={ch}:color=#141018:t={b}{out};"
|
| 463 |
prev = out
|
| 464 |
fc = fc.rstrip(";")
|
| 465 |
-
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 466 |
path = os.path.join(OUTPUT_DIR, f"comic-page-{int(time.time()*1000)}.mp4")
|
| 467 |
args += ["-filter_complex", fc, "-map", prev, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", str(FPS), "-t", f"{dur:.3f}", path]
|
| 468 |
try:
|
| 469 |
subprocess.run(args, check=True, capture_output=True, timeout=180)
|
| 470 |
except subprocess.CalledProcessError as error:
|
| 471 |
print(error.stderr.decode()[-1500:], flush=True)
|
| 472 |
-
raise gr.Error("Could not compose the page video.
|
| 473 |
return path
|
| 474 |
|
| 475 |
|
| 476 |
-
# ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
load_models()
|
| 478 |
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
.
|
| 493 |
-
.
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
.
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
.
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
"""
|
| 526 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
|
| 528 |
-
|
| 529 |
-
""
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
def add_panel(panels, still, caption):
|
| 534 |
-
panels = list(panels or [])
|
| 535 |
-
if not still:
|
| 536 |
-
raise gr.Error("Nothing to add.")
|
| 537 |
-
if len(panels) >= MAX_PANELS:
|
| 538 |
-
raise gr.Error(f"This page holds up to {MAX_PANELS} panels. Render or clear it first.")
|
| 539 |
-
panels.append({"still": still, "clip": None, "caption": (caption or "").strip()})
|
| 540 |
-
return panels, _thumbs(panels), render_comic_html(panels), f"{len(panels)} panel(s) on the page."
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
def clear_page(_panels):
|
| 544 |
-
return [], _thumbs([]), render_comic_html([]), "Cleared."
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
with gr.Blocks(title="Comic Orbit Studio", css=CSS) as demo:
|
| 548 |
-
gr.HTML(HERO)
|
| 549 |
-
|
| 550 |
-
panels_state = gr.State([])
|
| 551 |
-
|
| 552 |
-
with gr.Row(equal_height=False):
|
| 553 |
-
# ── left: storyboard builder ──
|
| 554 |
-
with gr.Column(scale=4):
|
| 555 |
-
gr.Markdown("### 1 · Build the page")
|
| 556 |
-
with gr.Tabs():
|
| 557 |
-
with gr.Tab("💬 Describe"):
|
| 558 |
-
describe_prompt = gr.Textbox(label="Describe the panel", lines=2,
|
| 559 |
-
placeholder="a lone samurai on a rooftop at dusk, rain, neon reflections")
|
| 560 |
-
describe_btn = gr.Button("+ Add panel", variant="primary", elem_classes=["pop"])
|
| 561 |
-
if not IMAGE_SPACE:
|
| 562 |
-
gr.Markdown("<span style='opacity:.6'>Set the `H3_IMAGE_SPACE` secret to enable text-to-panel.</span>")
|
| 563 |
-
with gr.Tab("✏️ Sketch"):
|
| 564 |
-
sketch = gr.ImageEditor(label="Draw a panel", type="pil", image_mode="RGBA",
|
| 565 |
-
brush=gr.Brush(colors=["#141018", "#ff2e63", "#2ec5ff", "#ffffff"], color_mode="fixed", default_size=6),
|
| 566 |
-
height=360, canvas_size=(768, 768))
|
| 567 |
-
sketch_refine = gr.Checkbox(label="Refine with AI (needs an image Space)", value=False, visible=bool(IMAGE_SPACE))
|
| 568 |
-
sketch_prompt = gr.Textbox(label="Refine hint", placeholder="inked manga panel, screentone", visible=bool(IMAGE_SPACE))
|
| 569 |
-
sketch_btn = gr.Button("+ Add panel", variant="primary", elem_classes=["pop"])
|
| 570 |
-
with gr.Tab("⬆️ Upload"):
|
| 571 |
-
upload = gr.Image(label="Upload a panel", type="filepath", height=300)
|
| 572 |
-
upload_btn = gr.Button("+ Add panel", variant="primary", elem_classes=["pop"])
|
| 573 |
-
|
| 574 |
-
caption = gr.Textbox(label="Panel caption (optional)", placeholder="“A dragon!”", lines=1)
|
| 575 |
-
gr.Markdown("#### Storyboard")
|
| 576 |
-
strip = gr.Gallery(label=None, columns=6, height=96, object_fit="cover", show_label=False,
|
| 577 |
-
allow_preview=False, elem_classes=["strip"])
|
| 578 |
-
|
| 579 |
-
gr.Markdown("### 2 · Direction")
|
| 580 |
-
motion = gr.Dropdown(label="Camera motion", choices=list(MOTIONS), value=DEFAULT_MOTION)
|
| 581 |
-
speed = gr.Radio(label="Speed", choices=list(SPEEDS), value=DEFAULT_SPEED)
|
| 582 |
-
with gr.Accordion("Advanced", open=False):
|
| 583 |
-
seed = gr.Number(label="Seed", value=42, precision=0)
|
| 584 |
-
with gr.Row():
|
| 585 |
-
clear_btn = gr.Button("🗑 Clear page")
|
| 586 |
-
render_btn = gr.Button("🎬 Render the comic", variant="primary", elem_classes=["pop", "render"], scale=2)
|
| 587 |
-
download_btn = gr.Button("⬇️ Download as video", variant="secondary")
|
| 588 |
-
status = gr.Markdown("Add a panel to begin.")
|
| 589 |
-
page_video = gr.File(label="Comic page video", visible=False)
|
| 590 |
-
|
| 591 |
-
# ── right: the comic page ──
|
| 592 |
-
with gr.Column(scale=6):
|
| 593 |
-
gr.Markdown("### Your comic")
|
| 594 |
-
comic = gr.HTML(EMPTY_PAGE)
|
| 595 |
-
|
| 596 |
-
# ── wiring ──
|
| 597 |
-
# each add-panel handler produces a still (held in a scratch State), then appends it to the page
|
| 598 |
-
still_a, still_b, still_c = gr.State(None), gr.State(None), gr.State(None)
|
| 599 |
-
describe_btn.click(describe_to_panel, describe_prompt, still_a, show_progress="full").then(
|
| 600 |
-
add_panel, [panels_state, still_a, caption], [panels_state, strip, comic, status])
|
| 601 |
-
sketch_btn.click(sketch_to_panel, [sketch, sketch_refine, sketch_prompt], still_b, show_progress="full").then(
|
| 602 |
-
add_panel, [panels_state, still_b, caption], [panels_state, strip, comic, status])
|
| 603 |
-
upload_btn.click(lambda p: save_panel(p) if p else None, upload, still_c).then(
|
| 604 |
-
add_panel, [panels_state, still_c, caption], [panels_state, strip, comic, status])
|
| 605 |
-
|
| 606 |
-
clear_btn.click(clear_page, panels_state, [panels_state, strip, comic, status])
|
| 607 |
-
|
| 608 |
-
def render_comic(panels, motion, speed, seed, progress=gr.Progress()):
|
| 609 |
-
"""Spin every un-spun panel and drop its clip into the page as it finishes (progressive)."""
|
| 610 |
-
panels = [dict(p) for p in (panels or [])]
|
| 611 |
-
if not panels:
|
| 612 |
-
raise gr.Error("Add at least one panel to the page first.")
|
| 613 |
-
todo = [i for i, p in enumerate(panels) if not p.get("clip")]
|
| 614 |
-
for k, i in enumerate(todo):
|
| 615 |
-
progress((k) / max(1, len(todo)), desc=f"Spinning panel {i+1} of {len(panels)} ...")
|
| 616 |
-
clip = spin_panel(panels[i]["still"], panels[i].get("caption", ""), speed, motion, seed)
|
| 617 |
-
panels[i]["clip"] = clip
|
| 618 |
-
yield panels, render_comic_html(panels), f"Panel {i+1} done ({k+1}/{len(todo)}).", gr.update(visible=False)
|
| 619 |
-
progress(1.0, desc="Page complete")
|
| 620 |
-
yield panels, render_comic_html(panels), "✅ Comic rendered — every panel is playable. Download it as a video below.", gr.update(visible=False)
|
| 621 |
-
|
| 622 |
-
render_btn.click(render_comic, [panels_state, motion, speed, seed], [panels_state, comic, status, page_video])
|
| 623 |
-
|
| 624 |
-
def make_video(panels):
|
| 625 |
-
path = composite_page_video(panels)
|
| 626 |
-
return gr.update(value=path, visible=True), "🎞️ Page video ready — download below."
|
| 627 |
-
|
| 628 |
-
download_btn.click(make_video, panels_state, [page_video, status])
|
| 629 |
-
|
| 630 |
-
# scriptable endpoint for API callers / smoke tests: one still path -> one orbit clip
|
| 631 |
-
api_still = gr.Image(type="filepath", visible=False)
|
| 632 |
-
api_speed = gr.Textbox(visible=False, value=DEFAULT_SPEED)
|
| 633 |
-
api_motion = gr.Textbox(visible=False, value=DEFAULT_MOTION)
|
| 634 |
-
api_hint = gr.Textbox(visible=False, value="")
|
| 635 |
-
api_seed = gr.Number(visible=False, value=42, precision=0)
|
| 636 |
-
api_out = gr.Video(visible=False)
|
| 637 |
-
api_btn = gr.Button(visible=False)
|
| 638 |
-
api_btn.click(lambda s, sp, mo, hi, se: spin_panel(s, hi, sp, mo, int(se)),
|
| 639 |
-
[api_still, api_speed, api_motion, api_hint, api_seed], api_out, api_name="spin")
|
| 640 |
|
| 641 |
|
| 642 |
if __name__ == "__main__":
|
| 643 |
-
demo.launch(show_error=True,
|
| 644 |
-
css=CSS, allowed_paths=[OUTPUT_DIR, PANEL_DIR])
|
|
|
|
| 1 |
+
"""Comic Orbit Studio — a custom-HTML comic-authoring studio on ZeroGPU.
|
| 2 |
|
| 3 |
+
Start with an empty manga page. Each panel has its own prompt box; drop character references in the cast rail so a
|
| 4 |
+
character stays consistent. Write a panel -> **Sketch** it (Krea 2 + an anime LoRA, run in a separate image Space) ->
|
| 5 |
+
**Approve & spin** -> it runs the ORB360 LoRA (MiniMax-H3 `fl2va`, the same panel pinned as both keyframes) so the
|
| 6 |
+
camera orbits the frozen scene, and every panel becomes a looping, playable clip. Export the page as one video.
|
| 7 |
|
| 8 |
+
The whole UI is a custom HTML/CSS/JS single-page app rendered into a Gradio 6 custom `gr.HTML` component; the custom
|
| 9 |
+
JS drives the backend through a hidden "command bus" component (`props.value` write -> `.change` -> `@spaces.GPU`),
|
| 10 |
+
the pattern proven by the GNM direct-manip Space. Heavy work stays same-origin so ZeroGPU's token is handled for us:
|
| 11 |
+
the orbit runs in-process (H3 DiT), the sketch is an external Krea-2 Space called over the gradio API.
|
| 12 |
"""
|
| 13 |
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
+
import base64
|
| 17 |
+
import json
|
| 18 |
import os
|
| 19 |
+
import re
|
| 20 |
import subprocess
|
| 21 |
import tempfile
|
| 22 |
import time
|
| 23 |
import traceback
|
| 24 |
from functools import cache
|
|
|
|
| 25 |
|
| 26 |
# Before anything that could initialize CUDA: `import spaces` patches `torch.cuda` so the 72 GiB load happens at
|
| 27 |
# startup rather than on GPU time.
|
| 28 |
import spaces
|
| 29 |
import gradio as gr
|
| 30 |
|
| 31 |
+
VERSION = "comic-orbit/3"
|
| 32 |
|
| 33 |
MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
|
| 34 |
CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
|
| 35 |
+
# External Krea-2 + anime-LoRA image Space for the Sketch step. Must expose a gradio API whose endpoint takes
|
| 36 |
+
# (prompt, image=optional_ref) and returns an image. Empty -> sketch falls back to the dropped ref or a placeholder.
|
| 37 |
IMAGE_SPACE = os.environ.get("H3_IMAGE_SPACE", "").strip()
|
| 38 |
IMAGE_API = os.environ.get("H3_IMAGE_API", "/predict").strip()
|
| 39 |
+
ANIME_STYLE = os.environ.get("H3_STYLE", "anime comic panel, flat cel anime style, cinematic lighting, detailed")
|
| 40 |
|
| 41 |
PLACEMENT = os.environ.get("H3_PLACEMENT", "pack").lower()
|
| 42 |
ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
|
| 43 |
GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
|
| 44 |
|
|
|
|
| 45 |
CANVASES = {
|
| 46 |
"960x544 · 16:9 fast": (544, 960),
|
| 47 |
"1344x768 · 16:9 full": (768, 1344),
|
|
|
|
| 54 |
"576x768 · 3:4 fast": (768, 576),
|
| 55 |
"768x1024 · 3:4 full": (1024, 768),
|
| 56 |
}
|
| 57 |
+
ORBIT_CANVASES = ("768x768 · 1:1 full", "1344x768 · 16:9 full", "768x1344 · 9:16 full", "1024x768 · 4:3 full", "768x1024 · 3:4 full")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
|
|
|
|
| 60 |
ORBIT_FRAMES = int(os.environ.get("H3_ORBIT_FRAMES", "73"))
|
| 61 |
|
|
|
|
| 62 |
ORBIT_PROMPT = (
|
| 63 |
"One frozen instant. Only the camera moves. In a continuous 360 orbit. Preserve every person and object in "
|
| 64 |
"exactly the same world position, orientation, shape and pose throughout the shot. Airborne objects remain "
|
|
|
|
| 72 |
"🎥 orbit · slow, cinematic": ORBIT_PROMPT + " A slow, smooth, cinematic orbit.",
|
| 73 |
}
|
| 74 |
DEFAULT_MOTION = list(MOTIONS)[0]
|
| 75 |
+
SPEEDS = {"⚡ Fast · 6 steps": ("larry", 6), "✨ Balanced · 8 steps": ("lightx8", 8), "💎 Quality · 28 steps": ("off", 28)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
DEFAULT_SPEED = list(SPEEDS)[0]
|
| 77 |
|
| 78 |
+
PAGE_W, PAGE_H = 1000, 1500
|
|
|
|
| 79 |
LAYOUTS = {
|
| 80 |
1: [(0, 0, 1, 1)],
|
| 81 |
2: [(0, 0, 1, 0.52), (0, 0.52, 1, 0.48)],
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
def layout_for(n: int):
|
|
|
|
| 91 |
if n in LAYOUTS:
|
| 92 |
return LAYOUTS[n]
|
| 93 |
rows = (n + 1) // 2
|
|
|
|
| 98 |
if len(left) == 1:
|
| 99 |
cells.append((0.0, r * rh, 1.0, rh))
|
| 100 |
else:
|
| 101 |
+
cells += [(0.0, r * rh, 0.5, rh), (0.5, r * rh, 0.5, rh)]
|
|
|
|
| 102 |
return cells
|
| 103 |
|
| 104 |
|
| 105 |
OUTPUT_DIR = os.path.join(tempfile.gettempdir(), "comic-orbit-outputs")
|
| 106 |
PANEL_DIR = os.path.join(tempfile.gettempdir(), "comic-orbit-panels")
|
| 107 |
+
for _d in (OUTPUT_DIR, PANEL_DIR):
|
| 108 |
+
os.makedirs(_d, exist_ok=True)
|
| 109 |
|
| 110 |
+
PIPE = MANAGER = None
|
|
|
|
| 111 |
LOAD_ERROR: str | None = None
|
| 112 |
LOADED_IN: float | None = None
|
| 113 |
LORA_STATUS: str | None = None
|
| 114 |
|
| 115 |
|
|
|
|
| 116 |
def snap_frames(seconds: float) -> int:
|
| 117 |
frames = max(1, round(float(seconds) * FPS))
|
| 118 |
while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
|
|
|
|
| 126 |
MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
|
| 127 |
|
| 128 |
|
|
|
|
| 129 |
def load_models() -> str | None:
|
| 130 |
global PIPE, MANAGER, LOAD_ERROR, LOADED_IN, LORA_STATUS
|
|
|
|
| 131 |
if PIPE is not None or LOAD_ERROR is not None:
|
| 132 |
return LOAD_ERROR
|
|
|
|
| 133 |
started = time.time()
|
| 134 |
try:
|
| 135 |
import torch
|
|
|
|
| 149 |
LORA_STATUS = h3_lora.apply_lora(pipe.transformer)
|
| 150 |
if LORA_STATUS:
|
| 151 |
print(f"[{VERSION}] {LORA_STATUS}", flush=True)
|
|
|
|
| 152 |
pipe.transformer.set_attention_backend(ATTENTION)
|
| 153 |
|
| 154 |
if PLACEMENT == "pack":
|
|
|
|
| 162 |
print(f"[{VERSION}] ready in {LOADED_IN:.0f}s", flush=True)
|
| 163 |
except Exception as error:
|
| 164 |
traceback.print_exc()
|
| 165 |
+
LOAD_ERROR = f"Loading {MODEL_REPO} + orbit/turbo LoRAs failed after {time.time() - started:.0f}s: {type(error).__name__}: {error}"
|
| 166 |
return LOAD_ERROR
|
| 167 |
|
| 168 |
|
|
|
|
| 180 |
module.decode = armed
|
| 181 |
|
| 182 |
|
|
|
|
| 183 |
@cache
|
| 184 |
def conditioner():
|
| 185 |
from gradio_client import Client
|
|
|
|
| 204 |
return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), handle.metadata(), plan
|
| 205 |
|
| 206 |
|
|
|
|
| 207 |
_DUR_B, _DUR_C = 1.1745e-4, 3.8396e-9
|
| 208 |
_DECODE_BASE, _DECODE_PER_DEFAULT_CANVAS, _DEFAULT_CANVAS_PIXELS = 15, 15, 960 * 544 * 124
|
| 209 |
_PLACEMENT_ALLOWANCE, _PAD = 12, 10
|
|
|
|
| 226 |
import h3_lora
|
| 227 |
|
| 228 |
active_lora = h3_lora.set_active(PIPE.transformer, lora)
|
|
|
|
| 229 |
if PLACEMENT == "lazy":
|
| 230 |
PIPE.to("cuda")
|
| 231 |
elif PLACEMENT == "pack":
|
| 232 |
PIPE.vae.to("cuda")
|
| 233 |
PIPE.audio_vae.to("cuda")
|
|
|
|
| 234 |
state = PIPE(
|
| 235 |
prompt_embeds=prompt_embeds.to("cuda"),
|
| 236 |
text_token_tags=text_token_tags,
|
|
|
|
| 246 |
|
| 247 |
|
| 248 |
def fit_panel(image_path: str) -> tuple[str, str]:
|
|
|
|
| 249 |
from PIL import Image, ImageOps
|
| 250 |
|
| 251 |
img = ImageOps.exif_transpose(Image.open(image_path)).convert("RGB")
|
|
|
|
| 255 |
target = w / h
|
| 256 |
if abs(aspect - target) > 1e-3:
|
| 257 |
if aspect > target:
|
| 258 |
+
nw = int(img.height * target)
|
| 259 |
+
img = img.crop(((img.width - nw) // 2, 0, (img.width - nw) // 2 + nw, img.height))
|
|
|
|
| 260 |
else:
|
| 261 |
+
nh = int(img.width / target)
|
| 262 |
+
img = img.crop((0, (img.height - nh) // 2, img.width, (img.height - nh) // 2 + nh))
|
| 263 |
+
out = os.path.join(PANEL_DIR, f"fit-{int(time.time()*1000)}.png")
|
|
|
|
|
|
|
| 264 |
img.save(out)
|
| 265 |
return out, label
|
| 266 |
|
| 267 |
|
| 268 |
def spin_panel(panel_path, scene_hint, speed, motion, seed) -> str:
|
|
|
|
| 269 |
if LOAD_ERROR:
|
| 270 |
+
raise gr.Error(LOAD_ERROR)
|
| 271 |
if PIPE is None:
|
| 272 |
+
raise gr.Error("The studio is still warming up — retry shortly.")
|
|
|
|
| 273 |
from PIL import Image, ImageOps
|
| 274 |
from diffusers.utils import encode_video
|
| 275 |
|
| 276 |
lora, steps = SPEEDS.get(speed, SPEEDS[DEFAULT_SPEED])
|
| 277 |
base = MOTIONS.get(motion, ORBIT_PROMPT)
|
| 278 |
prompt = f"{base} The scene: {scene_hint.strip()}." if (scene_hint and scene_hint.strip()) else base
|
|
|
|
| 279 |
panel, canvas = fit_panel(panel_path)
|
| 280 |
num_frames = snap_frames(ORBIT_FRAMES / FPS)
|
| 281 |
prompt_embeds, text_token_tags, metadata, plan = encode_remote(prompt, panel, panel, canvas, num_frames)
|
| 282 |
+
height, width, num_frames = (int(metadata[k]) for k in ("height", "width", "num_frames"))
|
| 283 |
keyframe = ImageOps.exif_transpose(Image.open(panel)).convert("RGB")
|
| 284 |
+
frames, active = _spin(prompt_embeds, text_token_tags, keyframe, keyframe, height, width, num_frames, steps, int(seed), lora)
|
| 285 |
+
path = os.path.join(OUTPUT_DIR, f"orbit-{int(time.time()*1000)}.mp4")
|
| 286 |
+
encode_video(frames, fps=FPS, output_path=path) # silent
|
| 287 |
+
print(f"[{VERSION}] spun {width}x{height} {num_frames}f {steps}st turbo={active}", flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
return path
|
| 289 |
|
| 290 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
def image_space_call(prompt: str, init_image: str | None = None) -> str:
|
| 292 |
from gradio_client import Client, handle_file
|
| 293 |
|
|
|
|
| 299 |
result = client.predict(**kwargs, api_name=IMAGE_API)
|
| 300 |
except Exception as error:
|
| 301 |
traceback.print_exc()
|
| 302 |
+
raise gr.Error(f"Sketch Space `{IMAGE_SPACE}` failed: {type(error).__name__}: {error}") from error
|
| 303 |
while isinstance(result, (list, tuple)) and result:
|
| 304 |
result = result[0]
|
| 305 |
if isinstance(result, dict):
|
| 306 |
result = result.get("path") or result.get("image") or result.get("url")
|
| 307 |
if not isinstance(result, str):
|
| 308 |
+
raise gr.Error(f"Could not read an image from `{IMAGE_SPACE}`.")
|
| 309 |
+
return save_image(result)
|
| 310 |
|
| 311 |
|
| 312 |
+
def save_image(src) -> str:
|
| 313 |
+
from PIL import Image
|
|
|
|
| 314 |
|
| 315 |
+
path = os.path.join(PANEL_DIR, f"panel-{int(time.time()*1000)}.png")
|
| 316 |
+
(src if isinstance(src, Image.Image) else Image.open(src)).convert("RGB").save(path)
|
| 317 |
+
return path
|
| 318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
+
def decode_data_url(data_url: str) -> str:
|
| 321 |
+
m = re.match(r"data:(image/\w+);base64,(.*)", data_url or "", re.S)
|
| 322 |
+
if not m:
|
| 323 |
+
raise gr.Error("Bad image data.")
|
| 324 |
+
path = os.path.join(PANEL_DIR, f"in-{int(time.time()*1000)}.png")
|
| 325 |
+
with open(path, "wb") as f:
|
| 326 |
+
f.write(base64.b64decode(m.group(2)))
|
| 327 |
+
return save_image(path)
|
| 328 |
|
| 329 |
+
|
| 330 |
+
def make_placeholder(prompt: str) -> str:
|
| 331 |
+
"""Fallback when no sketch Space is configured: a cream panel with the prompt text, so the flow is still testable."""
|
| 332 |
+
from PIL import Image, ImageDraw
|
| 333 |
+
|
| 334 |
+
img = Image.new("RGB", (768, 768), (239, 230, 208))
|
| 335 |
+
d = ImageDraw.Draw(img)
|
| 336 |
+
text = (prompt or "untitled panel").strip()
|
| 337 |
+
words, line, y = text.split(), "", 300
|
| 338 |
+
for w in words:
|
| 339 |
+
if len(line + " " + w) > 28:
|
| 340 |
+
d.text((60, y), line, fill=(40, 32, 26)); y += 34; line = w
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
else:
|
| 342 |
+
line = (line + " " + w).strip()
|
| 343 |
+
d.text((60, y), line, fill=(40, 32, 26))
|
| 344 |
+
d.text((60, 60), "sketch placeholder", fill=(150, 130, 100))
|
| 345 |
+
path = os.path.join(PANEL_DIR, f"ph-{int(time.time()*1000)}.png")
|
| 346 |
+
img.save(path)
|
| 347 |
+
return path
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def _file_url(path: str) -> str:
|
| 351 |
+
return f"/gradio_api/file={path}"
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def _url_to_path(url: str) -> str:
|
| 355 |
+
path = url.split("file=", 1)[-1]
|
| 356 |
+
if not (os.path.abspath(path).startswith(OUTPUT_DIR) or os.path.abspath(path).startswith(PANEL_DIR)):
|
| 357 |
+
raise gr.Error("Refusing to read a path outside the studio's own folders.")
|
| 358 |
+
return path
|
| 359 |
|
| 360 |
|
| 361 |
def _even(v: float) -> int:
|
|
|
|
| 364 |
|
| 365 |
|
| 366 |
def composite_page_video(panels: list[dict]) -> str:
|
|
|
|
|
|
|
| 367 |
panels = [p for p in (panels or []) if p.get("clip") or p.get("still")]
|
| 368 |
if not panels:
|
| 369 |
+
raise gr.Error("Spin at least one panel first.")
|
| 370 |
rects = layout_for(len(panels))
|
| 371 |
dur = ORBIT_FRAMES / FPS
|
| 372 |
args = ["ffmpeg", "-y", "-f", "lavfi", "-i", f"color=c=#f4ead6:s={PAGE_W}x{PAGE_H}:d={dur:.3f}:r={FPS}"]
|
|
|
|
| 375 |
args += ["-i", p["clip"]]
|
| 376 |
else:
|
| 377 |
args += ["-loop", "1", "-t", f"{dur:.3f}", "-i", p["still"]]
|
| 378 |
+
b = 7
|
| 379 |
fc, prev = "", "[0:v]"
|
| 380 |
for i, (p, (x, y, w, h)) in enumerate(zip(panels, rects)):
|
| 381 |
+
cw, ch, cx, cy = _even(w * PAGE_W), _even(h * PAGE_H), int(x * PAGE_W), int(y * PAGE_H)
|
|
|
|
| 382 |
iw, ih = cw - 2 * b, ch - 2 * b
|
| 383 |
+
fc += f"[{i+1}:v]scale={iw}:{ih}:force_original_aspect_ratio=increase,crop={iw}:{ih},setsar=1[v{i}];"
|
|
|
|
|
|
|
|
|
|
| 384 |
out = f"[o{i}]"
|
| 385 |
fc += f"{prev}[v{i}]overlay={cx+b}:{cy+b}{out};"
|
| 386 |
prev = out
|
| 387 |
for i, (x, y, w, h) in enumerate(rects):
|
| 388 |
+
cw, ch, cx, cy = _even(w * PAGE_W), _even(h * PAGE_H), int(x * PAGE_W), int(y * PAGE_H)
|
|
|
|
| 389 |
out = f"[b{i}]"
|
| 390 |
fc += f"{prev}drawbox=x={cx}:y={cy}:w={cw}:h={ch}:color=#141018:t={b}{out};"
|
| 391 |
prev = out
|
| 392 |
fc = fc.rstrip(";")
|
|
|
|
| 393 |
path = os.path.join(OUTPUT_DIR, f"comic-page-{int(time.time()*1000)}.mp4")
|
| 394 |
args += ["-filter_complex", fc, "-map", prev, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", str(FPS), "-t", f"{dur:.3f}", path]
|
| 395 |
try:
|
| 396 |
subprocess.run(args, check=True, capture_output=True, timeout=180)
|
| 397 |
except subprocess.CalledProcessError as error:
|
| 398 |
print(error.stderr.decode()[-1500:], flush=True)
|
| 399 |
+
raise gr.Error("Could not compose the page video.") from error
|
| 400 |
return path
|
| 401 |
|
| 402 |
|
| 403 |
+
# ── command router: the custom JS writes a JSON command into the hidden bus; this returns a JSON reply ───────────
|
| 404 |
+
def handle(raw):
|
| 405 |
+
if not raw:
|
| 406 |
+
return ""
|
| 407 |
+
cmd_json = raw.split("\u0001", 1)[0]
|
| 408 |
+
try:
|
| 409 |
+
cmd = json.loads(cmd_json)
|
| 410 |
+
except Exception:
|
| 411 |
+
return ""
|
| 412 |
+
op, i = cmd.get("op"), cmd.get("i")
|
| 413 |
+
try:
|
| 414 |
+
if op == "sketch":
|
| 415 |
+
prompt = (cmd.get("prompt") or "").strip()
|
| 416 |
+
refs = cmd.get("refs") or []
|
| 417 |
+
ref_path = decode_data_url(refs[0]) if refs else None
|
| 418 |
+
if IMAGE_SPACE:
|
| 419 |
+
styled = f"{prompt}, {ANIME_STYLE}" if prompt else ANIME_STYLE
|
| 420 |
+
path = image_space_call(styled, init_image=ref_path)
|
| 421 |
+
elif ref_path:
|
| 422 |
+
path = ref_path
|
| 423 |
+
else:
|
| 424 |
+
path = make_placeholder(prompt)
|
| 425 |
+
return json.dumps({"op": "sketch", "i": i, "still": _file_url(path)})
|
| 426 |
+
if op == "drop":
|
| 427 |
+
return json.dumps({"op": "sketch", "i": i, "still": _file_url(decode_data_url(cmd["img"]))})
|
| 428 |
+
if op == "spin":
|
| 429 |
+
clip = spin_panel(_url_to_path(cmd["url"]), cmd.get("hint", ""), cmd.get("speed", DEFAULT_SPEED), cmd.get("motion", DEFAULT_MOTION), int(cmd.get("seed", 42)))
|
| 430 |
+
return json.dumps({"op": "spin", "i": i, "clip": _file_url(clip)})
|
| 431 |
+
if op == "export":
|
| 432 |
+
vid = composite_page_video([{"clip": _url_to_path(u)} for u in cmd.get("clips", [])])
|
| 433 |
+
return json.dumps({"op": "export", "video": _file_url(vid)})
|
| 434 |
+
except gr.Error as error:
|
| 435 |
+
return json.dumps({"op": op, "i": i, "error": str(error)})
|
| 436 |
+
except Exception as error:
|
| 437 |
+
traceback.print_exc()
|
| 438 |
+
return json.dumps({"op": op, "i": i, "error": f"{type(error).__name__}: {error}"})
|
| 439 |
+
return ""
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
# ── the SPA (custom HTML/CSS/JS) ─────────────────────────────────────────────
|
| 443 |
load_models()
|
| 444 |
|
| 445 |
+
HEAD = r"""
|
| 446 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 447 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 448 |
+
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@600;800&family=Zen+Maru+Gothic:wght@500;700;900&display=swap" rel="stylesheet">
|
| 449 |
+
<style>
|
| 450 |
+
:root{--stage:#17130f;--stage2:#211a14;--paper:#f6edd7;--ink:#141019;--red:#c0392b;--red2:#e05a46;--bubble:#fffdf7;--bd:3px solid var(--ink);--ui:"Zen Maru Gothic",system-ui,sans-serif;--disp:"Shippori Mincho",serif;}
|
| 451 |
+
.gradio-container{max-width:none !important;padding:0 !important;margin:0 !important;}
|
| 452 |
+
gradio-app,body,html{background:var(--stage) !important;overflow:hidden !important;}
|
| 453 |
+
footer,.footer{display:none !important;}
|
| 454 |
+
#comic-app{position:fixed;inset:0;z-index:30;}
|
| 455 |
+
#comic-app *{box-sizing:border-box;}
|
| 456 |
+
#comic-app .app{height:100vh;display:grid;grid-template-columns:238px 1fr;color:#f3ece0;font-family:var(--ui);background:radial-gradient(120% 120% at 50% -10%,var(--stage2),var(--stage) 70%);}
|
| 457 |
+
#comic-app .rail{padding:20px 16px;display:flex;flex-direction:column;gap:12px;border-right:1px solid #2c241c;background:#140f0b;overflow:auto;}
|
| 458 |
+
#comic-app .rail h2{font-size:.72rem;letter-spacing:.22em;margin:2px 0;color:#b59b74;font-weight:700;}
|
| 459 |
+
#comic-app .rail .sub{font-size:.7rem;color:#7c6b55;margin:-6px 0 4px;line-height:1.4;}
|
| 460 |
+
#comic-app .char{background:#1d160f;border:1px solid #352a1f;border-radius:14px;padding:9px;display:flex;gap:10px;align-items:center;}
|
| 461 |
+
#comic-app .char .pic{width:50px;height:50px;border-radius:10px;border:2px solid var(--ink);object-fit:cover;background:#0d0a07;flex:0 0 auto;}
|
| 462 |
+
#comic-app .char .name{font-weight:700;font-size:.9rem;}#comic-app .char .tag{font-size:.66rem;color:#8c7a60;}
|
| 463 |
+
#comic-app .char.add{border-style:dashed;border-color:#4a3c2c;color:#9c8666;justify-content:center;cursor:pointer;font-weight:600;padding:14px;}
|
| 464 |
+
#comic-app .char.add:hover{border-color:var(--red2);color:var(--red2);}
|
| 465 |
+
#comic-app .count{display:flex;gap:4px;flex-wrap:wrap;}
|
| 466 |
+
#comic-app .count button{flex:1;min-width:30px;background:#1d160f;border:1px solid #352a1f;color:#cdb892;border-radius:9px;padding:7px 0;font-weight:800;cursor:pointer;font-family:var(--ui);}
|
| 467 |
+
#comic-app .count button.on{background:var(--red);color:#fff;border-color:var(--ink);}
|
| 468 |
+
#comic-app .railfoot{margin-top:auto;font-size:.68rem;color:#6f5f49;line-height:1.5;padding-top:12px;}
|
| 469 |
+
#comic-app .stage{position:relative;display:flex;align-items:center;justify-content:center;padding:14px 26px 86px;}
|
| 470 |
+
#comic-app .hint{position:absolute;top:14px;left:50%;transform:translateX(-50%);background:#2b2620ee;border:1px solid #42382c;color:#f0e8d8;padding:9px 18px;border-radius:12px;font-size:.82rem;box-shadow:0 6px 20px #0008;z-index:5;}
|
| 471 |
+
#comic-app .hint b{color:#ffd9a0;}
|
| 472 |
+
#comic-app .page{position:relative;width:min(92vw,1040px);aspect-ratio:16/10;background:repeating-linear-gradient(0deg,#00000005 0 3px,transparent 3px 6px),var(--paper);border:var(--bd);border-radius:7px;box-shadow:14px 16px 0 #0009,0 0 0 7px #0d0a07;padding:46px 13px 13px;}
|
| 473 |
+
#comic-app .title{position:absolute;top:7px;left:16px;display:flex;align-items:center;gap:10px;z-index:3;}
|
| 474 |
+
#comic-app .title .badge{background:var(--red);color:#fff;font-weight:800;font-size:.6rem;letter-spacing:.1em;padding:4px 7px;border-radius:5px;box-shadow:2px 2px 0 var(--ink);text-align:center;line-height:1.15;}
|
| 475 |
+
#comic-app .title .en b{font-family:var(--disp);font-style:italic;color:var(--ink);font-size:1rem;outline:none;}
|
| 476 |
+
#comic-app .title .en span{display:block;font-size:.62rem;color:#6a5a44;margin-top:2px;}
|
| 477 |
+
#comic-app .grid{position:absolute;inset:46px 13px 13px;}
|
| 478 |
+
#comic-app .panel{position:absolute;border:var(--bd);background:#efe6d0;overflow:hidden;}
|
| 479 |
+
#comic-app .panel .num{position:absolute;top:0;left:0;z-index:4;background:var(--ink);color:#fff;font-weight:900;font-size:.7rem;padding:2px 8px;border-radius:0 0 9px 0;}
|
| 480 |
+
#comic-app .panel img,#comic-app .panel video{width:100%;height:100%;object-fit:cover;display:block;}
|
| 481 |
+
#comic-app .empty{position:absolute;inset:0;display:flex;flex-direction:column;gap:8px;align-items:center;justify-content:center;padding:12px;background:repeating-linear-gradient(45deg,#e8dcbf 0 9px,#eee3c7 9px 18px);}
|
| 482 |
+
#comic-app .empty textarea{width:100%;flex:1;min-height:36px;resize:none;border:2px dashed #b7a57f;border-radius:10px;background:#fffdf5cc;font-family:var(--ui);font-size:.8rem;color:#3a3020;padding:8px 10px;outline:none;}
|
| 483 |
+
#comic-app .empty textarea::placeholder{color:#a9966f;}
|
| 484 |
+
#comic-app .btn{border:var(--bd);border-radius:10px;font-family:var(--ui);font-weight:800;cursor:pointer;padding:7px 13px;font-size:.8rem;box-shadow:3px 3px 0 var(--ink);transition:transform .06s,box-shadow .06s;}
|
| 485 |
+
#comic-app .btn:hover{transform:translate(-1px,-1px);box-shadow:4px 4px 0 var(--ink);}
|
| 486 |
+
#comic-app .btn:active{transform:translate(2px,2px);box-shadow:1px 1px 0 var(--ink);}
|
| 487 |
+
#comic-app .btn.red{background:var(--red);color:#fff;}#comic-app .btn.cream{background:var(--bubble);color:var(--ink);}
|
| 488 |
+
#comic-app .btn.sm{padding:5px 9px;font-size:.72rem;box-shadow:2px 2px 0 var(--ink);}
|
| 489 |
+
#comic-app .drop{font-size:.62rem;color:#9a855f;cursor:pointer;text-decoration:underline;}
|
| 490 |
+
#comic-app .overlay{position:absolute;inset:0;display:flex;align-items:flex-end;justify-content:center;gap:8px;padding:9px;background:linear-gradient(to top,#000000a8,transparent 42%);opacity:0;transition:opacity .15s;}
|
| 491 |
+
#comic-app .panel:hover .overlay{opacity:1;}
|
| 492 |
+
#comic-app .gen{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#efe6d0;}
|
| 493 |
+
#comic-app .ring{width:32px;height:32px;border-radius:50%;border:4px solid #d8c9a4;border-top-color:var(--red);animation:spin 1s linear infinite;}
|
| 494 |
+
@keyframes spin{to{transform:rotate(360deg);}}
|
| 495 |
+
#comic-app .gen .lbl{position:absolute;bottom:12px;font-size:.7rem;color:#7a684f;font-weight:600;}
|
| 496 |
+
#comic-app .spin-badge{position:absolute;top:6px;right:7px;z-index:4;background:#000000bb;color:#fff;font-size:.6rem;font-weight:700;padding:2px 8px;border-radius:999px;display:flex;align-items:center;gap:5px;}
|
| 497 |
+
#comic-app .dot{width:7px;height:7px;border-radius:50%;background:var(--red2);animation:pulse 1.4s infinite;}
|
| 498 |
+
@keyframes pulse{0%{box-shadow:0 0 0 0 #e05a4699;}70%{box-shadow:0 0 0 7px #e05a4600;}100%{box-shadow:0 0 0 0 #e05a4600;}}
|
| 499 |
+
#comic-app .handcur{position:absolute;right:11px;bottom:11px;font-size:1.25rem;filter:drop-shadow(0 1px 2px #000a);z-index:4;animation:nudge 1.8s ease-in-out infinite;pointer-events:none;}
|
| 500 |
+
@keyframes nudge{0%,100%{transform:translateX(0);}50%{transform:translateX(-10px);}}
|
| 501 |
+
#comic-app .page.halftone .panel::after{content:"";position:absolute;inset:0;pointer-events:none;mix-blend-mode:multiply;opacity:.2;background-image:radial-gradient(#000 29%,transparent 30%);background-size:4px 4px;}
|
| 502 |
+
#comic-app .toolbar{position:absolute;bottom:18px;left:50%;transform:translateX(-50%);display:flex;gap:6px;background:#241e18;border:1px solid #3c3127;border-radius:16px;padding:7px;box-shadow:0 10px 30px #000a;z-index:6;}
|
| 503 |
+
#comic-app .tool{display:flex;flex-direction:column;align-items:center;gap:1px;background:transparent;border:none;color:#e9dcc6;font-family:var(--ui);cursor:pointer;padding:7px 15px;border-radius:11px;font-size:.84rem;font-weight:700;}
|
| 504 |
+
#comic-app .tool small{font-size:.6rem;color:#9a876c;}#comic-app .tool:hover{background:#322a21;}
|
| 505 |
+
#comic-app .tool.on{background:#f1e7d2;color:var(--ink);}#comic-app .tool.on small{color:#6a5a44;}#comic-app .tool.primary{color:#ffd9a0;}
|
| 506 |
+
</style>
|
| 507 |
+
"""
|
| 508 |
+
|
| 509 |
+
APP_TEMPLATE = """
|
| 510 |
+
<div class="app">
|
| 511 |
+
<aside class="rail">
|
| 512 |
+
<h2>CAST</h2>
|
| 513 |
+
<div class="sub">Drop a reference so a character stays consistent across panels.</div>
|
| 514 |
+
<div id="cast"></div>
|
| 515 |
+
<h2 style="margin-top:8px">PAGE</h2>
|
| 516 |
+
<div class="count" id="count"></div>
|
| 517 |
+
<div class="railfoot">Krea 2 · anime LoRA sketches your panels, then ORB360 makes them turn.</div>
|
| 518 |
+
</aside>
|
| 519 |
+
<main class="stage">
|
| 520 |
+
<div class="hint">✍️ Write a panel · 🎭 add your cast · ✨ sketch — then <b>spin it</b></div>
|
| 521 |
+
<div class="page halftone" id="page">
|
| 522 |
+
<div class="title"><div class="badge">CH<br>01</div>
|
| 523 |
+
<div class="en"><b contenteditable="true" spellcheck="false">Untitled</b><span>tap to name your comic</span></div></div>
|
| 524 |
+
<div class="grid" id="grid"></div>
|
| 525 |
+
</div>
|
| 526 |
+
<div class="toolbar">
|
| 527 |
+
<button class="tool primary" data-act="spinall">▶ Spin all<small>orbit every panel</small></button>
|
| 528 |
+
<button class="tool" data-act="reset">↺ Reset<small>stop motion</small></button>
|
| 529 |
+
<button class="tool on" data-act="halftone">◦ Halftone<small>screentone</small></button>
|
| 530 |
+
<button class="tool" data-act="export">⬇ Export<small>one video</small></button>
|
| 531 |
+
</div>
|
| 532 |
+
</main>
|
| 533 |
+
</div>
|
| 534 |
+
<input type="file" id="charfile" accept="image/*" style="display:none">
|
| 535 |
"""
|
| 536 |
|
| 537 |
+
APP_JS = r"""
|
| 538 |
+
const el = element;
|
| 539 |
+
window.__COMIC = window.__COMIC || {};
|
| 540 |
+
const C = window.__COMIC;
|
| 541 |
+
const SPEED = "⚡ Fast · 6 steps", MOTION = "🔄 360° orbit", SEED = 42, MAXP = 6;
|
| 542 |
+
const LAYOUTS = {
|
| 543 |
+
1:[[0,0,1,1]], 2:[[0,0,1,.52],[0,.52,1,.48]],
|
| 544 |
+
3:[[0,0,1,.42],[0,.42,.5,.58],[.5,.42,.5,.58]],
|
| 545 |
+
4:[[0,0,.58,.5],[.58,0,.42,.5],[0,.5,.42,.5],[.42,.5,.58,.5]],
|
| 546 |
+
5:[[0,0,1,.34],[0,.34,.5,.33],[.5,.34,.5,.33],[0,.67,.55,.33],[.55,.67,.45,.33]],
|
| 547 |
+
6:[[0,0,.5,.34],[.5,0,.5,.34],[0,.34,.55,.33],[.55,.34,.45,.33],[0,.67,.45,.33],[.45,.67,.55,.33]]
|
| 548 |
+
};
|
| 549 |
+
const G = 8;
|
| 550 |
+
let N = 4, panels = [], cast = [], queue = [];
|
| 551 |
+
function L(n){ if(LAYOUTS[n]) return LAYOUTS[n]; const rows=Math.ceil(n/2),rh=1/rows,c=[]; for(let r=0;r<rows;r++){ const k=Math.min(2,n-2*r); if(k===1)c.push([0,r*rh,1,rh]); else {c.push([0,r*rh,.5,rh]);c.push([.5,r*rh,.5,rh]);} } return c; }
|
| 552 |
+
function init(){ panels = Array.from({length:N},()=>({state:"empty",prompt:""})); }
|
| 553 |
+
function esc(s){ return (s||"").replace(/[&<>"]/g,m=>({"&":"&","<":"<",">":">",'"':"""}[m])); }
|
| 554 |
+
function panelHTML(p,i){
|
| 555 |
+
if(p.state==="empty") return `<span class="num">${i+1}</span><div class="empty">
|
| 556 |
+
<textarea class="ptext" data-i="${i}" placeholder="Describe this panel…">${esc(p.prompt)}</textarea>
|
| 557 |
+
<button class="btn red" data-act="sketch" data-i="${i}">✨ Sketch</button>
|
| 558 |
+
<span class="drop" data-act="drop" data-i="${i}">or drop an image</span></div>`;
|
| 559 |
+
if(p.state==="gen") return `<span class="num">${i+1}</span><div class="gen"><div class="ring"></div><div class="lbl">${esc(p.lbl||"working…")}</div></div>`;
|
| 560 |
+
if(p.state==="sketch") return `<span class="num">${i+1}</span><img src="${p.img}">
|
| 561 |
+
<div class="overlay"><button class="btn cream sm" data-act="redo" data-i="${i}">↻ Redo</button>
|
| 562 |
+
<button class="btn red sm" data-act="spin" data-i="${i}">✓ Approve & spin</button></div>`;
|
| 563 |
+
return `<span class="num">${i+1}</span><video src="${p.clip}" autoplay loop muted playsinline onclick="this.paused?this.play():this.pause()"></video>
|
| 564 |
+
<div class="spin-badge"><span class="dot"></span>LIVE</div><div class="handcur">✋</div>`;
|
| 565 |
+
}
|
| 566 |
+
function render(){
|
| 567 |
+
const grid = el.querySelector("#grid"); grid.innerHTML = "";
|
| 568 |
+
const rects = L(N);
|
| 569 |
+
for(let i=0;i<N;i++){
|
| 570 |
+
const [x,y,w,h]=rects[i], d=document.createElement("div");
|
| 571 |
+
d.className="panel";
|
| 572 |
+
d.style.cssText=`left:calc(${x*100}% + ${x>0?G/2:0}px);top:calc(${y*100}% + ${y>0?G/2:0}px);width:calc(${w*100}% - ${G}px);height:calc(${h*100}% - ${G}px);`;
|
| 573 |
+
d.innerHTML=panelHTML(panels[i],i);
|
| 574 |
+
grid.appendChild(d);
|
| 575 |
+
}
|
| 576 |
+
const cm = el.querySelector("#cast");
|
| 577 |
+
cm.innerHTML = cast.map(c=>`<div class="char"><img class="pic" src="${c.img}"><div><div class="name">${esc(c.name)}</div><div class="tag">reference locked</div></div></div>`).join("")
|
| 578 |
+
+ `<div class="char add" data-act="addchar">+ Add character</div>`;
|
| 579 |
+
const ct = el.querySelector("#count");
|
| 580 |
+
ct.innerHTML=[1,2,3,4,5,6].map(n=>`<button class="${n===N?'on':''}" data-act="count" data-n="${n}">${n}</button>`).join("");
|
| 581 |
+
}
|
| 582 |
+
function prompt_of(i){ const t=el.querySelector(`.ptext[data-i="${i}"]`); return t?t.value:(panels[i].prompt||""); }
|
| 583 |
+
function send(cmd){ if(C.send) C.send(cmd); }
|
| 584 |
+
C.apply = function(r){
|
| 585 |
+
if(!r) return;
|
| 586 |
+
if(r.error){ const p=panels[r.i]; if(p) p.state = p.img?"sketch":"empty"; render(); alert(r.error); return; }
|
| 587 |
+
if(r.op==="sketch"){ panels[r.i]={state:"sketch",img:r.still}; render(); }
|
| 588 |
+
else if(r.op==="spin"){ panels[r.i]={state:"live",img:panels[r.i].img,clip:r.clip}; render();
|
| 589 |
+
if(queue.length){ doSpin(queue.shift()); } }
|
| 590 |
+
else if(r.op==="export"){ window.open(r.video,"_blank"); }
|
| 591 |
+
};
|
| 592 |
+
function doSketch(i){ const pr=prompt_of(i); panels[i]={state:"gen",lbl:"Krea 2 sketching…",prompt:pr};
|
| 593 |
+
render(); send({op:"sketch",i,prompt:pr,refs:cast.map(c=>c.img)}); }
|
| 594 |
+
function doSpin(i){ const img=panels[i].img; panels[i]={state:"gen",lbl:"ORB360 spinning…",img};
|
| 595 |
+
render(); send({op:"spin",i,url:img,hint:panels[i].prompt||"",speed:SPEED,motion:MOTION,seed:SEED}); }
|
| 596 |
+
function spinAll(){ queue=[]; for(let i=0;i<N;i++) if(panels[i].state==="sketch") queue.push(i);
|
| 597 |
+
if(queue.length) doSpin(queue.shift()); }
|
| 598 |
+
function resetAll(){ panels.forEach((p,i)=>{ if(p.state==="live") panels[i]={state:"sketch",img:p.img}; }); render(); }
|
| 599 |
+
function exportVideo(){ const clips=panels.filter(p=>p.state==="live").map(p=>p.clip); if(!clips.length){alert("Spin a panel first.");return;} send({op:"export",clips}); }
|
| 600 |
+
function setN(n){ const old=panels.slice(); N=n; panels=Array.from({length:N},(_,i)=>old[i]||{state:"empty",prompt:""}); render(); }
|
| 601 |
+
function pickChar(){ const f=el.querySelector("#charfile"); f.onchange=()=>{ const file=f.files[0]; if(!file)return;
|
| 602 |
+
const rd=new FileReader(); rd.onload=()=>{ cast.push({name:"Character "+String.fromCharCode(65+cast.length),img:rd.result}); render(); }; rd.readAsDataURL(file); f.value=""; }; f.click(); }
|
| 603 |
+
function dropImg(i){ const inp=document.createElement("input"); inp.type="file"; inp.accept="image/*";
|
| 604 |
+
inp.onchange=()=>{ const file=inp.files[0]; if(!file)return; const rd=new FileReader();
|
| 605 |
+
rd.onload=()=>{ panels[i]={state:"gen",lbl:"loading…"}; render(); send({op:"drop",i,img:rd.result}); }; rd.readAsDataURL(file); }; inp.click(); }
|
| 606 |
+
el.addEventListener("click",e=>{ const t=e.target.closest("[data-act]"); if(!t)return; const a=t.dataset.act,i=+t.dataset.i;
|
| 607 |
+
if(a==="sketch"||a==="redo")doSketch(i); else if(a==="spin")doSpin(i); else if(a==="drop")dropImg(i);
|
| 608 |
+
else if(a==="spinall")spinAll(); else if(a==="reset")resetAll(); else if(a==="halftone"){el.querySelector("#page").classList.toggle("halftone");t.classList.toggle("on");}
|
| 609 |
+
else if(a==="export")exportVideo(); else if(a==="addchar")pickChar(); else if(a==="count")setN(+t.dataset.n); });
|
| 610 |
+
el.addEventListener("input",e=>{ const t=e.target; if(t.classList&&t.classList.contains("ptext")) panels[+t.dataset.i].prompt=t.value; });
|
| 611 |
+
init(); render();
|
| 612 |
+
"""
|
| 613 |
+
|
| 614 |
+
BUS_JS = r"""
|
| 615 |
+
window.__COMIC = window.__COMIC || {};
|
| 616 |
+
window.__COMIC.send = function(cmd){ props.value = JSON.stringify(cmd) + "\u0001" + Date.now(); };
|
| 617 |
+
"""
|
| 618 |
+
|
| 619 |
+
OUT_JS = r"""
|
| 620 |
+
watch("value", function(){ try{ if(props.value){ window.__COMIC && window.__COMIC.apply(JSON.parse(props.value)); } }catch(e){ console.error(e); } });
|
| 621 |
+
"""
|
| 622 |
|
| 623 |
+
with gr.Blocks(title="Comic Orbit Studio") as demo:
|
| 624 |
+
gr.HTML(value="", html_template=APP_TEMPLATE, js_on_load=APP_JS, head=HEAD, elem_id="comic-app", container=False)
|
| 625 |
+
bus = gr.HTML(value="", html_template='<div style="display:none"></div>', js_on_load=BUS_JS, container=False, elem_id="comic-bus")
|
| 626 |
+
out = gr.HTML(value="", html_template='<div style="display:none"></div>', js_on_load=OUT_JS, container=False, elem_id="comic-out")
|
| 627 |
+
bus.change(handle, bus, out, show_progress="hidden")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 628 |
|
| 629 |
|
| 630 |
if __name__ == "__main__":
|
| 631 |
+
demo.launch(show_error=True, allowed_paths=[OUTPUT_DIR, PANEL_DIR])
|
|
|