linoyts HF Staff commited on
Commit
b3fa65b
·
verified ·
1 Parent(s): 070b50f

Upload app.py with huggingface_hub

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