Spaces:
Running on Zero
Running on Zero
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>MiniMax-H3 Studio</title> | |
| <style> | |
| :root { | |
| --bg: #0b0d10; | |
| --panel: #12151a; | |
| --panel-2: #171b21; | |
| --edge: #23282f; | |
| --edge-hi: #31383f; | |
| --text: #e8eaed; | |
| --dim: #9aa3ad; | |
| --faint: #5c6670; | |
| --accent: #f59e0b; | |
| --accent-dim: #92610a; | |
| --go: #22c55e; | |
| --err: #ef4444; | |
| --mono: "SF Mono", ui-monospace, Menlo, Consolas, monospace; | |
| } | |
| * { margin: 0; box-sizing: border-box; } | |
| body { | |
| background: var(--bg); color: var(--text); | |
| font: 14px/1.5 -apple-system, "Segoe UI", Inter, Roboto, sans-serif; | |
| height: 100vh; display: flex; flex-direction: column; overflow: hidden; | |
| } | |
| /* ---- top bar ---- */ | |
| header { | |
| display: flex; align-items: center; gap: 14px; | |
| padding: 0 18px; height: 52px; flex: none; | |
| background: var(--panel); border-bottom: 1px solid var(--edge); | |
| } | |
| .logo { font-weight: 700; letter-spacing: .4px; font-size: 15px; } | |
| .logo b { color: var(--accent); } | |
| .logo span { color: var(--faint); font-weight: 400; margin-left: 8px; font-size: 12px; } | |
| header .links { margin-left: auto; display: flex; gap: 14px; align-items: center; } | |
| header a { color: var(--dim); text-decoration: none; font-size: 12px; } | |
| header a:hover { color: var(--text); } | |
| #status-pill { | |
| display: flex; align-items: center; gap: 7px; | |
| font: 11px var(--mono); color: var(--dim); | |
| border: 1px solid var(--edge); border-radius: 99px; padding: 4px 12px; | |
| max-width: 46vw; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| } | |
| #status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex: none; animation: pulse 1.2s infinite; } | |
| #status-pill.ready #status-dot { background: var(--go); animation: none; } | |
| #status-pill.error #status-dot { background: var(--err); animation: none; } | |
| @keyframes pulse { 50% { opacity: .35; } } | |
| main { flex: 1; display: flex; min-height: 0; } | |
| /* ---- control deck ---- */ | |
| aside { | |
| width: 340px; flex: none; overflow-y: auto; | |
| background: var(--panel); border-right: 1px solid var(--edge); | |
| padding: 16px; display: flex; flex-direction: column; gap: 14px; | |
| } | |
| .deck-label { | |
| font: 10px var(--mono); letter-spacing: 1.5px; color: var(--faint); | |
| text-transform: uppercase; margin-bottom: 6px; | |
| } | |
| textarea, select, input[type=number] { | |
| width: 100%; background: var(--panel-2); color: var(--text); | |
| border: 1px solid var(--edge); border-radius: 8px; | |
| padding: 10px 12px; font: 13px/1.5 inherit; resize: vertical; | |
| } | |
| textarea:focus, select:focus, input:focus { outline: none; border-color: var(--accent-dim); } | |
| textarea { min-height: 96px; } | |
| .dropzone { | |
| border: 1.5px dashed var(--edge-hi); border-radius: 8px; | |
| min-height: 74px; display: flex; align-items: center; justify-content: center; | |
| color: var(--faint); font-size: 12px; cursor: pointer; position: relative; | |
| overflow: hidden; text-align: center; padding: 6px; transition: border-color .15s; | |
| } | |
| .dropzone:hover, .dropzone.drag { border-color: var(--accent); color: var(--dim); } | |
| .dropzone img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } | |
| .dropzone .clear { | |
| position: absolute; top: 4px; right: 6px; z-index: 2; color: #fff; | |
| background: rgba(0,0,0,.6); border-radius: 4px; padding: 0 6px; font-size: 14px; display: none; | |
| } | |
| .dropzone.filled .clear { display: block; } | |
| .frames-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } | |
| .check { display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: 13px; cursor: pointer; } | |
| .check input { accent-color: var(--accent); } | |
| details { border: 1px solid var(--edge); border-radius: 8px; background: var(--panel-2); } | |
| summary { | |
| padding: 9px 12px; cursor: pointer; font: 11px var(--mono); | |
| letter-spacing: 1px; color: var(--dim); text-transform: uppercase; user-select: none; | |
| } | |
| details .body { padding: 4px 12px 12px; display: flex; flex-direction: column; gap: 12px; } | |
| .slider-row { display: flex; justify-content: space-between; font-size: 12px; color: var(--dim); margin-bottom: 2px; } | |
| .slider-row output { font-family: var(--mono); color: var(--text); } | |
| input[type=range] { width: 100%; accent-color: var(--accent); } | |
| #run { | |
| margin-top: auto; border: none; border-radius: 8px; padding: 13px; | |
| background: var(--accent); color: #111; font: 700 14px inherit; | |
| letter-spacing: .5px; cursor: pointer; transition: filter .15s, opacity .15s; | |
| } | |
| #run:hover:not(:disabled) { filter: brightness(1.1); } | |
| #run:disabled { opacity: .45; cursor: not-allowed; } | |
| /* ---- stage ---- */ | |
| section.stage { flex: 1; display: flex; flex-direction: column; min-width: 0; } | |
| .monitor-wrap { | |
| flex: 1; display: flex; align-items: center; justify-content: center; | |
| padding: 22px; min-height: 0; | |
| background: radial-gradient(ellipse at 50% 40%, #10141a 0%, var(--bg) 75%); | |
| } | |
| .monitor { | |
| position: relative; max-width: 100%; max-height: 100%; | |
| border: 1px solid var(--edge-hi); border-radius: 10px; overflow: hidden; | |
| background: #000; box-shadow: 0 24px 70px rgba(0,0,0,.55); | |
| display: flex; align-items: center; justify-content: center; | |
| } | |
| .monitor video { display: block; max-width: 100%; max-height: calc(100vh - 220px); } | |
| .monitor .placeholder { | |
| position: absolute; inset: 0; display: flex; flex-direction: column; | |
| align-items: center; justify-content: center; gap: 10px; color: var(--faint); | |
| font: 12px var(--mono); letter-spacing: 1px; text-align: center; padding: 20px; | |
| } | |
| .monitor .placeholder .rec { width: 46px; height: 46px; border: 1.5px solid var(--edge-hi); border-radius: 50%; | |
| display: flex; align-items: center; justify-content: center; } | |
| .monitor .placeholder .rec::after { content: ""; width: 14px; height: 14px; border-radius: 50%; background: var(--edge-hi); } | |
| .monitor.hidden-video video { display: none; } | |
| /* ---- transport / report bar ---- */ | |
| .transport { | |
| flex: none; border-top: 1px solid var(--edge); background: var(--panel); | |
| padding: 10px 18px; display: flex; align-items: center; gap: 16px; | |
| font: 12px var(--mono); color: var(--dim); min-height: 44px; | |
| } | |
| #job-state { color: var(--accent); } | |
| #job-state.done { color: var(--go); } | |
| #job-state.failed { color: var(--err); } | |
| #report { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; } | |
| #elapsed { color: var(--faint); flex: none; } | |
| #bar-track { | |
| flex: none; width: 180px; height: 6px; border-radius: 3px; | |
| background: var(--edge); overflow: hidden; position: relative; | |
| } | |
| #bar { height: 100%; width: 0%; border-radius: 3px; background: var(--accent); transition: width .4s linear; } | |
| #bar.indeterminate { width: 40%; animation: slide 1.2s ease-in-out infinite; } | |
| @keyframes slide { 0% { margin-left: -40%; } 100% { margin-left: 100%; } } | |
| #refined-bar { | |
| flex: none; display: none; border-top: 1px solid var(--edge); | |
| background: var(--panel-2); padding: 10px 18px; font-size: 12px; color: var(--dim); | |
| max-height: 110px; overflow-y: auto; | |
| } | |
| #refined-bar b { color: var(--faint); font: 10px var(--mono); letter-spacing: 1px; text-transform: uppercase; display: block; margin-bottom: 4px; } | |
| .examples { display: flex; flex-direction: column; gap: 6px; } | |
| .examples button { | |
| text-align: left; background: var(--panel-2); border: 1px solid var(--edge); | |
| color: var(--dim); border-radius: 6px; padding: 7px 10px; font-size: 12px; cursor: pointer; | |
| } | |
| .examples button:hover { color: var(--text); border-color: var(--edge-hi); } | |
| @media (max-width: 860px) { | |
| body { height: 100dvh; overflow: hidden; } | |
| main { flex-direction: column; overflow-y: auto; -webkit-overflow-scrolling: touch; } | |
| /* Stage first, controls below: the monitor is what a phone user came for. */ | |
| section.stage { order: -1; flex: none; } | |
| .monitor-wrap { padding: 12px; } | |
| .monitor video { max-height: 38vh; } | |
| .monitor .placeholder { position: static; padding: 42px 16px; } | |
| aside { | |
| width: 100%; flex: none; overflow: visible; | |
| border-right: none; border-top: 1px solid var(--edge); | |
| padding: 14px 14px 90px; /* room for the sticky run button */ | |
| } | |
| #run { | |
| position: sticky; bottom: 12px; z-index: 5; | |
| padding: 15px; font-size: 15px; | |
| box-shadow: 0 8px 24px rgba(0,0,0,.5); | |
| } | |
| /* Touch targets + kill iOS auto-zoom on focus (needs >=16px). */ | |
| textarea, select, input[type=number] { font-size: 16px; } | |
| .dropzone { min-height: 92px; } | |
| .check { padding: 6px 0; } | |
| summary { padding: 12px; } | |
| input[type=range] { height: 28px; } | |
| header { padding: 0 12px; gap: 10px; } | |
| .logo span { display: none; } | |
| #status-pill { max-width: 38vw; padding: 4px 9px; } | |
| header .links a { display: none; } | |
| header .links #status-pill { display: flex; } | |
| .transport { flex-wrap: wrap; gap: 8px 12px; padding: 10px 12px; } | |
| #bar-track { flex: 1 1 100%; order: -1; width: auto; height: 5px; } | |
| #report { white-space: normal; flex: 1 1 60%; font-size: 11px; } | |
| #refined-bar { max-height: 80px; } | |
| } | |
| @media (max-width: 400px) { | |
| .frames-row { grid-template-columns: 1fr; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="logo">MiniMax-<b>H3</b> Studio<span>video + synchronized soundtrack · 4-step turbo</span></div> | |
| <div class="links"> | |
| <div id="status-pill"><span id="status-dot"></span><span id="status-text">connecting…</span></div> | |
| <a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank" rel="noopener">model</a> | |
| <a href="https://huggingface.co/larryvrh/MiniMax-H3-Turbo-Lora" target="_blank" rel="noopener">turbo lora</a> | |
| </div> | |
| </header> | |
| <main> | |
| <aside> | |
| <div> | |
| <div class="deck-label">Prompt</div> | |
| <textarea id="prompt" spellcheck="false">A red fox trotting through a snowy pine forest at dawn, snow crunching underfoot</textarea> | |
| </div> | |
| <div> | |
| <div class="deck-label">Turbo LoRA</div> | |
| <select id="lora"></select> | |
| </div> | |
| <label class="check"><input type="checkbox" id="upsample"> Upsample prompt</label> | |
| <div> | |
| <div class="deck-label">Keyframes (optional)</div> | |
| <div class="frames-row"> | |
| <div class="dropzone" id="dz-first">First frame<span class="clear">×</span></div> | |
| <div class="dropzone" id="dz-last">Last frame<span class="clear">×</span></div> | |
| </div> | |
| </div> | |
| <details open> | |
| <summary>Shot settings</summary> | |
| <div class="body"> | |
| <div> | |
| <div class="deck-label">Canvas</div> | |
| <select id="canvas"></select> | |
| </div> | |
| <div> | |
| <div class="slider-row"><span>Duration</span><output id="duration-out">5 s</output></div> | |
| <input type="range" id="duration" min="2" max="14" step="1" value="5"> | |
| </div> | |
| <div> | |
| <div class="slider-row"><span>Steps</span><output id="steps-out">6</output></div> | |
| <input type="range" id="steps" min="2" max="40" step="1" value="6"> | |
| </div> | |
| <div> | |
| <div class="deck-label">Seed</div> | |
| <input type="number" id="seed" value="42" step="1"> | |
| </div> | |
| </div> | |
| </details> | |
| <div> | |
| <div class="deck-label">Examples</div> | |
| <div class="examples" id="examples"></div> | |
| </div> | |
| <button id="run">▶ Generate</button> | |
| </aside> | |
| <section class="stage"> | |
| <div class="monitor-wrap"> | |
| <div class="monitor hidden-video" id="monitor"> | |
| <video id="video" controls playsinline></video> | |
| <div class="placeholder" id="placeholder"> | |
| <div class="rec"></div> | |
| <div>STANDBY — CUT A PROMPT AND ROLL</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="refined-bar"><b>Upsampled prompt</b><span id="refined"></span></div> | |
| <div class="transport"> | |
| <span id="job-state">IDLE</span> | |
| <div id="bar-track"><div id="bar"></div></div> | |
| <span id="report"></span> | |
| <span id="elapsed"></span> | |
| </div> | |
| </section> | |
| </main> | |
| <input type="file" id="file-first" accept="image/*" hidden> | |
| <input type="file" id="file-last" accept="image/*" hidden> | |
| <script type="module"> | |
| // @gradio/client is REQUIRED here (not plain fetch): it forwards the HF iframe auth headers that ZeroGPU | |
| // quota handling depends on — without them every booking lands on the pod IP's shared quota. | |
| import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; | |
| const $ = (id) => document.getElementById(id); | |
| const state = { first: null, last: null, busy: false, timer: null }; | |
| /* ---- config + status ---- */ | |
| fetch("/studio-config").then(r => r.json()).then(cfg => { | |
| const sel = $("canvas"); | |
| for (const label of cfg.canvases) { | |
| const o = document.createElement("option"); | |
| o.value = o.textContent = label; | |
| if (label === cfg.default_canvas) o.selected = true; | |
| sel.appendChild(o); | |
| } | |
| $("duration").min = cfg.min_duration; $("duration").max = cfg.max_duration; | |
| const loraSel = $("lora"); | |
| for (const [value, spec] of Object.entries(cfg.loras || { larry: { label: "larry", steps: 6 } })) { | |
| const o = document.createElement("option"); | |
| o.value = value; | |
| o.textContent = value === "off" ? "off (base model)" : value; | |
| o.title = spec.label; | |
| if (value === cfg.default_lora) o.selected = true; | |
| loraSel.appendChild(o); | |
| } | |
| // Each LoRA has a design point: suggest it on switch (the slider stays free). | |
| loraSel.addEventListener("change", () => { | |
| const spec = (cfg.loras || {})[loraSel.value]; | |
| if (spec && spec.steps) { $("steps").value = spec.steps; $("steps-out").textContent = spec.steps; } | |
| }); | |
| }); | |
| async function pollStatus() { | |
| try { | |
| const s = await (await fetch("/status")).json(); | |
| const pill = $("status-pill"); | |
| $("status-text").textContent = s.status.replace(/[*`]/g, ""); | |
| pill.classList.toggle("ready", s.ready); | |
| pill.classList.toggle("error", !s.ready && /failed/i.test(s.status)); | |
| if (s.ready) return; | |
| } catch (e) { /* still booting */ } | |
| setTimeout(pollStatus, 5000); | |
| } | |
| pollStatus(); | |
| /* ---- sliders ---- */ | |
| const bind = (id, fmt) => $(id).addEventListener("input", e => $(id + "-out").textContent = fmt(e.target.value)); | |
| bind("duration", v => v + " s"); | |
| bind("steps", v => v); | |
| /* ---- dropzones ---- */ | |
| function wireDropzone(dzId, inputId, key) { | |
| const dz = $(dzId), input = $(inputId); | |
| const set = (file) => { | |
| if (!file) return; | |
| state[key] = file; | |
| const img = document.createElement("img"); | |
| img.src = URL.createObjectURL(file); | |
| dz.appendChild(img); | |
| dz.classList.add("filled"); | |
| }; | |
| dz.addEventListener("click", (e) => { | |
| if (e.target.classList.contains("clear")) { | |
| state[key] = null; dz.classList.remove("filled"); | |
| dz.querySelector("img")?.remove(); input.value = ""; | |
| } else input.click(); | |
| }); | |
| input.addEventListener("change", () => set(input.files[0])); | |
| dz.addEventListener("dragover", e => { e.preventDefault(); dz.classList.add("drag"); }); | |
| dz.addEventListener("dragleave", () => dz.classList.remove("drag")); | |
| dz.addEventListener("drop", e => { e.preventDefault(); dz.classList.remove("drag"); set(e.dataTransfer.files[0]); }); | |
| } | |
| wireDropzone("dz-first", "file-first", "first"); | |
| wireDropzone("dz-last", "file-last", "last"); | |
| /* ---- examples ---- */ | |
| const EXAMPLES = [ | |
| ["A red fox trotting through a snowy pine forest at dawn, snow crunching underfoot", "1344x768 · 16:9 full"], | |
| ["A busy night market, neon signs reflecting in puddles, sizzling street food", "768x1344 · 9:16 full"], | |
| ["A cellist playing a slow melody in an empty concert hall", "768x768 · 1:1 full"], | |
| ["Waves crashing against basalt cliffs at golden hour, gulls crying overhead", "1152x640 · 16:9"], | |
| ]; | |
| for (const [p, c] of EXAMPLES) { | |
| const b = document.createElement("button"); | |
| b.textContent = p.length > 60 ? p.slice(0, 60) + "…" : p; | |
| b.title = p; | |
| b.onclick = () => { $("prompt").value = p; $("canvas").value = c; }; | |
| $("examples").appendChild(b); | |
| } | |
| /* ---- generate ---- */ | |
| const client = await Client.connect(window.location.origin, { events: ["data", "status"] }); | |
| function setJob(label, cls) { | |
| const el = $("job-state"); | |
| el.textContent = label; el.className = cls || ""; | |
| } | |
| function setBar(pct) { | |
| const bar = $("bar"); | |
| if (pct === null) { bar.className = "indeterminate"; return; } | |
| bar.className = ""; | |
| bar.style.width = Math.max(0, Math.min(100, pct)) + "%"; | |
| } | |
| $("run").addEventListener("click", async () => { | |
| if (state.busy) return; | |
| const prompt = $("prompt").value.trim(); | |
| if (!prompt) { setJob("FAILED", "failed"); $("report").textContent = "a prompt is required"; return; } | |
| state.busy = true; | |
| $("run").disabled = true; | |
| $("refined-bar").style.display = "none"; | |
| $("report").textContent = ""; | |
| setJob("ROLLING", ""); | |
| const t0 = Date.now(); | |
| state.timer = setInterval(() => $("elapsed").textContent = ((Date.now() - t0) / 1000).toFixed(0) + "s", 500); | |
| try { | |
| const submission = client.submit("/generate", { | |
| prompt, | |
| image_path: state.first ? handle_file(state.first) : null, | |
| last_image_path: state.last ? handle_file(state.last) : null, | |
| canvas: $("canvas").value, | |
| duration: Number($("duration").value), | |
| steps: Number($("steps").value), | |
| seed: Number($("seed").value), | |
| upsample: $("upsample").checked, | |
| lora: $("lora").value, | |
| }); | |
| let data = null; | |
| for await (const msg of submission) { | |
| if (msg.type === "status") { | |
| if (msg.stage === "pending") { | |
| setJob(msg.position != null ? `QUEUED #${msg.position + 1}` : "QUEUED"); | |
| setBar(null); | |
| } else if (msg.stage === "generating") { | |
| setJob("ROLLING"); | |
| // ETA comes from the queue's booking estimate; blend it with our own elapsed timer. | |
| if (msg.eta != null && msg.eta > 0) { | |
| const elapsed = (Date.now() - t0) / 1000; | |
| setBar(100 * elapsed / (elapsed + msg.eta)); | |
| } else setBar(null); | |
| } else if (msg.stage === "error") { | |
| throw new Error(msg.message || "generation failed"); | |
| } | |
| } else if (msg.type === "data") { | |
| data = msg.data; | |
| } | |
| } | |
| if (!data) throw new Error("the backend returned no data"); | |
| // Server mode returns the tuple as ONE Api output: unwrap [[video, report, refined]] too. | |
| if (data.length === 1 && Array.isArray(data[0])) data = data[0]; | |
| const [video, report, refined] = data; | |
| // FileData may carry only `path`; resolve those through Gradio's file route. | |
| const videoUrl = (video && (video.url || (video.path && `/gradio_api/file=${video.path}`))) | |
| || (typeof video === "string" && `/gradio_api/file=${video}`); | |
| if (!videoUrl) throw new Error("the backend returned no video reference"); | |
| $("video").src = videoUrl; | |
| $("monitor").classList.remove("hidden-video"); | |
| $("placeholder").style.display = "none"; | |
| $("report").textContent = report; | |
| if (refined) { | |
| $("refined").textContent = refined; | |
| $("refined-bar").style.display = "block"; | |
| } | |
| setJob("DONE", "done"); | |
| setBar(100); | |
| } catch (e) { | |
| setJob("FAILED", "failed"); | |
| setBar(0); | |
| $("report").textContent = (e && e.message) ? e.message.slice(0, 300) : String(e); | |
| } finally { | |
| clearInterval(state.timer); | |
| state.busy = false; | |
| $("run").disabled = false; | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |