01
"""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""" """ APP_TEMPLATE = """