Spaces:
Running on Zero
Running on Zero
Download app.py from linoyts/comic-orbit-studio: direct link, hf CLI and curl.
- Browser
- Download file 52.2 kB
-
https://huggingface.co/spaces/linoyts/comic-orbit-studio/resolve/main/app.py
- Command line
-
hf download hf://spaces/linoyts/comic-orbit-studio/app.py
-
curl -L -o app.py https://huggingface.co/spaces/linoyts/comic-orbit-studio/resolve/main/app.py
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 | |
| 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) | |
| 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‑VL 3B</a> storyboard · | |
| <a href="https://huggingface.co/Qwen/Qwen-Image-2.1" target="_blank">Qwen‑Image 2.1</a> + | |
| <a href="https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo" target="_blank">Viggle turbo</a> panels & edits · | |
| <a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank">MiniMax‑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=>({"&":"&","<":"<",">":">",'"':"""}[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]) | |