akhaliq HF Staff commited on
Commit
299d59b
·
1 Parent(s): d8b48ff

Rebuild the UI as a custom studio frontend on gradio.Server

Browse files

Server mode: @app .api(name="generate") keeps the queue / ZeroGPU /
gradio_client contract (same name and signature), @app .get("/") serves a
self-contained index.html — dark studio layout with a control deck
(prompt, keyframe dropzones, canvas / duration / steps / seed), a preview
monitor, and a transport-style report bar, driven by the @gradio/client
JS package. /status and /config feed the readiness pill and the canvas
list. Keyframe cover-crop moved server-side into generate so API callers
get it too.

Files changed (3) hide show
  1. README.md +9 -0
  2. app.py +88 -113
  3. index.html +374 -0
README.md CHANGED
@@ -35,6 +35,15 @@ Space is therefore impossible, which is why quantized demos of it run NVFP4 or f
35
  Besides the quality argument, unquantized weights are the ones AoTI can export; an NVFP4 checkpoint cannot be
36
  exported at all.
37
 
 
 
 
 
 
 
 
 
 
38
  ## 4-step Turbo LoRA
39
 
40
  The transformer runs with [`larryvrh/MiniMax-H3-Turbo-Lora`](https://huggingface.co/larryvrh/MiniMax-H3-Turbo-Lora)
 
35
  Besides the quality argument, unquantized weights are the ones AoTI can export; an NVFP4 checkpoint cannot be
36
  exported at all.
37
 
38
+ ## Studio frontend (gradio.Server)
39
+
40
+ The UI is a custom single-page studio (`index.html`) served by [`gradio.Server`](https://www.gradio.app/docs/gradio/server):
41
+ `@app.get("/")` serves the page, `@app.api(name="generate")` keeps the request on Gradio's queue (concurrency control,
42
+ SSE, ZeroGPU booking, `gradio_client` compatibility — the API name and signature are unchanged), and the page talks
43
+ to it with the `@gradio/client` JS package. `/status` and `/config` are plain FastAPI routes the page polls for
44
+ readiness and the canvas table. Keyframe cover-crop / canvas fitting moved server-side into `generate`, so API
45
+ callers get the same treatment the old upload event gave.
46
+
47
  ## 4-step Turbo LoRA
48
 
49
  The transformer runs with [`larryvrh/MiniMax-H3-Turbo-Lora`](https://huggingface.co/larryvrh/MiniMax-H3-Turbo-Lora)
app.py CHANGED
@@ -11,7 +11,9 @@ from functools import cache
11
  # Before anything that could initialize CUDA: `import spaces` patches `torch.cuda` so the 72 GiB load can happen at
12
  # startup rather than on GPU time.
13
  import spaces
14
- import gradio as gr
 
 
15
 
16
  MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
17
  CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
@@ -248,25 +250,67 @@ def _generate(prompt_embeds, text_token_tags, image, last_image, height, width,
248
  return state.get("videos")[0], state.get("audio")[0].cpu(), state.get("sampling_rate")
249
 
250
 
251
- def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVAS, duration=5, steps=4, seed=42, upsample=False, progress=gr.Progress(track_tqdm=True)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  """One request. `upsample` is last and defaults off, so a positional API client that predates it is unaffected."""
253
  if LOAD_ERROR:
254
- raise gr.Error(LOAD_ERROR)
255
  if PIPE is None:
256
- raise gr.Error("The denoiser is still loading.")
257
  if not prompt or not prompt.strip():
258
- raise gr.Error("MiniMax-H3 always takes a prompt, keyframes or not.")
259
 
260
  from PIL import Image, ImageOps
261
 
262
  from diffusers.utils import encode_video
263
 
 
 
 
 
 
 
 
 
 
264
  num_frames = snap_frames(duration)
265
 
266
- progress(0.0, desc=f"Upsampling the prompt on {CONDITIONER_SPACE} ..." if upsample else f"Conditioning on {CONDITIONER_SPACE} ...")
267
  conditioned = time.time()
268
  prompt_embeds, text_token_tags, metadata, plan = encode_remote(
269
- prompt, image_path, last_image_path, canvas, num_frames, rewrite_prompt=upsample
270
  )
271
  condition_seconds = time.time() - conditioned
272
  height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
@@ -277,13 +321,12 @@ def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVA
277
  # exactly this way.
278
  return ImageOps.exif_transpose(Image.open(path)).convert("RGB") if path else None
279
 
280
- progress(0.1, desc=f"Denoising {steps} steps at {width}x{height}, {num_frames} frames ...")
281
  started = time.time()
282
  frames, audio, sampling_rate = _generate(
283
  prompt_embeds,
284
  text_token_tags,
285
- keyframe(image_path),
286
- keyframe(last_image_path),
287
  height,
288
  width,
289
  num_frames,
@@ -298,123 +341,55 @@ def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVA
298
  encode_video(frames, fps=FPS, output_path=path, audio=audio, audio_sample_rate=sampling_rate)
299
 
300
  report = (
301
- f"`{width}x{height}`, {num_frames} frames ({num_frames / FPS:.3f} s), {int(steps)} steps · "
302
  f"conditioner {condition_seconds:.0f}s ({plan['num_text_tokens']} tokens"
303
  f"{', upsampled' if refined else ''}) · "
304
  f"denoise + decode {generate_seconds:.0f}s ({generate_seconds / int(steps):.1f} s/step) · seed {int(seed)}"
305
  )
306
  print(f"[gen] {report}", flush=True)
307
- return path, report, refined, gr.update(visible=bool(refined))
308
 
309
 
310
- def _fit_keyframe(image_path, current_canvas):
311
- """Cover-crop an uploaded keyframe to the closest supported aspect ratio and select that ratio's smallest
312
- (fastest) canvas, unless the user already picked a matching ratio."""
313
- if not image_path:
314
- return gr.update(), gr.update()
315
- from PIL import Image as _Image
316
 
317
- img = _Image.open(image_path)
318
- aspect = img.width / img.height
319
- fastest = {}
320
- for label, (h, w) in CANVASES.items():
321
- r = w / h
322
- if r not in fastest or w * h < fastest[r][1][0] * fastest[r][1][1]:
323
- fastest[r] = (label, (h, w))
324
- ratio = min(fastest, key=lambda r: abs(r - aspect))
325
- label, (h, w) = fastest[ratio]
326
 
327
- cur_h, cur_w = CANVASES[current_canvas]
328
- if abs(cur_w / cur_h - aspect) <= abs(ratio - aspect):
329
- label = current_canvas
330
- h, w = cur_h, cur_w
331
 
332
- target = w / h
333
- if abs(img.width / img.height - target) <= 1e-3:
334
- return gr.update(), gr.update(value=label)
335
- if img.width / img.height > target:
336
- new_w = int(img.height * target)
337
- left = (img.width - new_w) // 2
338
- img = img.crop((left, 0, left + new_w, img.height))
339
- else:
340
- new_h = int(img.width / target)
341
- top = (img.height - new_h) // 2
342
- img = img.crop((0, top, img.width, top + new_h))
343
- img.save(image_path)
344
- return gr.update(value=image_path), gr.update(value=label)
345
 
346
 
347
- load_models()
 
 
 
348
 
349
- INTRO = """# MiniMax-H3
350
-
351
- <div align="center">
352
- <a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank" rel="noopener"><strong>[ model ]</strong></a> &nbsp;
353
- <a href="https://www.minimax.io/blog/minimax-h3" target="_blank" rel="noopener"><strong>[ blog ]</strong></a> &nbsp;
354
- <a href="https://huggingface.co/spaces/multimodalart/minimax-h3-reference" target="_blank" rel="noopener"><strong>[ reference to video ]</strong></a>
355
- </div>
356
-
357
- **MiniMax-H3** is a 33B parameter state of the art video generation model that produces video and a
358
- fully synchronized soundtrack (ambience, foley, speech).
359
- """
360
-
361
- CSS = """
362
- .main.fillable {max-width: 1250px !important}
363
- .dark .gradio-container { color: var(--body-text-color); }
364
- """
365
-
366
- with gr.Blocks(title="MiniMax-H3") as demo:
367
- gr.Markdown(INTRO)
368
-
369
- with gr.Row():
370
- with gr.Column():
371
- prompt = gr.Textbox(
372
- label="Prompt",
373
- lines=3,
374
- value="A red fox trotting through a snowy pine forest at dawn, snow crunching underfoot",
375
- )
376
- upsample = gr.Checkbox(label="Upsample prompt", value=False)
377
- with gr.Row():
378
- image = gr.Image(label="First frame (optional)", type="filepath")
379
- last_image = gr.Image(label="Last frame (optional)", type="filepath")
380
- run = gr.Button("Generate", variant="primary")
381
- with gr.Accordion("Advanced options", open=False):
382
- canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
383
- duration = gr.Slider(label="Duration (s)", minimum=MIN_UI_DURATION, maximum=MAX_UI_DURATION, step=1, value=5)
384
- steps = gr.Slider(label="Steps", minimum=2, maximum=40, step=1, value=4)
385
- seed = gr.Number(label="Seed", value=42, precision=0)
386
-
387
- with gr.Column():
388
- video = gr.Video(label="Video + soundtrack")
389
- report = gr.Markdown(visible=False)
390
- # An output, so it can be revealed only for a request that asked for a rewrite.
391
- with gr.Accordion("Upsampled prompt", open=False, visible=False) as upsampled_panel:
392
- upsampled = gr.Textbox(show_label=False, lines=8, interactive=False)
393
-
394
- image.upload(_fit_keyframe, [image, canvas], [image, canvas])
395
-
396
- gr.Examples(
397
- examples=[
398
- ["A red fox trotting through a snowy pine forest at dawn, snow crunching underfoot", None, None, "1344x768 · 16:9 full"],
399
- ["A busy night market, neon signs reflecting in puddles, sizzling street food", None, None, "768x1344 · 9:16 full"],
400
- ["A cellist playing a slow melody in an empty concert hall", None, None, "768x768 · 1:1 full"],
401
- ["The fox looks around, then trots deeper into the forest", "examples/first.png", None, "1344x768 · 16:9 full"],
402
- ["A slow seamless camera move from the first view to the last", "examples/first.png", "examples/last.png", "1344x768 · 16:9 full"],
403
- ],
404
- inputs=[prompt, image, last_image, canvas],
405
- outputs=[video, report, upsampled, upsampled_panel],
406
- fn=generate,
407
- cache_examples=True,
408
- cache_mode="lazy",
409
- )
410
 
411
- run.click(
412
- generate,
413
- [prompt, image, last_image, canvas, duration, steps, seed, upsample],
414
- [video, report, upsampled, upsampled_panel],
415
- api_name="generate",
416
- )
 
 
 
 
 
 
 
 
 
417
 
418
 
 
 
419
  if __name__ == "__main__":
420
- demo.launch(show_error=True, theme=gr.themes.Citrus(), css=CSS)
 
11
  # Before anything that could initialize CUDA: `import spaces` patches `torch.cuda` so the 72 GiB load can happen at
12
  # startup rather than on GPU time.
13
  import spaces
14
+ from fastapi.responses import HTMLResponse
15
+ from gradio import Server
16
+ from gradio.data_classes import FileData
17
 
18
  MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
19
  CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
 
250
  return state.get("videos")[0], state.get("audio")[0].cpu(), state.get("sampling_rate")
251
 
252
 
253
+ def _fit_keyframe(image_path, current_canvas):
254
+ """Cover-crop an uploaded keyframe to the closest supported aspect ratio and pick that ratio's smallest
255
+ (fastest) canvas, unless the caller already picked a matching ratio. Returns `(image_path, canvas_label)`."""
256
+ from PIL import Image as _Image
257
+
258
+ img = _Image.open(image_path)
259
+ aspect = img.width / img.height
260
+ fastest = {}
261
+ for label, (h, w) in CANVASES.items():
262
+ r = w / h
263
+ if r not in fastest or w * h < fastest[r][1][0] * fastest[r][1][1]:
264
+ fastest[r] = (label, (h, w))
265
+ ratio = min(fastest, key=lambda r: abs(r - aspect))
266
+ label, (h, w) = fastest[ratio]
267
+
268
+ cur_h, cur_w = CANVASES[current_canvas]
269
+ if abs(cur_w / cur_h - aspect) <= abs(ratio - aspect):
270
+ label = current_canvas
271
+ h, w = cur_h, cur_w
272
+
273
+ target = w / h
274
+ if abs(img.width / img.height - target) > 1e-3:
275
+ if img.width / img.height > target:
276
+ new_w = int(img.height * target)
277
+ left = (img.width - new_w) // 2
278
+ img = img.crop((left, 0, left + new_w, img.height))
279
+ else:
280
+ new_h = int(img.width / target)
281
+ top = (img.height - new_h) // 2
282
+ img = img.crop((0, top, img.width, top + new_h))
283
+ img.save(image_path)
284
+ return image_path, label
285
+
286
+
287
+ def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVAS, duration=5, steps=4, seed=42, upsample=False):
288
  """One request. `upsample` is last and defaults off, so a positional API client that predates it is unaffected."""
289
  if LOAD_ERROR:
290
+ raise Exception(LOAD_ERROR)
291
  if PIPE is None:
292
+ raise Exception("The denoiser is still loading.")
293
  if not prompt or not prompt.strip():
294
+ raise Exception("MiniMax-H3 always takes a prompt, keyframes or not.")
295
 
296
  from PIL import Image, ImageOps
297
 
298
  from diffusers.utils import encode_video
299
 
300
+ # Server mode: keyframes arrive as FileData dicts, and the cover-crop / canvas-fit that used to be an upload
301
+ # event in the Blocks UI runs here instead, so API callers get the same treatment.
302
+ first = image_path["path"] if isinstance(image_path, dict) else image_path
303
+ last = last_image_path["path"] if isinstance(last_image_path, dict) else last_image_path
304
+ if first:
305
+ first, canvas = _fit_keyframe(first, canvas)
306
+ if last:
307
+ last, canvas = _fit_keyframe(last, canvas)
308
+
309
  num_frames = snap_frames(duration)
310
 
 
311
  conditioned = time.time()
312
  prompt_embeds, text_token_tags, metadata, plan = encode_remote(
313
+ prompt, first, last, canvas, num_frames, rewrite_prompt=upsample
314
  )
315
  condition_seconds = time.time() - conditioned
316
  height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
 
321
  # exactly this way.
322
  return ImageOps.exif_transpose(Image.open(path)).convert("RGB") if path else None
323
 
 
324
  started = time.time()
325
  frames, audio, sampling_rate = _generate(
326
  prompt_embeds,
327
  text_token_tags,
328
+ keyframe(first),
329
+ keyframe(last),
330
  height,
331
  width,
332
  num_frames,
 
341
  encode_video(frames, fps=FPS, output_path=path, audio=audio, audio_sample_rate=sampling_rate)
342
 
343
  report = (
344
+ f"{width}x{height} · {num_frames} frames ({num_frames / FPS:.3f} s) · {int(steps)} steps · "
345
  f"conditioner {condition_seconds:.0f}s ({plan['num_text_tokens']} tokens"
346
  f"{', upsampled' if refined else ''}) · "
347
  f"denoise + decode {generate_seconds:.0f}s ({generate_seconds / int(steps):.1f} s/step) · seed {int(seed)}"
348
  )
349
  print(f"[gen] {report}", flush=True)
350
+ return FileData(path=path), report, refined
351
 
352
 
 
 
 
 
 
 
353
 
354
+ # ======================================================================
355
+ # Server mode: Gradio's API engine (queue, SSE, concurrency, ZeroGPU,
356
+ # gradio_client) under a fully custom studio frontend (index.html).
357
+ # ======================================================================
358
+ app = Server(title="MiniMax-H3 Studio")
 
 
 
 
359
 
 
 
 
 
360
 
361
+ @app.api(name="generate")
362
+ def _generate_api(prompt: str, image_path: FileData | None = None, last_image_path: FileData | None = None,
363
+ canvas: str = DEFAULT_CANVAS, duration: float = 5, steps: int = 4, seed: float = 42,
364
+ upsample: bool = False) -> tuple[FileData, str, str]:
365
+ """Generate a video with a synchronized soundtrack. Returns (video, report, refined prompt)."""
366
+ return generate(prompt, image_path, last_image_path, canvas, duration, steps, seed, upsample)
 
 
 
 
 
 
 
367
 
368
 
369
+ @app.get("/status")
370
+ def studio_status():
371
+ """Polled by the frontend: is the denoiser ready, and the human-readable status line."""
372
+ return {"ready": PIPE is not None and LOAD_ERROR is None, "status": status()}
373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
 
375
+ @app.get("/config")
376
+ def studio_config():
377
+ """The canvas table and slider ranges, so the frontend never hardcodes a label the backend would reject."""
378
+ return {
379
+ "canvases": list(CANVASES),
380
+ "default_canvas": DEFAULT_CANVAS,
381
+ "min_duration": MIN_UI_DURATION,
382
+ "max_duration": MAX_UI_DURATION,
383
+ }
384
+
385
+
386
+ @app.get("/", response_class=HTMLResponse)
387
+ def homepage():
388
+ with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html"), encoding="utf-8") as f:
389
+ return f.read()
390
 
391
 
392
+ load_models()
393
+
394
  if __name__ == "__main__":
395
+ app.launch(show_error=True)
index.html ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>MiniMax-H3 Studio</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0b0d10;
10
+ --panel: #12151a;
11
+ --panel-2: #171b21;
12
+ --edge: #23282f;
13
+ --edge-hi: #31383f;
14
+ --text: #e8eaed;
15
+ --dim: #9aa3ad;
16
+ --faint: #5c6670;
17
+ --accent: #f59e0b;
18
+ --accent-dim: #92610a;
19
+ --go: #22c55e;
20
+ --err: #ef4444;
21
+ --mono: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
22
+ }
23
+ * { margin: 0; box-sizing: border-box; }
24
+ body {
25
+ background: var(--bg); color: var(--text);
26
+ font: 14px/1.5 -apple-system, "Segoe UI", Inter, Roboto, sans-serif;
27
+ height: 100vh; display: flex; flex-direction: column; overflow: hidden;
28
+ }
29
+
30
+ /* ---- top bar ---- */
31
+ header {
32
+ display: flex; align-items: center; gap: 14px;
33
+ padding: 0 18px; height: 52px; flex: none;
34
+ background: var(--panel); border-bottom: 1px solid var(--edge);
35
+ }
36
+ .logo { font-weight: 700; letter-spacing: .4px; font-size: 15px; }
37
+ .logo b { color: var(--accent); }
38
+ .logo span { color: var(--faint); font-weight: 400; margin-left: 8px; font-size: 12px; }
39
+ header .links { margin-left: auto; display: flex; gap: 14px; align-items: center; }
40
+ header a { color: var(--dim); text-decoration: none; font-size: 12px; }
41
+ header a:hover { color: var(--text); }
42
+ #status-pill {
43
+ display: flex; align-items: center; gap: 7px;
44
+ font: 11px var(--mono); color: var(--dim);
45
+ border: 1px solid var(--edge); border-radius: 99px; padding: 4px 12px;
46
+ max-width: 46vw; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
47
+ }
48
+ #status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex: none; animation: pulse 1.2s infinite; }
49
+ #status-pill.ready #status-dot { background: var(--go); animation: none; }
50
+ #status-pill.error #status-dot { background: var(--err); animation: none; }
51
+ @keyframes pulse { 50% { opacity: .35; } }
52
+
53
+ main { flex: 1; display: flex; min-height: 0; }
54
+
55
+ /* ---- control deck ---- */
56
+ aside {
57
+ width: 340px; flex: none; overflow-y: auto;
58
+ background: var(--panel); border-right: 1px solid var(--edge);
59
+ padding: 16px; display: flex; flex-direction: column; gap: 14px;
60
+ }
61
+ .deck-label {
62
+ font: 10px var(--mono); letter-spacing: 1.5px; color: var(--faint);
63
+ text-transform: uppercase; margin-bottom: 6px;
64
+ }
65
+ textarea, select, input[type=number] {
66
+ width: 100%; background: var(--panel-2); color: var(--text);
67
+ border: 1px solid var(--edge); border-radius: 8px;
68
+ padding: 10px 12px; font: 13px/1.5 inherit; resize: vertical;
69
+ }
70
+ textarea:focus, select:focus, input:focus { outline: none; border-color: var(--accent-dim); }
71
+ textarea { min-height: 96px; }
72
+
73
+ .dropzone {
74
+ border: 1.5px dashed var(--edge-hi); border-radius: 8px;
75
+ min-height: 74px; display: flex; align-items: center; justify-content: center;
76
+ color: var(--faint); font-size: 12px; cursor: pointer; position: relative;
77
+ overflow: hidden; text-align: center; padding: 6px; transition: border-color .15s;
78
+ }
79
+ .dropzone:hover, .dropzone.drag { border-color: var(--accent); color: var(--dim); }
80
+ .dropzone img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
81
+ .dropzone .clear {
82
+ position: absolute; top: 4px; right: 6px; z-index: 2; color: #fff;
83
+ background: rgba(0,0,0,.6); border-radius: 4px; padding: 0 6px; font-size: 14px; display: none;
84
+ }
85
+ .dropzone.filled .clear { display: block; }
86
+ .frames-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
87
+
88
+ .check { display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: 13px; cursor: pointer; }
89
+ .check input { accent-color: var(--accent); }
90
+
91
+ details { border: 1px solid var(--edge); border-radius: 8px; background: var(--panel-2); }
92
+ summary {
93
+ padding: 9px 12px; cursor: pointer; font: 11px var(--mono);
94
+ letter-spacing: 1px; color: var(--dim); text-transform: uppercase; user-select: none;
95
+ }
96
+ details .body { padding: 4px 12px 12px; display: flex; flex-direction: column; gap: 12px; }
97
+ .slider-row { display: flex; justify-content: space-between; font-size: 12px; color: var(--dim); margin-bottom: 2px; }
98
+ .slider-row output { font-family: var(--mono); color: var(--text); }
99
+ input[type=range] { width: 100%; accent-color: var(--accent); }
100
+
101
+ #run {
102
+ margin-top: auto; border: none; border-radius: 8px; padding: 13px;
103
+ background: var(--accent); color: #111; font: 700 14px inherit;
104
+ letter-spacing: .5px; cursor: pointer; transition: filter .15s, opacity .15s;
105
+ }
106
+ #run:hover:not(:disabled) { filter: brightness(1.1); }
107
+ #run:disabled { opacity: .45; cursor: not-allowed; }
108
+
109
+ /* ---- stage ---- */
110
+ section.stage { flex: 1; display: flex; flex-direction: column; min-width: 0; }
111
+ .monitor-wrap {
112
+ flex: 1; display: flex; align-items: center; justify-content: center;
113
+ padding: 22px; min-height: 0;
114
+ background: radial-gradient(ellipse at 50% 40%, #10141a 0%, var(--bg) 75%);
115
+ }
116
+ .monitor {
117
+ position: relative; max-width: 100%; max-height: 100%;
118
+ border: 1px solid var(--edge-hi); border-radius: 10px; overflow: hidden;
119
+ background: #000; box-shadow: 0 24px 70px rgba(0,0,0,.55);
120
+ display: flex; align-items: center; justify-content: center;
121
+ }
122
+ .monitor video { display: block; max-width: 100%; max-height: calc(100vh - 220px); }
123
+ .monitor .placeholder {
124
+ position: absolute; inset: 0; display: flex; flex-direction: column;
125
+ align-items: center; justify-content: center; gap: 10px; color: var(--faint);
126
+ font: 12px var(--mono); letter-spacing: 1px; text-align: center; padding: 20px;
127
+ }
128
+ .monitor .placeholder .rec { width: 46px; height: 46px; border: 1.5px solid var(--edge-hi); border-radius: 50%;
129
+ display: flex; align-items: center; justify-content: center; }
130
+ .monitor .placeholder .rec::after { content: ""; width: 14px; height: 14px; border-radius: 50%; background: var(--edge-hi); }
131
+ .monitor.hidden-video video { display: none; }
132
+
133
+ /* ---- transport / report bar ---- */
134
+ .transport {
135
+ flex: none; border-top: 1px solid var(--edge); background: var(--panel);
136
+ padding: 10px 18px; display: flex; align-items: center; gap: 16px;
137
+ font: 12px var(--mono); color: var(--dim); min-height: 44px;
138
+ }
139
+ #job-state { color: var(--accent); }
140
+ #job-state.done { color: var(--go); }
141
+ #job-state.failed { color: var(--err); }
142
+ #report { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
143
+ #elapsed { color: var(--faint); flex: none; }
144
+
145
+ #refined-bar {
146
+ flex: none; display: none; border-top: 1px solid var(--edge);
147
+ background: var(--panel-2); padding: 10px 18px; font-size: 12px; color: var(--dim);
148
+ max-height: 110px; overflow-y: auto;
149
+ }
150
+ #refined-bar b { color: var(--faint); font: 10px var(--mono); letter-spacing: 1px; text-transform: uppercase; display: block; margin-bottom: 4px; }
151
+
152
+ .examples { display: flex; flex-direction: column; gap: 6px; }
153
+ .examples button {
154
+ text-align: left; background: var(--panel-2); border: 1px solid var(--edge);
155
+ color: var(--dim); border-radius: 6px; padding: 7px 10px; font-size: 12px; cursor: pointer;
156
+ }
157
+ .examples button:hover { color: var(--text); border-color: var(--edge-hi); }
158
+
159
+ @media (max-width: 860px) {
160
+ main { flex-direction: column; overflow-y: auto; }
161
+ aside { width: 100%; border-right: none; border-bottom: 1px solid var(--edge); }
162
+ body { overflow: auto; }
163
+ }
164
+ </style>
165
+ </head>
166
+ <body>
167
+
168
+ <header>
169
+ <div class="logo">MiniMax-<b>H3</b> Studio<span>video + synchronized soundtrack · 4-step turbo</span></div>
170
+ <div class="links">
171
+ <div id="status-pill"><span id="status-dot"></span><span id="status-text">connecting…</span></div>
172
+ <a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank" rel="noopener">model</a>
173
+ <a href="https://huggingface.co/larryvrh/MiniMax-H3-Turbo-Lora" target="_blank" rel="noopener">turbo lora</a>
174
+ </div>
175
+ </header>
176
+
177
+ <main>
178
+ <aside>
179
+ <div>
180
+ <div class="deck-label">Prompt</div>
181
+ <textarea id="prompt" spellcheck="false">A red fox trotting through a snowy pine forest at dawn, snow crunching underfoot</textarea>
182
+ </div>
183
+ <label class="check"><input type="checkbox" id="upsample"> Upsample prompt</label>
184
+
185
+ <div>
186
+ <div class="deck-label">Keyframes (optional)</div>
187
+ <div class="frames-row">
188
+ <div class="dropzone" id="dz-first">First frame<span class="clear">×</span></div>
189
+ <div class="dropzone" id="dz-last">Last frame<span class="clear">×</span></div>
190
+ </div>
191
+ </div>
192
+
193
+ <details open>
194
+ <summary>Shot settings</summary>
195
+ <div class="body">
196
+ <div>
197
+ <div class="deck-label">Canvas</div>
198
+ <select id="canvas"></select>
199
+ </div>
200
+ <div>
201
+ <div class="slider-row"><span>Duration</span><output id="duration-out">5 s</output></div>
202
+ <input type="range" id="duration" min="2" max="14" step="1" value="5">
203
+ </div>
204
+ <div>
205
+ <div class="slider-row"><span>Steps</span><output id="steps-out">4</output></div>
206
+ <input type="range" id="steps" min="2" max="40" step="1" value="4">
207
+ </div>
208
+ <div>
209
+ <div class="deck-label">Seed</div>
210
+ <input type="number" id="seed" value="42" step="1">
211
+ </div>
212
+ </div>
213
+ </details>
214
+
215
+ <div>
216
+ <div class="deck-label">Examples</div>
217
+ <div class="examples" id="examples"></div>
218
+ </div>
219
+
220
+ <button id="run">▶&nbsp; Generate</button>
221
+ </aside>
222
+
223
+ <section class="stage">
224
+ <div class="monitor-wrap">
225
+ <div class="monitor hidden-video" id="monitor">
226
+ <video id="video" controls playsinline></video>
227
+ <div class="placeholder" id="placeholder">
228
+ <div class="rec"></div>
229
+ <div>STANDBY — CUT A PROMPT AND ROLL</div>
230
+ </div>
231
+ </div>
232
+ </div>
233
+ <div id="refined-bar"><b>Upsampled prompt</b><span id="refined"></span></div>
234
+ <div class="transport">
235
+ <span id="job-state">IDLE</span>
236
+ <span id="report"></span>
237
+ <span id="elapsed"></span>
238
+ </div>
239
+ </section>
240
+ </main>
241
+
242
+ <input type="file" id="file-first" accept="image/*" hidden>
243
+ <input type="file" id="file-last" accept="image/*" hidden>
244
+
245
+ <script type="module">
246
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
247
+
248
+ const $ = (id) => document.getElementById(id);
249
+ const state = { first: null, last: null, busy: false, timer: null };
250
+
251
+ /* ---- config + status ---- */
252
+ fetch("/config").then(r => r.json()).then(cfg => {
253
+ const sel = $("canvas");
254
+ for (const label of cfg.canvases) {
255
+ const o = document.createElement("option");
256
+ o.value = o.textContent = label;
257
+ if (label === cfg.default_canvas) o.selected = true;
258
+ sel.appendChild(o);
259
+ }
260
+ $("duration").min = cfg.min_duration; $("duration").max = cfg.max_duration;
261
+ });
262
+
263
+ async function pollStatus() {
264
+ try {
265
+ const s = await (await fetch("/status")).json();
266
+ const pill = $("status-pill");
267
+ $("status-text").textContent = s.status.replace(/[*`]/g, "");
268
+ pill.classList.toggle("ready", s.ready);
269
+ pill.classList.toggle("error", !s.ready && /failed/i.test(s.status));
270
+ if (s.ready) return;
271
+ } catch (e) { /* still booting */ }
272
+ setTimeout(pollStatus, 5000);
273
+ }
274
+ pollStatus();
275
+
276
+ /* ---- sliders ---- */
277
+ const bind = (id, fmt) => $(id).addEventListener("input", e => $(id + "-out").textContent = fmt(e.target.value));
278
+ bind("duration", v => v + " s");
279
+ bind("steps", v => v);
280
+
281
+ /* ---- dropzones ---- */
282
+ function wireDropzone(dzId, inputId, key) {
283
+ const dz = $(dzId), input = $(inputId);
284
+ const set = (file) => {
285
+ if (!file) return;
286
+ state[key] = file;
287
+ const img = document.createElement("img");
288
+ img.src = URL.createObjectURL(file);
289
+ dz.appendChild(img);
290
+ dz.classList.add("filled");
291
+ };
292
+ dz.addEventListener("click", (e) => {
293
+ if (e.target.classList.contains("clear")) {
294
+ state[key] = null; dz.classList.remove("filled");
295
+ dz.querySelector("img")?.remove(); input.value = "";
296
+ } else input.click();
297
+ });
298
+ input.addEventListener("change", () => set(input.files[0]));
299
+ dz.addEventListener("dragover", e => { e.preventDefault(); dz.classList.add("drag"); });
300
+ dz.addEventListener("dragleave", () => dz.classList.remove("drag"));
301
+ dz.addEventListener("drop", e => { e.preventDefault(); dz.classList.remove("drag"); set(e.dataTransfer.files[0]); });
302
+ }
303
+ wireDropzone("dz-first", "file-first", "first");
304
+ wireDropzone("dz-last", "file-last", "last");
305
+
306
+ /* ---- examples ---- */
307
+ const EXAMPLES = [
308
+ ["A red fox trotting through a snowy pine forest at dawn, snow crunching underfoot", "1344x768 · 16:9 full"],
309
+ ["A busy night market, neon signs reflecting in puddles, sizzling street food", "768x1344 · 9:16 full"],
310
+ ["A cellist playing a slow melody in an empty concert hall", "768x768 · 1:1 full"],
311
+ ["Waves crashing against basalt cliffs at golden hour, gulls crying overhead", "1152x640 · 16:9"],
312
+ ];
313
+ for (const [p, c] of EXAMPLES) {
314
+ const b = document.createElement("button");
315
+ b.textContent = p.length > 60 ? p.slice(0, 60) + "…" : p;
316
+ b.title = p;
317
+ b.onclick = () => { $("prompt").value = p; $("canvas").value = c; };
318
+ $("examples").appendChild(b);
319
+ }
320
+
321
+ /* ---- generate ---- */
322
+ const client = await Client.connect(window.location.origin);
323
+
324
+ function setJob(label, cls) {
325
+ const el = $("job-state");
326
+ el.textContent = label; el.className = cls || "";
327
+ }
328
+
329
+ $("run").addEventListener("click", async () => {
330
+ if (state.busy) return;
331
+ const prompt = $("prompt").value.trim();
332
+ if (!prompt) { setJob("FAILED", "failed"); $("report").textContent = "a prompt is required"; return; }
333
+
334
+ state.busy = true;
335
+ $("run").disabled = true;
336
+ $("refined-bar").style.display = "none";
337
+ $("report").textContent = "";
338
+ setJob("ROLLING", "");
339
+ const t0 = Date.now();
340
+ state.timer = setInterval(() => $("elapsed").textContent = ((Date.now() - t0) / 1000).toFixed(0) + "s", 500);
341
+
342
+ try {
343
+ const result = await client.predict("/generate", {
344
+ prompt,
345
+ image_path: state.first ? handle_file(state.first) : null,
346
+ last_image_path: state.last ? handle_file(state.last) : null,
347
+ canvas: $("canvas").value,
348
+ duration: Number($("duration").value),
349
+ steps: Number($("steps").value),
350
+ seed: Number($("seed").value),
351
+ upsample: $("upsample").checked,
352
+ });
353
+ const [video, report, refined] = result.data;
354
+ $("video").src = video.url;
355
+ $("monitor").classList.remove("hidden-video");
356
+ $("placeholder").style.display = "none";
357
+ $("report").textContent = report;
358
+ if (refined) {
359
+ $("refined").textContent = refined;
360
+ $("refined-bar").style.display = "block";
361
+ }
362
+ setJob("DONE", "done");
363
+ } catch (e) {
364
+ setJob("FAILED", "failed");
365
+ $("report").textContent = (e && e.message) ? e.message.slice(0, 300) : String(e);
366
+ } finally {
367
+ clearInterval(state.timer);
368
+ state.busy = false;
369
+ $("run").disabled = false;
370
+ }
371
+ });
372
+ </script>
373
+ </body>
374
+ </html>