linoyts's picture
linoyts HF Staff
remove Krea refs + halftone; auto-title from VLM; update model links
7ba951d verified
Raw History Blame Contribute Delete
52.2 kB
"""Comic Orbit Studio — a custom-HTML comic-authoring studio on ZeroGPU.
Write one page prompt -> a storyboard VLM (Qwen2.5-VL-3B) invents a title + N scene beats -> panels are drawn by
Qwen-Image 2.1 (t2i, or consistent to a cast reference / previous panels) -> **Approve & spin** runs the ORB360 LoRA
(MiniMax-H3 `fl2va`, the same panel pinned as both keyframes) so the camera orbits the frozen scene and each panel
becomes a draggable 360 clip. Per-panel ✎ edits also use Qwen-Image 2.1. Export the page as one video.
The whole UI is a custom HTML/CSS/JS single-page app rendered into a Gradio 6 custom `gr.HTML` component; the custom
JS drives the backend through a hidden "command bus" component (`props.value` write -> `.change` -> `@spaces.GPU`),
the pattern proven by the GNM direct-manip Space. The orbit runs in-process (H3 DiT); storyboard, panel generation
and edits are external Spaces called over the gradio API.
"""
from __future__ import annotations
import base64
import json
import os
import re
import subprocess
import tempfile
import time
import traceback
from functools import cache
# Before anything that could initialize CUDA: `import spaces` patches `torch.cuda` so the 72 GiB load happens at
# startup rather than on GPU time.
import spaces
import gradio as gr
VERSION = "comic-orbit/3"
MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
# Qwen-Image 2.1 Space: /generate makes panels (t2i or reference-consistent), /predict edits one.
EDIT_SPACE = os.environ.get("H3_EDIT_SPACE", "").strip()
EDIT_API = os.environ.get("H3_EDIT_API", "/predict").strip()
GEN_SPACE = os.environ.get("H3_GEN_SPACE", "").strip()
GEN_API = os.environ.get("H3_GEN_API", "/generate").strip()
# Dedicated storyboard LLM Space (Qwen2.5-3B, isolated on its own ZeroGPU so it never shares an allocator with the
# heavy image models). Endpoint: /storyboard(story, n) -> JSON list of beats.
STORYBOARD_SPACE = os.environ.get("H3_STORYBOARD_SPACE", "").strip()
STORYBOARD_API = os.environ.get("H3_STORYBOARD_API", "/storyboard").strip()
EXAMPLE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "examples")
EXAMPLE_CHAR = "Luna"
EXAMPLE_BEATS = [
"a cheerful witch girl with long lavender hair, a black star hat and a teal star cloak stands at a sleepy village at dawn holding a treasure map",
"the lavender-haired witch in her black star hat and teal star cloak flies on a broomstick over green rolling hills",
"the lavender-haired witch in the teal star cloak lands in a glowing enchanted forest with floating lanterns",
"the lavender-haired witch with the black star hat and teal cloak sits on a hilltop at night under a huge moon with a black cat",
]
def _fallback_beats(story: str, n: int) -> list[str]:
shots = ["wide establishing shot", "medium shot", "close-up", "over-the-shoulder view", "low angle", "reaction close-up"]
return [f"{story.strip()}, {shots[i % len(shots)]}" for i in range(n)]
def storyboard(story: str, n: int, ref: str | None = None) -> tuple[str, list[str]]:
"""One page prompt (+ optional character ref data URL) -> (made-up title, N distinct scene beats) via the storyboard
VLM Space. The VLM reads the character image and restates its look in every beat; it also invents a page title.
Template fallback if it's down."""
story = (story or "").strip()
if not story:
raise gr.Error("Describe the page first.")
if STORYBOARD_SPACE:
try:
from gradio_client import Client, handle_file
img = handle_file(decode_data_url(ref)) if ref else None
data = json.loads(Client(STORYBOARD_SPACE).predict(story, int(n), img, api_name=STORYBOARD_API))
beats = [str(b).strip() for b in data.get("panels", []) if str(b).strip()]
if beats:
if len(beats) < n:
beats += _fallback_beats(story, n)[len(beats):]
return str(data.get("title", "")).strip(), beats[:n]
except Exception as error:
traceback.print_exc()
print(f"[comic] storyboard via {STORYBOARD_SPACE} failed ({type(error).__name__}); template", flush=True)
return "", _fallback_beats(story, n)
def _resolve_token():
for name in ("HF_TOKEN", "H3_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
if os.environ.get(name):
return os.environ[name], name
try:
from huggingface_hub import get_token
tok = get_token()
if tok:
return tok, "hub-login"
except Exception:
pass
return None, "NONE"
PLACEMENT = os.environ.get("H3_PLACEMENT", "pack").lower()
ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
CANVASES = {
"960x544 · 16:9 fast": (544, 960),
"1344x768 · 16:9 full": (768, 1344),
"544x960 · 9:16 fast": (960, 544),
"768x1344 · 9:16 full": (1344, 768),
"544x544 · 1:1 fast": (544, 544),
"768x768 · 1:1 full": (768, 768),
"768x576 · 4:3 fast": (576, 768),
"1024x768 · 4:3 full": (768, 1024),
"576x768 · 3:4 fast": (768, 576),
"768x1024 · 3:4 full": (1024, 768),
}
ORBIT_CANVASES = ("768x768 · 1:1 full", "1344x768 · 16:9 full", "768x1344 · 9:16 full", "1024x768 · 4:3 full", "768x1024 · 3:4 full")
FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
ORBIT_FRAMES = int(os.environ.get("H3_ORBIT_FRAMES", "73"))
ORBIT_PROMPT = (
"One frozen instant. Only the camera moves. In a continuous 360 orbit. Preserve every person and object in "
"exactly the same world position, orientation, shape and pose throughout the shot. Airborne objects remain "
"suspended at the captured height and angle: no wobbling, shaking, spinning, drifting, falling or continued "
"action. Keep faces, hands, clothing, liquids and the background motionless while retaining their natural "
"appearance. Camera parallax is the only source of apparent movement. No cuts, zoom, morphing or added objects."
)
MOTIONS = {
"🔄 360° orbit": ORBIT_PROMPT,
"↩️ orbit · counter-clockwise": ORBIT_PROMPT + " The camera orbits counter-clockwise.",
"🎥 orbit · slow, cinematic": ORBIT_PROMPT + " A slow, smooth, cinematic orbit.",
}
DEFAULT_MOTION = list(MOTIONS)[0]
SPEEDS = {"⚡ Fast · 6 steps": ("larry", 6), "✨ Balanced · 8 steps": ("lightx8", 8), "💎 Quality · 28 steps": ("off", 28)}
DEFAULT_SPEED = list(SPEEDS)[0]
PAGE_W, PAGE_H = 1000, 1500
LAYOUTS = {
1: [(0, 0, 1, 1)],
2: [(0, 0, 1, 0.52), (0, 0.52, 1, 0.48)],
3: [(0, 0, 1, 0.42), (0, 0.42, 0.5, 0.58), (0.5, 0.42, 0.5, 0.58)],
4: [(0, 0, 0.58, 0.5), (0.58, 0, 0.42, 0.5), (0, 0.5, 0.42, 0.5), (0.42, 0.5, 0.58, 0.5)],
5: [(0, 0, 1, 0.34), (0, 0.34, 0.5, 0.33), (0.5, 0.34, 0.5, 0.33), (0, 0.67, 0.55, 0.33), (0.55, 0.67, 0.45, 0.33)],
6: [(0, 0, 0.5, 0.34), (0.5, 0, 0.5, 0.34), (0, 0.34, 0.55, 0.33), (0.55, 0.34, 0.45, 0.33), (0, 0.67, 0.45, 0.33), (0.45, 0.67, 0.55, 0.33)],
}
MAX_PANELS = 6
def layout_for(n: int):
if n in LAYOUTS:
return LAYOUTS[n]
rows = (n + 1) // 2
rh = 1.0 / rows
cells = []
for r in range(rows):
left = [i for i in (2 * r, 2 * r + 1) if i < n]
if len(left) == 1:
cells.append((0.0, r * rh, 1.0, rh))
else:
cells += [(0.0, r * rh, 0.5, rh), (0.5, r * rh, 0.5, rh)]
return cells
OUTPUT_DIR = os.path.join(tempfile.gettempdir(), "comic-orbit-outputs")
PANEL_DIR = os.path.join(tempfile.gettempdir(), "comic-orbit-panels")
for _d in (OUTPUT_DIR, PANEL_DIR):
os.makedirs(_d, exist_ok=True)
PIPE = MANAGER = None
LOAD_ERROR: str | None = None
LOADED_IN: float | None = None
LORA_STATUS: str | None = None
def snap_frames(seconds: float) -> int:
frames = max(1, round(float(seconds) * FPS))
while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
frames += 1
return frames
def lower_duration_floor(seconds: float = 2) -> None:
from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MiniMaxH3ModularPipeline
MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
def load_models() -> str | None:
global PIPE, MANAGER, LOAD_ERROR, LOADED_IN, LORA_STATUS
if PIPE is not None or LOAD_ERROR is not None:
return LOAD_ERROR
started = time.time()
try:
import torch
from diffusers import ComponentsManager
from h3_split_blocks import MiniMaxH3GeneratorBlocks
lower_duration_floor()
manager = ComponentsManager()
blocks = MiniMaxH3GeneratorBlocks()
print(f"[{VERSION}] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
pipe.load_components(dtype=torch.bfloat16)
import h3_lora
LORA_STATUS = h3_lora.apply_lora(pipe.transformer)
if LORA_STATUS:
print(f"[{VERSION}] {LORA_STATUS}", flush=True)
pipe.transformer.set_attention_backend(ATTENTION)
if PLACEMENT == "pack":
pipe.transformer.to("cuda")
if PLACEMENT == "offload":
manager.enable_auto_cpu_offload(device="cuda")
_arm_decode_hooks(pipe)
PIPE, MANAGER = pipe, manager
LOADED_IN = time.time() - started
print(f"[{VERSION}] ready in {LOADED_IN:.0f}s", flush=True)
except Exception as error:
traceback.print_exc()
LOAD_ERROR = f"Loading {MODEL_REPO} + orbit/turbo LoRAs failed after {time.time() - started:.0f}s: {type(error).__name__}: {error}"
return LOAD_ERROR
def _arm_decode_hooks(pipe):
for name in ("vae", "audio_vae"):
module = getattr(pipe, name)
inner = module.decode
def armed(*args, _module=module, _decode=inner, **kwargs):
hook = getattr(_module, "_hf_hook", None)
if hook is not None:
hook.pre_forward(_module)
return _decode(*args, **kwargs)
module.decode = armed
@cache
def conditioner():
from gradio_client import Client
return Client(CONDITIONER_SPACE)
def encode_remote(prompt, image_path, last_image_path, canvas, num_frames):
from gradio_client import handle_file
from safetensors import safe_open
path, plan = conditioner().predict(
prompt=prompt,
image_path=handle_file(image_path) if image_path else None,
last_image_path=handle_file(last_image_path) if last_image_path else None,
canvas=canvas,
num_frames=num_frames,
rewrite_prompt=False,
api_name="/encode",
)
with safe_open(path, framework="pt") as handle:
return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), handle.metadata(), plan
_DUR_B, _DUR_C = 1.1745e-4, 3.8396e-9
_DECODE_BASE, _DECODE_PER_DEFAULT_CANVAS, _DEFAULT_CANVAS_PIXELS = 15, 15, 960 * 544 * 124
_PLACEMENT_ALLOWANCE, _PAD = 12, 10
def get_duration(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, steps, seed, lora="larry", *a, **k):
height, width, num_frames, steps = int(height), int(width), int(num_frames), int(steps)
latent_frames = (num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK * LATENTS_PER_CHUNK + 2
patches = (height // 32) * (width // 32)
rows = latent_frames * patches + (int(image is not None) + int(last_image is not None)) * patches
denoise = steps * (_DUR_B * rows + _DUR_C * rows**2)
decode = _DECODE_BASE + _DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / _DEFAULT_CANVAS_PIXELS
return max(60, int(denoise + decode) + _PLACEMENT_ALLOWANCE + _PAD)
@spaces.GPU(duration=get_duration, size=GPU_SIZE)
def _spin(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, steps, seed, lora="larry"):
import torch
import h3_lora
active_lora = h3_lora.set_active(PIPE.transformer, lora)
if PLACEMENT == "lazy":
PIPE.to("cuda")
elif PLACEMENT == "pack":
PIPE.vae.to("cuda")
PIPE.audio_vae.to("cuda")
state = PIPE(
prompt_embeds=prompt_embeds.to("cuda"),
text_token_tags=text_token_tags,
image=image,
last_image=last_image,
height=height,
width=width,
num_frames=num_frames,
num_inference_steps=int(steps) + 1,
generator=torch.Generator("cpu").manual_seed(int(seed)),
)
return state.get("videos")[0], active_lora
def fit_panel(image_path: str) -> tuple[str, str]:
from PIL import Image, ImageOps
img = ImageOps.exif_transpose(Image.open(image_path)).convert("RGB")
aspect = img.width / img.height
label = min(ORBIT_CANVASES, key=lambda lab: abs(CANVASES[lab][1] / CANVASES[lab][0] - aspect))
h, w = CANVASES[label]
target = w / h
if abs(aspect - target) > 1e-3:
if aspect > target:
nw = int(img.height * target)
img = img.crop(((img.width - nw) // 2, 0, (img.width - nw) // 2 + nw, img.height))
else:
nh = int(img.width / target)
img = img.crop((0, (img.height - nh) // 2, img.width, (img.height - nh) // 2 + nh))
out = os.path.join(PANEL_DIR, f"fit-{int(time.time()*1000)}.png")
img.save(out)
return out, label
def spin_panel(panel_path, scene_hint, speed, motion, seed) -> str:
if LOAD_ERROR:
raise gr.Error(LOAD_ERROR)
if PIPE is None:
raise gr.Error("The studio is still warming up — retry shortly.")
from PIL import Image, ImageOps
from diffusers.utils import encode_video
lora, steps = SPEEDS.get(speed, SPEEDS[DEFAULT_SPEED])
base = MOTIONS.get(motion, ORBIT_PROMPT)
prompt = f"{base} The scene: {scene_hint.strip()}." if (scene_hint and scene_hint.strip()) else base
panel, canvas = fit_panel(panel_path)
num_frames = snap_frames(ORBIT_FRAMES / FPS)
prompt_embeds, text_token_tags, metadata, plan = encode_remote(prompt, panel, panel, canvas, num_frames)
height, width, num_frames = (int(metadata[k]) for k in ("height", "width", "num_frames"))
keyframe = ImageOps.exif_transpose(Image.open(panel)).convert("RGB")
frames, active = _spin(prompt_embeds, text_token_tags, keyframe, keyframe, height, width, num_frames, steps, int(seed), lora)
path = os.path.join(OUTPUT_DIR, f"orbit-{int(time.time()*1000)}.mp4")
encode_video(frames, fps=FPS, output_path=path) # silent
print(f"[{VERSION}] spun {width}x{height} {num_frames}f {steps}st turbo={active}", flush=True)
sprite = build_orbit_sprite(frames)
return path, sprite
def build_orbit_sprite(frames) -> dict:
"""Tile the orbit frames into one sprite-sheet image so the UI can drag-scrub the angle (frame-accurate, a single
request) instead of autoplaying. Returns the sprite url + grid geometry for the SPA's background-position math."""
import math
import numpy as np
from PIL import Image
def to_pil(fr):
if isinstance(fr, Image.Image):
return fr.convert("RGB")
a = np.asarray(fr)
if a.dtype != np.uint8:
a = ((a * 255) if a.max() <= 1.0 else a).clip(0, 255).astype("uint8")
return Image.fromarray(a).convert("RGB")
imgs = [to_pil(f) for f in frames]
n = len(imgs)
fw = min(640, imgs[0].width) # cap per-frame width; plenty for an on-page panel
fh = round(imgs[0].height * fw / imgs[0].width)
cols = math.ceil(math.sqrt(n))
rows = math.ceil(n / cols)
sheet = Image.new("RGB", (cols * fw, rows * fh), (12, 10, 7))
for k, im in enumerate(imgs):
sheet.paste(im.resize((fw, fh), Image.LANCZOS), ((k % cols) * fw, (k // cols) * fh))
path = os.path.join(OUTPUT_DIR, f"orbit-{int(time.time()*1000)}-sprite.jpg")
sheet.save(path, quality=88)
return {"sprite": _data_url(path), "cols": cols, "rows": rows, "n": n, "fw": fw, "fh": fh}
def canvas_for_aspect(aspect: float) -> tuple[int, int]:
"""Nearest orbit canvas (height, width) to a target aspect — same mapping fit_panel uses at spin time, so the
sketch, the still and the orbit clip all share one canvas (minimal UI crop, no aspect pop when the video swaps in)."""
label = min(ORBIT_CANVASES, key=lambda lab: abs(CANVASES[lab][1] / CANVASES[lab][0] - aspect))
return CANVASES[label]
def gen_space_call(prompt: str, refs: list[str] | None = None, aspect: float | None = None) -> str:
"""Generate a panel via Qwen-Image 2.1 `/generate`: t2i when refs is empty, else subject-consistent to the refs
(a character and/or previous panels). Panel rendered at the cell's canvas so it fits the layout."""
from gradio_client import Client, handle_file
refs = (refs or [])[:3]
r = [handle_file(p) for p in refs] + [None, None, None]
w = h = 0
if aspect:
ch, cw = canvas_for_aspect(aspect)
w, h = cw, ch
try:
result = Client(GEN_SPACE).predict(prompt, r[0], r[1], r[2], 0, True, 6, w, h, api_name=GEN_API)
except Exception as error:
traceback.print_exc()
raise gr.Error(f"Panel Space `{GEN_SPACE}` failed: {type(error).__name__}: {error}") from error
while isinstance(result, (list, tuple)) and result:
result = result[0]
if isinstance(result, dict):
result = result.get("path") or result.get("image") or result.get("url")
if not isinstance(result, str):
raise gr.Error(f"Could not read an image from `{GEN_SPACE}`.")
return save_image(result)
def edit_space_call(src_path: str, instruction: str) -> str:
from gradio_client import Client, handle_file
if not EDIT_SPACE:
raise gr.Error("No edit Space configured.")
try:
result = Client(EDIT_SPACE).predict(handle_file(src_path), instruction, 0, True, 6, api_name=EDIT_API)
except Exception as error:
traceback.print_exc()
raise gr.Error(f"Edit Space `{EDIT_SPACE}` failed: {type(error).__name__}: {error}") from error
while isinstance(result, (list, tuple)) and result:
result = result[0]
if isinstance(result, dict):
result = result.get("path") or result.get("image") or result.get("url")
if not isinstance(result, str):
raise gr.Error(f"Could not read an image from `{EDIT_SPACE}`.")
return save_image(result)
def save_image(src) -> str:
from PIL import Image
path = os.path.join(PANEL_DIR, f"panel-{int(time.time()*1000)}.png")
(src if isinstance(src, Image.Image) else Image.open(src)).convert("RGB").save(path)
return path
def decode_data_url(data_url: str) -> str:
m = re.match(r"data:(image/\w+);base64,(.*)", data_url or "", re.S)
if not m:
raise gr.Error("Bad image data.")
path = os.path.join(PANEL_DIR, f"in-{int(time.time()*1000)}.png")
with open(path, "wb") as f:
f.write(base64.b64decode(m.group(2)))
return save_image(path)
def make_placeholder(prompt: str) -> str:
"""Fallback when no sketch Space is configured: a cream panel with the prompt text, so the flow is still testable."""
from PIL import Image, ImageDraw
img = Image.new("RGB", (768, 768), (239, 230, 208))
d = ImageDraw.Draw(img)
text = (prompt or "untitled panel").strip()
words, line, y = text.split(), "", 300
for w in words:
if len(line + " " + w) > 28:
d.text((60, y), line, fill=(40, 32, 26)); y += 34; line = w
else:
line = (line + " " + w).strip()
d.text((60, y), line, fill=(40, 32, 26))
d.text((60, 60), "sketch placeholder", fill=(150, 130, 100))
path = os.path.join(PANEL_DIR, f"ph-{int(time.time()*1000)}.png")
img.save(path)
return path
def _file_url(path: str) -> str:
# kept only as an opaque round-trip token echoed by the SPA (never fetched by the browser); see _data_url.
return f"/gradio_api/file={path}"
def _data_url(path: str) -> str:
"""Inline an image as a base64 data URL so the SPA renders it with zero dependence on gradio/SSR file serving
(which 404s custom dirs here). Re-encode to JPEG to keep the payload small."""
import base64
import io
from PIL import Image
im = Image.open(path).convert("RGB")
buf = io.BytesIO()
im.save(buf, "JPEG", quality=90)
return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
def _video_data_url(path: str) -> str:
import base64
with open(path, "rb") as f:
return "data:video/mp4;base64," + base64.b64encode(f.read()).decode()
def _url_to_path(url: str) -> str:
path = url.split("file=", 1)[-1]
if not (os.path.abspath(path).startswith(OUTPUT_DIR) or os.path.abspath(path).startswith(PANEL_DIR)):
raise gr.Error("Refusing to read a path outside the studio's own folders.")
return path
def _even(v: float) -> int:
n = int(round(v))
return n - (n % 2)
def composite_page_video(panels: list[dict]) -> str:
panels = [p for p in (panels or []) if p.get("clip") or p.get("still")]
if not panels:
raise gr.Error("Spin at least one panel first.")
rects = layout_for(len(panels))
dur = ORBIT_FRAMES / FPS
args = ["ffmpeg", "-y", "-f", "lavfi", "-i", f"color=c=#f4ead6:s={PAGE_W}x{PAGE_H}:d={dur:.3f}:r={FPS}"]
for p in panels:
if p.get("clip"):
args += ["-i", p["clip"]]
else:
args += ["-loop", "1", "-t", f"{dur:.3f}", "-i", p["still"]]
b = 7
fc, prev = "", "[0:v]"
for i, (p, (x, y, w, h)) in enumerate(zip(panels, rects)):
cw, ch, cx, cy = _even(w * PAGE_W), _even(h * PAGE_H), int(x * PAGE_W), int(y * PAGE_H)
iw, ih = cw - 2 * b, ch - 2 * b
fc += f"[{i+1}:v]scale={iw}:{ih}:force_original_aspect_ratio=increase,crop={iw}:{ih},setsar=1[v{i}];"
out = f"[o{i}]"
fc += f"{prev}[v{i}]overlay={cx+b}:{cy+b}{out};"
prev = out
for i, (x, y, w, h) in enumerate(rects):
cw, ch, cx, cy = _even(w * PAGE_W), _even(h * PAGE_H), int(x * PAGE_W), int(y * PAGE_H)
out = f"[b{i}]"
fc += f"{prev}drawbox=x={cx}:y={cy}:w={cw}:h={ch}:color=#141018:t={b}{out};"
prev = out
fc = fc.rstrip(";")
path = os.path.join(OUTPUT_DIR, f"comic-page-{int(time.time()*1000)}.mp4")
args += ["-filter_complex", fc, "-map", prev, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", str(FPS), "-t", f"{dur:.3f}", path]
try:
subprocess.run(args, check=True, capture_output=True, timeout=180)
except subprocess.CalledProcessError as error:
print(error.stderr.decode()[-1500:], flush=True)
raise gr.Error("Could not compose the page video.") from error
return path
# ── command router: the custom JS writes a JSON command into the hidden bus; this returns a JSON reply ───────────
def handle(raw):
if not raw:
return ""
cmd_json = raw.split("\u0001", 1)[0]
try:
cmd = json.loads(cmd_json)
except Exception:
return ""
op, i = cmd.get("op"), cmd.get("i")
try:
if op == "storyboard":
title, beats = storyboard(cmd.get("story", ""), int(cmd.get("n", 4)), cmd.get("ref"))
return json.dumps({"op": "storyboard", "beats": beats, "title": title})
if op == "example":
import shutil
from PIL import Image
ts = int(time.time() * 1000)
panels_out = []
for k in range(4):
dst = os.path.join(PANEL_DIR, f"example-{k}-{ts}.png")
shutil.copy(os.path.join(EXAMPLE_DIR, f"p{k}.jpg"), dst) # into PANEL_DIR so it's spinnable
panel = {"i": k, "still": _data_url(dst), "ref": _file_url(dst), "prompt": EXAMPLE_BEATS[k]}
# pre-baked orbit (sprite + clip) bundled in the repo -> the example loads already orbiting, no spin wait.
sprite_path = os.path.join(EXAMPLE_DIR, f"p{k}_sprite.jpg")
clip_path = os.path.join(EXAMPLE_DIR, f"p{k}_orbit.mp4")
if os.path.exists(sprite_path) and os.path.exists(clip_path):
w, h = Image.open(sprite_path).size
clip_dst = os.path.join(OUTPUT_DIR, f"example-{k}-{ts}.mp4")
shutil.copy(clip_path, clip_dst)
panel["orbit"] = {"sprite": _data_url(sprite_path), "cols": 9, "rows": 9, "n": 73, "fw": w // 9, "fh": h // 9}
panel["clip"] = _file_url(clip_dst)
panels_out.append(panel)
char = _data_url(os.path.join(EXAMPLE_DIR, "char.jpg"))
return json.dumps({"op": "example", "n": 4, "cast": [{"name": EXAMPLE_CHAR, "img": char}], "panels": panels_out})
if op == "sketch":
prompt = (cmd.get("prompt") or "").strip()
# references for subject consistency: a cast-rail character (data URL) and/or previous panels (file tokens).
refs = []
if cmd.get("char"):
refs.append(decode_data_url(cmd["char"]))
for tok in (cmd.get("prev") or []):
refs.append(_url_to_path(tok))
note = None
if GEN_SPACE:
try:
path = gen_space_call(prompt, refs=refs, aspect=cmd.get("ar"))
except Exception as error: # generator down -> keep the flow alive with a placeholder
traceback.print_exc()
path = (refs[0] if refs else None) or make_placeholder(prompt)
note = f"panel Space unavailable ({type(error).__name__}); using a placeholder"
else:
path = (refs[0] if refs else None) or make_placeholder(prompt)
reply = {"op": "sketch", "i": i, "still": _data_url(path), "ref": _file_url(path)}
if note:
reply["note"] = note
return json.dumps(reply)
if op == "edit":
inst = (cmd.get("prompt") or "").strip()
if not inst:
raise gr.Error("Describe the edit.")
path = edit_space_call(_url_to_path(cmd["url"]), inst)
return json.dumps({"op": "edit", "i": i, "still": _data_url(path), "ref": _file_url(path)})
if op == "drop":
path = decode_data_url(cmd["img"])
return json.dumps({"op": "sketch", "i": i, "still": _data_url(path), "ref": _file_url(path)})
if op == "spin":
clip, orbit = 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)))
# sprite is already a data URL; clip stays an opaque token the SPA echoes back for export.
return json.dumps({"op": "spin", "i": i, "clip": _file_url(clip), **orbit})
if op == "export":
vid = composite_page_video([{"clip": _url_to_path(u)} for u in cmd.get("clips", [])])
return json.dumps({"op": "export", "video": _video_data_url(vid)})
except gr.Error as error:
return json.dumps({"op": op, "i": i, "error": str(error)})
except Exception as error:
traceback.print_exc()
return json.dumps({"op": op, "i": i, "error": f"{type(error).__name__}: {error}"})
return ""
# ── the SPA (custom HTML/CSS/JS) ─────────────────────────────────────────────
load_models()
HEAD = r"""
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@600;800&family=Zen+Maru+Gothic:wght@500;700;900&display=swap" rel="stylesheet">
<style>
: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;}
.gradio-container{max-width:none !important;padding:0 !important;margin:0 !important;}
gradio-app,body,html{background:var(--stage) !important;overflow:hidden !important;}
footer,.footer{display:none !important;}
#comic-app{position:fixed;inset:0;z-index:30;}
#comic-app *{box-sizing:border-box;}
#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%);}
#comic-app .rail{padding:18px 16px;display:flex;flex-direction:column;gap:12px;border-right:1px solid #2c241c;background:#140f0b;overflow:auto;}
#comic-app .rail h2{font-size:.72rem;letter-spacing:.22em;margin:2px 0;color:#b59b74;font-weight:700;}
#comic-app .rail .sub{font-size:.7rem;color:#7c6b55;margin:-6px 0 4px;line-height:1.4;}
#comic-app .brand{border-bottom:1px solid #2c241c;padding-bottom:12px;}
#comic-app .brand .logo{font-family:var(--disp);font-size:1.18rem;color:#f3ead8;letter-spacing:.01em;}
#comic-app .brand .logo b{color:var(--red2);font-style:italic;}
#comic-app .brand .blurb{font-size:.7rem;color:#9a876c;line-height:1.5;margin-top:5px;}
#comic-app .brand .flow{display:flex;align-items:center;gap:5px;margin-top:9px;flex-wrap:wrap;}
#comic-app .brand .flow b{font-size:.62rem;font-weight:800;letter-spacing:.05em;text-transform:uppercase;color:#e8dcc4;background:#241c13;border:1px solid #3c3127;border-radius:6px;padding:3px 7px;}
#comic-app .brand .flow i{color:var(--red2);font-style:normal;font-weight:800;}
#comic-app .exbtn{background:#241c13;border:1px solid #4a3c2c;color:#ffd9a0;border-radius:11px;padding:10px;font-family:var(--ui);font-weight:800;font-size:.82rem;cursor:pointer;}
#comic-app .exbtn:hover{border-color:var(--red2);color:var(--red2);background:#2a2016;}
#comic-app .models{margin-top:auto;font-size:.64rem;color:#6f5f49;line-height:1.7;padding-top:12px;border-top:1px solid #2c241c;}
#comic-app .models a{color:#8c7a60;text-decoration:none;}#comic-app .models a:hover{color:var(--red2);text-decoration:underline;}
#comic-app .char{background:#1d160f;border:1px solid #352a1f;border-radius:14px;padding:9px;display:flex;gap:10px;align-items:center;}
#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;}
#comic-app .char .name{font-weight:700;font-size:.9rem;}#comic-app .char .tag{font-size:.66rem;color:#8c7a60;}
#comic-app .char.add{border-style:dashed;border-color:#4a3c2c;color:#9c8666;justify-content:center;cursor:pointer;font-weight:600;padding:14px;}
#comic-app .char.add:hover{border-color:var(--red2);color:var(--red2);}
#comic-app .count{display:flex;gap:4px;flex-wrap:wrap;}
#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);}
#comic-app .count button.on{background:var(--red);color:#fff;border-color:var(--ink);}
#comic-app .railfoot{margin-top:auto;font-size:.68rem;color:#6f5f49;line-height:1.5;padding-top:12px;}
#comic-app .stage{position:relative;display:flex;align-items:center;justify-content:center;padding:14px 26px 86px;}
#comic-app .storybar{position:absolute;top:12px;left:50%;transform:translateX(-50%);display:flex;gap:8px;align-items:center;width:min(76vw,760px);background:#2b2620ee;border:1px solid #4a3d2c;border-radius:14px;padding:7px 7px 7px 14px;box-shadow:0 8px 26px #0009;z-index:7;}
#comic-app .storybar textarea{flex:1;background:transparent;border:none;outline:none;resize:none;color:#f3ece0;font-family:var(--ui);font-size:.92rem;line-height:1.35;max-height:88px;padding:3px 0;}
#comic-app .storybar textarea::placeholder{color:#8c7a60;}
#comic-app .genpage{flex:0 0 auto;background:var(--red);color:#fff;border:none;border-radius:10px;padding:9px 16px;font-family:var(--ui);font-weight:800;font-size:.86rem;cursor:pointer;box-shadow:0 2px 0 #7e241a;}
#comic-app .genpage:hover{background:var(--red2);}#comic-app .genpage:disabled{opacity:.55;cursor:default;box-shadow:none;}
#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;}
#comic-app .title{position:absolute;top:7px;left:16px;display:flex;align-items:center;gap:10px;z-index:3;}
#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;}
#comic-app .title .en b{font-family:var(--disp);font-style:italic;color:var(--ink);font-size:1rem;outline:none;}
#comic-app .title .en span{display:block;font-size:.62rem;color:#6a5a44;margin-top:2px;}
#comic-app .grid{position:absolute;inset:46px 13px 13px;}
#comic-app .panel{position:absolute;border:var(--bd);background:#efe6d0;overflow:hidden;}
#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;}
#comic-app .panel img,#comic-app .panel video{width:100%;height:100%;object-fit:cover;display:block;}
#comic-app .orbit{position:absolute;inset:0;width:100%;height:100%;background-repeat:no-repeat;cursor:grab;touch-action:none;}
#comic-app .orbit.dragging{cursor:grabbing;}
#comic-app .editbar{position:absolute;left:7px;right:7px;bottom:7px;display:flex;gap:5px;z-index:5;}
#comic-app .editbar .einp{flex:1;min-width:0;background:#fffdf7;border:2px solid var(--ink);border-radius:9px;padding:6px 9px;font-family:var(--ui);font-size:.78rem;color:var(--ink);}
#comic-app .editbar .einp::placeholder{color:#9a876c;}
#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);}
#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;}
#comic-app .empty textarea::placeholder{color:#a9966f;}
#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;}
#comic-app .btn:hover{transform:translate(-1px,-1px);box-shadow:4px 4px 0 var(--ink);}
#comic-app .btn:active{transform:translate(2px,2px);box-shadow:1px 1px 0 var(--ink);}
#comic-app .btn.red{background:var(--red);color:#fff;}#comic-app .btn.cream{background:var(--bubble);color:var(--ink);}
#comic-app .btn.sm{padding:5px 9px;font-size:.72rem;box-shadow:2px 2px 0 var(--ink);}
#comic-app .drop{font-size:.62rem;color:#9a855f;cursor:pointer;text-decoration:underline;}
#comic-app .empty .erow{display:flex;gap:6px;flex-wrap:wrap;justify-content:center;}
#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;}
#comic-app .panel:hover .overlay{opacity:1;}
#comic-app .gen{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#efe6d0;}
#comic-app .ring{width:32px;height:32px;border-radius:50%;border:4px solid #d8c9a4;border-top-color:var(--red);animation:spin 1s linear infinite;}
@keyframes spin{to{transform:rotate(360deg);}}
#comic-app .gen .lbl{position:absolute;bottom:12px;font-size:.7rem;color:#7a684f;font-weight:600;}
#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;}
#comic-app .dot{width:7px;height:7px;border-radius:50%;background:var(--red2);animation:pulse 1.4s infinite;}
@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;}}
#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;}
@keyframes nudge{0%,100%{transform:translateX(0);}50%{transform:translateX(-10px);}}
#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;}
#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;}
#comic-app .tool small{font-size:.6rem;color:#9a876c;}#comic-app .tool:hover{background:#322a21;}
#comic-app .tool.on{background:#f1e7d2;color:var(--ink);}#comic-app .tool.on small{color:#6a5a44;}#comic-app .tool.primary{color:#ffd9a0;}
</style>
"""
APP_TEMPLATE = """
<div class="app">
<aside class="rail">
<div class="brand">
<div class="logo">Comic Orbit <b>Studio</b></div>
<div class="blurb">One line of story → an anime manga page whose panels spin in a 360° camera orbit.</div>
<div class="flow"><b>story</b><i>→</i><b>sketch</b><i>→</i><b>orbit</b><i>→</i><b>drag</b></div>
</div>
<button class="exbtn" data-act="example">▣ Load example page</button>
<h2>CAST</h2>
<div class="sub">Drop a reference so a character stays consistent across panels.</div>
<div id="cast"></div>
<h2 style="margin-top:8px">PAGE</h2>
<div class="count" id="count"></div>
<div class="models">
<b>Models</b><br>
<a href="https://huggingface.co/Qwen/Qwen2.5-VL-3B-Instruct" target="_blank">Qwen2.5&#8209;VL&nbsp;3B</a> storyboard ·
<a href="https://huggingface.co/Qwen/Qwen-Image-2.1" target="_blank">Qwen&#8209;Image&nbsp;2.1</a> +
<a href="https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo" target="_blank">Viggle&nbsp;turbo</a> panels &amp; edits ·
<a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank">MiniMax&#8209;H3</a> +
<a href="https://huggingface.co/pablodawson/MiniMax-H3-360-Orbit-LoRA" target="_blank">ORB360</a> orbit
</div>
</aside>
<main class="stage">
<div class="storybar">
<textarea id="story" rows="1" placeholder="Describe your whole page in one line — e.g. a girl finds a stray cat in the rain and brings it home"></textarea>
<button class="genpage" data-act="genpage">✨ Generate page</button>
</div>
<div class="page" id="page">
<div class="title"><div class="badge">CH<br>01</div>
<div class="en"><b contenteditable="true" spellcheck="false">Untitled</b><span>tap to name your comic</span></div></div>
<div class="grid" id="grid"></div>
</div>
<div class="toolbar">
<button class="tool primary" data-act="spinall">▶ Spin all<small>orbit every panel</small></button>
<button class="tool" data-act="reset">↺ Reset<small>stop motion</small></button>
<button class="tool" data-act="export">⬇ Export<small>one video</small></button>
</div>
</main>
</div>
<input type="file" id="charfile" accept="image/*" style="display:none">
"""
APP_JS = r"""
const el = element;
window.__COMIC = window.__COMIC || {};
const C = window.__COMIC;
const SPEED = "⚡ Fast · 6 steps", MOTION = "🔄 360° orbit", SEED = 42, MAXP = 6;
const LAYOUTS = {
1:[[0,0,1,1]], 2:[[0,0,1,.52],[0,.52,1,.48]],
3:[[0,0,1,.42],[0,.42,.5,.58],[.5,.42,.5,.58]],
4:[[0,0,.58,.5],[.58,0,.42,.5],[0,.5,.42,.5],[.42,.5,.58,.5]],
5:[[0,0,1,.34],[0,.34,.5,.33],[.5,.34,.5,.33],[0,.67,.55,.33],[.55,.67,.45,.33]],
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]]
};
const G = 8;
let N = 4, panels = [], cast = [], queue = [], genOrder = [], paging = false;
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; }
function init(){ panels = Array.from({length:N},()=>({state:"empty",prompt:""})); }
function esc(s){ return (s||"").replace(/[&<>"]/g,m=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[m])); }
function panelHTML(p,i){
if(p.state==="empty") return `<span class="num">${i+1}</span><div class="empty">
<textarea class="ptext" data-i="${i}" placeholder="Describe this panel…">${esc(p.prompt)}</textarea>
<div class="erow"><button class="btn red sm" data-act="sketch" data-i="${i}">✨ Sketch</button>
<button class="btn cream sm" data-act="drop" data-i="${i}">⬆ Upload</button></div></div>`;
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>`;
if(p.state==="sketch") return `<span class="num">${i+1}</span><img src="${p.img}">
<div class="overlay"><button class="btn cream sm" data-act="redo" data-i="${i}">↻ Redo</button>
<button class="btn cream sm" data-act="editopen" data-i="${i}">✎ Edit</button>
<button class="btn cream sm" data-act="drop" data-i="${i}">⬆</button>
<button class="btn red sm" data-act="spin" data-i="${i}">✓ Spin</button></div>`;
if(p.state==="editing") return `<span class="num">${i+1}</span><img src="${p.img}">
<div class="editbar"><input class="einp" data-i="${i}" placeholder="make it night · add snow · red scarf…" value="${esc(p.edit||'')}">
<button class="btn red sm" data-act="editdo" data-i="${i}">Apply</button>
<button class="btn cream sm" data-act="editcancel" data-i="${i}">✕</button></div>`;
const o=p.orbit;
return `<span class="num">${i+1}</span>
<div class="orbit" data-i="${i}" data-n="${o.n}" data-cols="${o.cols}" data-rows="${o.rows}"
style="background-image:url(${o.sprite});background-size:${o.cols*100}% ${o.rows*100}%;background-position:0% 0%;"></div>
<div class="spin-badge"><span class="dot"></span>360°</div><div class="handcur">✋ drag</div>`;
}
function render(){
const grid = el.querySelector("#grid"); grid.innerHTML = "";
const rects = L(N);
for(let i=0;i<N;i++){
const [x,y,w,h]=rects[i], d=document.createElement("div");
d.className="panel";
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);`;
d.innerHTML=panelHTML(panels[i],i);
grid.appendChild(d);
}
const cm = el.querySelector("#cast");
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("")
+ `<div class="char add" data-act="addchar">+ Add character</div>`;
const ct = el.querySelector("#count");
ct.innerHTML=[1,2,3,4,5,6].map(n=>`<button class="${n===N?'on':''}" data-act="count" data-n="${n}">${n}</button>`).join("");
}
function prompt_of(i){ const t=el.querySelector(`.ptext[data-i="${i}"]`); return t?t.value:(panels[i].prompt||""); }
function send(cmd){ if(C.send) C.send(cmd); }
C.apply = function(r){
if(!r) return;
if(r.error){ const p=panels[r.i]; if(p) p.state = p.img?"sketch":"empty"; render();
if(paging){ if(genOrder.length) genNext(); else { paging=false; setGenBtn(false); } return; }
alert(r.error); return; }
if(r.op==="storyboard"){ const b=r.beats||[];
for(let i=0;i<N;i++) panels[i]={state:"empty",prompt:b[i]||panels[i].prompt||""};
if(r.title){ const t=el.querySelector(".title .en b"); if(t) t.textContent=r.title; }
render(); genOrder=Array.from({length:N},(_,i)=>i); paging=true; genNext(); return; }
if(r.op==="example"){ N=r.n||4; panels=Array.from({length:N},()=>({state:"empty",prompt:""}));
cast=(r.cast||[]).map(c=>({name:c.name,img:c.img}));
(r.panels||[]).forEach(p=>{ if(p.orbit){ panels[p.i]={state:"live",img:p.still,ref:p.ref,clip:p.clip,
orbit:{sprite:p.orbit.sprite,cols:p.orbit.cols,rows:p.orbit.rows,n:p.orbit.n,fw:p.orbit.fw,fh:p.orbit.fh,frame:0}};
} else { panels[p.i]={state:"sketch",img:p.still,ref:p.ref,prompt:p.prompt||""}; } });
const b=el.querySelector(".exbtn"); if(b){ b.disabled=false; b.textContent="▣ Load example page"; }
render(); return; }
if(r.op==="edit"){ panels[r.i]={state:"sketch",img:r.still,ref:r.ref,prompt:panels[r.i].prompt||""}; render(); return; }
if(r.op==="sketch"){ panels[r.i]={state:"sketch",img:r.still,ref:r.ref,prompt:panels[r.i].prompt||""}; render();
if(paging){ if(genOrder.length) genNext(); else { paging=false; setGenBtn(false); } } }
else if(r.op==="spin"){ panels[r.i]={state:"live",img:panels[r.i].img,ref:panels[r.i].ref,clip:r.clip,
orbit:{sprite:r.sprite,cols:r.cols,rows:r.rows,n:r.n,fw:r.fw,fh:r.fh,frame:0}}; render();
if(queue.length){ doSpin(queue.shift()); } }
else if(r.op==="export"){ const a=document.createElement("a"); a.href=r.video; a.download="comic-page.mp4"; a.click(); }
};
function cellAspect(i){ const rc=L(N)[i]; return 1.6*rc[2]/rc[3]; }
function setGenBtn(busy){ const b=el.querySelector(".genpage"); if(b){ b.disabled=busy; b.textContent=busy?"✦ Drawing page…":"✨ Generate page"; } }
function doSketch(i){ const pr=prompt_of(i); const ar=cellAspect(i);
const char = cast.length ? cast[0].img : null;
panels[i]={state:"gen",lbl:"Qwen 2.1 sketching…",prompt:pr};
render(); send({op:"sketch",i,prompt:pr,char,prev:[],ar}); }
function generatePage(){ const story=(el.querySelector("#story").value||"").trim();
if(!story){ alert("Describe your page first."); return; }
// the cast reference is sent so the storyboard VLM reads the character into the beats AND Qwen-Image 2.1 uses it as a
// reference per panel; without a cast ref, panels reference previous panels for consistency (see genNext).
setGenBtn(true); paging=true; send({op:"storyboard",story,n:N,ref: cast.length?cast[0].img:null}); }
function loadExample(){ const b=el.querySelector(".exbtn"); if(b){ b.disabled=true; b.textContent="Loading…"; } send({op:"example"}); }
// sequential page gen with Qwen-Image 2.1: with a cast ref every panel references the character; without one, each
// panel references panel 0 (established look) + the previous panel (continuity) so the cast stays consistent.
function genNext(){ if(!genOrder.length){ paging=false; setGenBtn(false); return; }
const i=genOrder.shift(); const pr=panels[i].prompt||"";
const char = cast.length ? cast[0].img : null;
// no character ref -> sequential: reference panel 0 (established look) + the immediately previous panel (continuity).
let prev = [];
if(!char){ if(i>0 && panels[0] && panels[0].ref) prev.push(panels[0].ref);
if(i>1 && panels[i-1] && panels[i-1].ref && panels[i-1].ref!==panels[0].ref) prev.push(panels[i-1].ref); }
const lbl = char ? "Qwen 2.1 · matching cast…" : (prev.length ? "Qwen 2.1 · matching page…" : "Qwen 2.1 · drawing lead…");
panels[i]={state:"gen",lbl,prompt:pr};
render(); send({op:"sketch",i,prompt:pr,char,prev,ar:cellAspect(i)}); }
function editOpen(i){ panels[i]={state:"editing",img:panels[i].img,ref:panels[i].ref,prompt:panels[i].prompt||"",edit:panels[i].edit||""}; render(); }
function editCancel(i){ panels[i]={state:"sketch",img:panels[i].img,ref:panels[i].ref,prompt:panels[i].prompt||""}; render(); }
function editDo(i){ const inp=el.querySelector('.einp[data-i="'+i+'"]'); const inst=inp?inp.value.trim():"";
if(!inst){ alert("Describe the edit."); return; } const img=panels[i].img, ref=panels[i].ref;
panels[i]={state:"gen",lbl:"Qwen 2.1 editing…",img,ref,prompt:panels[i].prompt||""}; render();
send({op:"edit",i,url:ref,prompt:inst}); }
function doSpin(i){ const img=panels[i].img, ref=panels[i].ref; panels[i]={state:"gen",lbl:"ORB360 spinning…",img,ref};
render(); send({op:"spin",i,url:ref,hint:panels[i].prompt||"",speed:SPEED,motion:MOTION,seed:SEED}); }
function spinAll(){ queue=[]; for(let i=0;i<N;i++) if(panels[i].state==="sketch") queue.push(i);
if(queue.length) doSpin(queue.shift()); }
function resetAll(){ panels.forEach((p,i)=>{ if(p.state==="live") panels[i]={state:"sketch",img:p.img,ref:p.ref}; }); render(); }
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}); }
function setN(n){ const old=panels.slice(); N=n; panels=Array.from({length:N},(_,i)=>old[i]||{state:"empty",prompt:""}); render(); }
function pickChar(){ const f=el.querySelector("#charfile"); f.onchange=()=>{ const file=f.files[0]; if(!file)return;
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(); }
function dropImg(i){ const inp=document.createElement("input"); inp.type="file"; inp.accept="image/*";
inp.onchange=()=>{ const file=inp.files[0]; if(!file)return; const rd=new FileReader();
rd.onload=()=>{ panels[i]={state:"gen",lbl:"loading…"}; render(); send({op:"drop",i,img:rd.result}); }; rd.readAsDataURL(file); }; inp.click(); }
el.addEventListener("click",e=>{ const t=e.target.closest("[data-act]"); if(!t)return; const a=t.dataset.act,i=+t.dataset.i;
if(a==="sketch"||a==="redo")doSketch(i); else if(a==="spin")doSpin(i); else if(a==="drop")dropImg(i);
else if(a==="editopen")editOpen(i); else if(a==="editcancel")editCancel(i); else if(a==="editdo")editDo(i);
else if(a==="spinall")spinAll(); else if(a==="reset")resetAll();
else if(a==="export")exportVideo(); else if(a==="addchar")pickChar(); else if(a==="count")setN(+t.dataset.n);
else if(a==="genpage")generatePage(); else if(a==="example")loadExample(); });
el.addEventListener("input",e=>{ const t=e.target; if(t.classList&&t.classList.contains("ptext")) panels[+t.dataset.i].prompt=t.value; });
// drag-to-rotate the orbit: horizontal drag scrubs the sprite frames (a full panel-width drag = one 360° turn).
function orbitPos(o,f){ const c=+o.dataset.cols,r=+o.dataset.rows;
o.style.backgroundPosition = (c>1?(f%c)/(c-1)*100:0)+"% "+(r>1?Math.floor(f/c)/(r-1)*100:0)+"%"; }
let drag=null;
el.addEventListener("pointerdown",e=>{ const o=e.target.closest(".orbit"); if(!o)return;
const i=+o.dataset.i; drag={o,i,n:+o.dataset.n,x0:e.clientX,f0:(panels[i].orbit&&panels[i].orbit.frame)||0,w:o.getBoundingClientRect().width||1};
o.classList.add("dragging"); if(o.setPointerCapture)o.setPointerCapture(e.pointerId);
const h=o.parentElement.querySelector(".handcur"); if(h)h.style.display="none"; e.preventDefault(); });
window.addEventListener("pointermove",e=>{ if(!drag)return;
let f=Math.round(drag.f0 + (e.clientX-drag.x0)/drag.w*drag.n) % drag.n; if(f<0)f+=drag.n;
orbitPos(drag.o,f); if(panels[drag.i].orbit)panels[drag.i].orbit.frame=f; });
window.addEventListener("pointerup",()=>{ if(drag){ drag.o.classList.remove("dragging"); drag=null; } });
init(); render();
"""
BUS_JS = r"""
window.__COMIC = window.__COMIC || {};
window.__COMIC.send = function(cmd){ props.value = JSON.stringify(cmd) + "\u0001" + Date.now(); };
"""
OUT_JS = r"""
watch("value", function(){ try{ if(props.value){ window.__COMIC && window.__COMIC.apply(JSON.parse(props.value)); } }catch(e){ console.error(e); } });
"""
with gr.Blocks(title="Comic Orbit Studio") as demo:
gr.HTML(value="", html_template=APP_TEMPLATE, js_on_load=APP_JS, head=HEAD, elem_id="comic-app", container=False)
bus = gr.HTML(value="", html_template='<div style="display:none"></div>', js_on_load=BUS_JS, container=False, elem_id="comic-bus")
out = gr.HTML(value="", html_template='<div style="display:none"></div>', js_on_load=OUT_JS, container=False, elem_id="comic-out")
bus.change(handle, bus, out, show_progress="hidden")
if __name__ == "__main__":
demo.launch(show_error=True, allowed_paths=[OUTPUT_DIR, PANEL_DIR])