Spaces:
Running on Zero
Running on Zero
Update index.html
Browse files- index.html +298 -73
index.html
CHANGED
|
@@ -26,7 +26,6 @@
|
|
| 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;
|
|
@@ -49,9 +48,7 @@
|
|
| 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;
|
|
@@ -69,7 +66,6 @@
|
|
| 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;
|
|
@@ -84,10 +80,8 @@
|
|
| 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);
|
|
@@ -97,7 +91,6 @@
|
|
| 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;
|
|
@@ -105,7 +98,6 @@
|
|
| 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 {
|
|
@@ -129,7 +121,6 @@
|
|
| 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);
|
|
@@ -141,7 +132,6 @@
|
|
| 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 |
#bar-track {
|
| 146 |
flex: none; width: 180px; height: 6px; border-radius: 3px;
|
| 147 |
background: var(--edge); overflow: hidden; position: relative;
|
|
@@ -149,31 +139,26 @@
|
|
| 149 |
#bar { height: 100%; width: 0%; border-radius: 3px; background: var(--accent); transition: width .4s linear; }
|
| 150 |
#bar.indeterminate { width: 40%; animation: slide 1.2s ease-in-out infinite; }
|
| 151 |
@keyframes slide { 0% { margin-left: -40%; } 100% { margin-left: 100%; } }
|
| 152 |
-
|
| 153 |
#refined-bar {
|
| 154 |
flex: none; display: none; border-top: 1px solid var(--edge);
|
| 155 |
background: var(--panel-2); padding: 10px 18px; font-size: 12px; color: var(--dim);
|
| 156 |
max-height: 110px; overflow-y: auto;
|
| 157 |
}
|
| 158 |
#refined-bar b { color: var(--faint); font: 10px var(--mono); letter-spacing: 1px; text-transform: uppercase; display: block; margin-bottom: 4px; }
|
| 159 |
-
|
| 160 |
.examples { display: flex; flex-direction: column; gap: 6px; }
|
| 161 |
.examples button {
|
| 162 |
text-align: left; background: var(--panel-2); border: 1px solid var(--edge);
|
| 163 |
color: var(--dim); border-radius: 6px; padding: 7px 10px; font-size: 12px; cursor: pointer;
|
| 164 |
}
|
| 165 |
.examples button:hover { color: var(--text); border-color: var(--edge-hi); }
|
| 166 |
-
|
| 167 |
@media (max-width: 860px) {
|
| 168 |
body { height: 100dvh; overflow: hidden; }
|
| 169 |
main { flex-direction: column; overflow-y: auto; -webkit-overflow-scrolling: touch; }
|
| 170 |
-
|
| 171 |
/* Stage first, controls below: the monitor is what a phone user came for. */
|
| 172 |
section.stage { order: -1; flex: none; }
|
| 173 |
.monitor-wrap { padding: 12px; }
|
| 174 |
.monitor video { max-height: 38vh; }
|
| 175 |
.monitor .placeholder { position: static; padding: 42px 16px; }
|
| 176 |
-
|
| 177 |
aside {
|
| 178 |
width: 100%; flex: none; overflow: visible;
|
| 179 |
border-right: none; border-top: 1px solid var(--edge);
|
|
@@ -184,20 +169,17 @@
|
|
| 184 |
padding: 15px; font-size: 15px;
|
| 185 |
box-shadow: 0 8px 24px rgba(0,0,0,.5);
|
| 186 |
}
|
| 187 |
-
|
| 188 |
/* Touch targets + kill iOS auto-zoom on focus (needs >=16px). */
|
| 189 |
textarea, select, input[type=number] { font-size: 16px; }
|
| 190 |
.dropzone { min-height: 92px; }
|
| 191 |
.check { padding: 6px 0; }
|
| 192 |
summary { padding: 12px; }
|
| 193 |
input[type=range] { height: 28px; }
|
| 194 |
-
|
| 195 |
header { padding: 0 12px; gap: 10px; }
|
| 196 |
.logo span { display: none; }
|
| 197 |
#status-pill { max-width: 38vw; padding: 4px 9px; }
|
| 198 |
header .links a { display: none; }
|
| 199 |
header .links #status-pill { display: flex; }
|
| 200 |
-
|
| 201 |
.transport { flex-wrap: wrap; gap: 8px 12px; padding: 10px 12px; }
|
| 202 |
#bar-track { flex: 1 1 100%; order: -1; width: auto; height: 5px; }
|
| 203 |
#report { white-space: normal; flex: 1 1 60%; font-size: 11px; }
|
|
@@ -206,6 +188,11 @@
|
|
| 206 |
@media (max-width: 400px) {
|
| 207 |
.frames-row { grid-template-columns: 1fr; }
|
| 208 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
</style>
|
| 210 |
</head>
|
| 211 |
<body>
|
|
@@ -229,7 +216,18 @@
|
|
| 229 |
<div class="deck-label">Turbo LoRA</div>
|
| 230 |
<select id="lora"></select>
|
| 231 |
</div>
|
| 232 |
-
<label class="check"><input type="checkbox" id="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
<div>
|
| 235 |
<div class="deck-label">Keyframes (optional)</div>
|
|
@@ -237,6 +235,7 @@
|
|
| 237 |
<div class="dropzone" id="dz-first">First frame<span class="clear">×</span></div>
|
| 238 |
<div class="dropzone" id="dz-last">Last frame<span class="clear">×</span></div>
|
| 239 |
</div>
|
|
|
|
| 240 |
</div>
|
| 241 |
|
| 242 |
<details open>
|
|
@@ -247,25 +246,20 @@
|
|
| 247 |
<select id="canvas"></select>
|
| 248 |
</div>
|
| 249 |
<div>
|
| 250 |
-
<div class="slider-row"><span>Duration</span><output id="duration-out">
|
| 251 |
-
<input type="range" id="duration" min="2" max="14" step="1" value="
|
| 252 |
</div>
|
| 253 |
<div>
|
| 254 |
-
<div class="slider-row"><span>Steps</span><output id="steps-out">
|
| 255 |
-
<input type="range" id="steps" min="2" max="40" step="1" value="
|
| 256 |
</div>
|
| 257 |
<div>
|
| 258 |
-
<div class="deck-label">Seed</div>
|
| 259 |
-
<input type="number" id="seed" value="42" step="1">
|
| 260 |
</div>
|
| 261 |
</div>
|
| 262 |
</details>
|
| 263 |
|
| 264 |
-
<div>
|
| 265 |
-
<div class="deck-label">Examples</div>
|
| 266 |
-
<div class="examples" id="examples"></div>
|
| 267 |
-
</div>
|
| 268 |
-
|
| 269 |
<button id="run">▶ Generate</button>
|
| 270 |
</aside>
|
| 271 |
|
|
@@ -288,6 +282,16 @@
|
|
| 288 |
</div>
|
| 289 |
</section>
|
| 290 |
</main>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
|
| 292 |
<input type="file" id="file-first" accept="image/*" hidden>
|
| 293 |
<input type="file" id="file-last" accept="image/*" hidden>
|
|
@@ -296,9 +300,250 @@
|
|
| 296 |
// @gradio/client is REQUIRED here (not plain fetch): it forwards the HF iframe auth headers that ZeroGPU
|
| 297 |
// quota handling depends on — without them every booking lands on the pod IP's shared quota.
|
| 298 |
import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
|
| 299 |
-
|
| 300 |
const $ = (id) => document.getElementById(id);
|
| 301 |
const state = { first: null, last: null, busy: false, timer: null };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
|
| 303 |
/* ---- config + status ---- */
|
| 304 |
fetch("/studio-config").then(r => r.json()).then(cfg => {
|
|
@@ -306,11 +551,10 @@ fetch("/studio-config").then(r => r.json()).then(cfg => {
|
|
| 306 |
for (const label of cfg.canvases) {
|
| 307 |
const o = document.createElement("option");
|
| 308 |
o.value = o.textContent = label;
|
| 309 |
-
if (label ===
|
| 310 |
sel.appendChild(o);
|
| 311 |
}
|
| 312 |
$("duration").min = cfg.min_duration; $("duration").max = cfg.max_duration;
|
| 313 |
-
|
| 314 |
const loraSel = $("lora");
|
| 315 |
for (const [value, spec] of Object.entries(cfg.loras || { larry: { label: "larry", steps: 6 } })) {
|
| 316 |
const o = document.createElement("option");
|
|
@@ -326,7 +570,6 @@ fetch("/studio-config").then(r => r.json()).then(cfg => {
|
|
| 326 |
if (spec && spec.steps) { $("steps").value = spec.steps; $("steps-out").textContent = spec.steps; }
|
| 327 |
});
|
| 328 |
});
|
| 329 |
-
|
| 330 |
async function pollStatus() {
|
| 331 |
try {
|
| 332 |
const s = await (await fetch("/status")).json();
|
|
@@ -339,27 +582,22 @@ async function pollStatus() {
|
|
| 339 |
setTimeout(pollStatus, 5000);
|
| 340 |
}
|
| 341 |
pollStatus();
|
| 342 |
-
|
| 343 |
/* ---- sliders ---- */
|
| 344 |
const bind = (id, fmt) => $(id).addEventListener("input", e => $(id + "-out").textContent = fmt(e.target.value));
|
| 345 |
bind("duration", v => v + " s");
|
| 346 |
bind("steps", v => v);
|
| 347 |
-
|
| 348 |
/* ---- dropzones ---- */
|
| 349 |
function wireDropzone(dzId, inputId, key) {
|
| 350 |
const dz = $(dzId), input = $(inputId);
|
| 351 |
const set = (file) => {
|
| 352 |
-
if (!file) return;
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
img.src = URL.createObjectURL(file);
|
| 356 |
-
dz.appendChild(img);
|
| 357 |
-
dz.classList.add("filled");
|
| 358 |
};
|
| 359 |
dz.addEventListener("click", (e) => {
|
|
|
|
| 360 |
if (e.target.classList.contains("clear")) {
|
| 361 |
-
|
| 362 |
-
dz.querySelector("img")?.remove(); input.value = "";
|
| 363 |
} else input.click();
|
| 364 |
});
|
| 365 |
input.addEventListener("change", () => set(input.files[0]));
|
|
@@ -369,51 +607,41 @@ function wireDropzone(dzId, inputId, key) {
|
|
| 369 |
}
|
| 370 |
wireDropzone("dz-first", "file-first", "first");
|
| 371 |
wireDropzone("dz-last", "file-last", "last");
|
| 372 |
-
|
| 373 |
-
/* ---- examples ---- */
|
| 374 |
-
const EXAMPLES = [
|
| 375 |
-
["A red fox trotting through a snowy pine forest at dawn, snow crunching underfoot", "1344x768 · 16:9 full"],
|
| 376 |
-
["A busy night market, neon signs reflecting in puddles, sizzling street food", "768x1344 · 9:16 full"],
|
| 377 |
-
["A cellist playing a slow melody in an empty concert hall", "768x768 · 1:1 full"],
|
| 378 |
-
["Waves crashing against basalt cliffs at golden hour, gulls crying overhead", "1152x640 · 16:9"],
|
| 379 |
-
];
|
| 380 |
-
for (const [p, c] of EXAMPLES) {
|
| 381 |
-
const b = document.createElement("button");
|
| 382 |
-
b.textContent = p.length > 60 ? p.slice(0, 60) + "…" : p;
|
| 383 |
-
b.title = p;
|
| 384 |
-
b.onclick = () => { $("prompt").value = p; $("canvas").value = c; };
|
| 385 |
-
$("examples").appendChild(b);
|
| 386 |
-
}
|
| 387 |
-
|
| 388 |
/* ---- generate ---- */
|
| 389 |
const client = await Client.connect(window.location.origin, { events: ["data", "status"] });
|
| 390 |
-
|
| 391 |
function setJob(label, cls) {
|
| 392 |
const el = $("job-state");
|
| 393 |
el.textContent = label; el.className = cls || "";
|
| 394 |
}
|
| 395 |
-
|
| 396 |
function setBar(pct) {
|
| 397 |
const bar = $("bar");
|
| 398 |
if (pct === null) { bar.className = "indeterminate"; return; }
|
| 399 |
bar.className = "";
|
| 400 |
bar.style.width = Math.max(0, Math.min(100, pct)) + "%";
|
| 401 |
}
|
| 402 |
-
|
| 403 |
$("run").addEventListener("click", async () => {
|
| 404 |
if (state.busy) return;
|
| 405 |
const prompt = $("prompt").value.trim();
|
| 406 |
if (!prompt) { setJob("FAILED", "failed"); $("report").textContent = "a prompt is required"; return; }
|
| 407 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
state.busy = true;
|
| 409 |
-
|
| 410 |
$("refined-bar").style.display = "none";
|
| 411 |
$("report").textContent = "";
|
| 412 |
setJob("ROLLING", "");
|
| 413 |
const t0 = Date.now();
|
| 414 |
state.timer = setInterval(() => $("elapsed").textContent = ((Date.now() - t0) / 1000).toFixed(0) + "s", 500);
|
| 415 |
-
|
| 416 |
try {
|
|
|
|
| 417 |
const submission = client.submit("/generate", {
|
| 418 |
prompt,
|
| 419 |
image_path: state.first ? handle_file(state.first) : null,
|
|
@@ -422,7 +650,7 @@ $("run").addEventListener("click", async () => {
|
|
| 422 |
duration: Number($("duration").value),
|
| 423 |
steps: Number($("steps").value),
|
| 424 |
seed: Number($("seed").value),
|
| 425 |
-
upsample:
|
| 426 |
lora: $("lora").value,
|
| 427 |
});
|
| 428 |
let data = null;
|
|
@@ -453,10 +681,7 @@ $("run").addEventListener("click", async () => {
|
|
| 453 |
const videoUrl = (video && (video.url || (video.path && `/gradio_api/file=${video.path}`)))
|
| 454 |
|| (typeof video === "string" && `/gradio_api/file=${video}`);
|
| 455 |
if (!videoUrl) throw new Error("the backend returned no video reference");
|
| 456 |
-
|
| 457 |
-
$("monitor").classList.remove("hidden-video");
|
| 458 |
-
$("placeholder").style.display = "none";
|
| 459 |
-
$("report").textContent = report;
|
| 460 |
if (refined) {
|
| 461 |
$("refined").textContent = refined;
|
| 462 |
$("refined-bar").style.display = "block";
|
|
@@ -464,13 +689,13 @@ $("run").addEventListener("click", async () => {
|
|
| 464 |
setJob("DONE", "done");
|
| 465 |
setBar(100);
|
| 466 |
} catch (e) {
|
| 467 |
-
setJob("FAILED", "failed");
|
| 468 |
setBar(0);
|
| 469 |
$("report").textContent = (e && e.message) ? e.message.slice(0, 300) : String(e);
|
| 470 |
} finally {
|
| 471 |
clearInterval(state.timer);
|
| 472 |
state.busy = false;
|
| 473 |
-
|
| 474 |
}
|
| 475 |
});
|
| 476 |
</script>
|
|
|
|
| 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 |
/* ---- top bar ---- */
|
| 30 |
header {
|
| 31 |
display: flex; align-items: center; gap: 14px;
|
|
|
|
| 48 |
#status-pill.ready #status-dot { background: var(--go); animation: none; }
|
| 49 |
#status-pill.error #status-dot { background: var(--err); animation: none; }
|
| 50 |
@keyframes pulse { 50% { opacity: .35; } }
|
|
|
|
| 51 |
main { flex: 1; display: flex; min-height: 0; }
|
|
|
|
| 52 |
/* ---- control deck ---- */
|
| 53 |
aside {
|
| 54 |
width: 340px; flex: none; overflow-y: auto;
|
|
|
|
| 66 |
}
|
| 67 |
textarea:focus, select:focus, input:focus { outline: none; border-color: var(--accent-dim); }
|
| 68 |
textarea { min-height: 96px; }
|
|
|
|
| 69 |
.dropzone {
|
| 70 |
border: 1.5px dashed var(--edge-hi); border-radius: 8px;
|
| 71 |
min-height: 74px; display: flex; align-items: center; justify-content: center;
|
|
|
|
| 80 |
}
|
| 81 |
.dropzone.filled .clear { display: block; }
|
| 82 |
.frames-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
|
|
|
| 83 |
.check { display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: 13px; cursor: pointer; }
|
| 84 |
.check input { accent-color: var(--accent); }
|
|
|
|
| 85 |
details { border: 1px solid var(--edge); border-radius: 8px; background: var(--panel-2); }
|
| 86 |
summary {
|
| 87 |
padding: 9px 12px; cursor: pointer; font: 11px var(--mono);
|
|
|
|
| 91 |
.slider-row { display: flex; justify-content: space-between; font-size: 12px; color: var(--dim); margin-bottom: 2px; }
|
| 92 |
.slider-row output { font-family: var(--mono); color: var(--text); }
|
| 93 |
input[type=range] { width: 100%; accent-color: var(--accent); }
|
|
|
|
| 94 |
#run {
|
| 95 |
margin-top: auto; border: none; border-radius: 8px; padding: 13px;
|
| 96 |
background: var(--accent); color: #111; font: 700 14px inherit;
|
|
|
|
| 98 |
}
|
| 99 |
#run:hover:not(:disabled) { filter: brightness(1.1); }
|
| 100 |
#run:disabled { opacity: .45; cursor: not-allowed; }
|
|
|
|
| 101 |
/* ---- stage ---- */
|
| 102 |
section.stage { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
| 103 |
.monitor-wrap {
|
|
|
|
| 121 |
display: flex; align-items: center; justify-content: center; }
|
| 122 |
.monitor .placeholder .rec::after { content: ""; width: 14px; height: 14px; border-radius: 50%; background: var(--edge-hi); }
|
| 123 |
.monitor.hidden-video video { display: none; }
|
|
|
|
| 124 |
/* ---- transport / report bar ---- */
|
| 125 |
.transport {
|
| 126 |
flex: none; border-top: 1px solid var(--edge); background: var(--panel);
|
|
|
|
| 132 |
#job-state.failed { color: var(--err); }
|
| 133 |
#report { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
|
| 134 |
#elapsed { color: var(--faint); flex: none; }
|
|
|
|
| 135 |
#bar-track {
|
| 136 |
flex: none; width: 180px; height: 6px; border-radius: 3px;
|
| 137 |
background: var(--edge); overflow: hidden; position: relative;
|
|
|
|
| 139 |
#bar { height: 100%; width: 0%; border-radius: 3px; background: var(--accent); transition: width .4s linear; }
|
| 140 |
#bar.indeterminate { width: 40%; animation: slide 1.2s ease-in-out infinite; }
|
| 141 |
@keyframes slide { 0% { margin-left: -40%; } 100% { margin-left: 100%; } }
|
|
|
|
| 142 |
#refined-bar {
|
| 143 |
flex: none; display: none; border-top: 1px solid var(--edge);
|
| 144 |
background: var(--panel-2); padding: 10px 18px; font-size: 12px; color: var(--dim);
|
| 145 |
max-height: 110px; overflow-y: auto;
|
| 146 |
}
|
| 147 |
#refined-bar b { color: var(--faint); font: 10px var(--mono); letter-spacing: 1px; text-transform: uppercase; display: block; margin-bottom: 4px; }
|
|
|
|
| 148 |
.examples { display: flex; flex-direction: column; gap: 6px; }
|
| 149 |
.examples button {
|
| 150 |
text-align: left; background: var(--panel-2); border: 1px solid var(--edge);
|
| 151 |
color: var(--dim); border-radius: 6px; padding: 7px 10px; font-size: 12px; cursor: pointer;
|
| 152 |
}
|
| 153 |
.examples button:hover { color: var(--text); border-color: var(--edge-hi); }
|
|
|
|
| 154 |
@media (max-width: 860px) {
|
| 155 |
body { height: 100dvh; overflow: hidden; }
|
| 156 |
main { flex-direction: column; overflow-y: auto; -webkit-overflow-scrolling: touch; }
|
|
|
|
| 157 |
/* Stage first, controls below: the monitor is what a phone user came for. */
|
| 158 |
section.stage { order: -1; flex: none; }
|
| 159 |
.monitor-wrap { padding: 12px; }
|
| 160 |
.monitor video { max-height: 38vh; }
|
| 161 |
.monitor .placeholder { position: static; padding: 42px 16px; }
|
|
|
|
| 162 |
aside {
|
| 163 |
width: 100%; flex: none; overflow: visible;
|
| 164 |
border-right: none; border-top: 1px solid var(--edge);
|
|
|
|
| 169 |
padding: 15px; font-size: 15px;
|
| 170 |
box-shadow: 0 8px 24px rgba(0,0,0,.5);
|
| 171 |
}
|
|
|
|
| 172 |
/* Touch targets + kill iOS auto-zoom on focus (needs >=16px). */
|
| 173 |
textarea, select, input[type=number] { font-size: 16px; }
|
| 174 |
.dropzone { min-height: 92px; }
|
| 175 |
.check { padding: 6px 0; }
|
| 176 |
summary { padding: 12px; }
|
| 177 |
input[type=range] { height: 28px; }
|
|
|
|
| 178 |
header { padding: 0 12px; gap: 10px; }
|
| 179 |
.logo span { display: none; }
|
| 180 |
#status-pill { max-width: 38vw; padding: 4px 9px; }
|
| 181 |
header .links a { display: none; }
|
| 182 |
header .links #status-pill { display: flex; }
|
|
|
|
| 183 |
.transport { flex-wrap: wrap; gap: 8px 12px; padding: 10px 12px; }
|
| 184 |
#bar-track { flex: 1 1 100%; order: -1; width: auto; height: 5px; }
|
| 185 |
#report { white-space: normal; flex: 1 1 60%; font-size: 11px; }
|
|
|
|
| 188 |
@media (max-width: 400px) {
|
| 189 |
.frames-row { grid-template-columns: 1fr; }
|
| 190 |
}
|
| 191 |
+
#put-last-frame { margin-top:8px; padding:8px 12px; border:1px solid var(--edge-hi); border-radius:6px; background:var(--panel-2); color:var(--text); cursor:pointer; }
|
| 192 |
+
button:disabled { cursor:not-allowed !important; opacity:.45; }
|
| 193 |
+
[hidden] { display:none !important; }
|
| 194 |
+
.monitor video { max-height:calc(58vh - 140px); }
|
| 195 |
+
@media (max-width:860px) { .monitor video { max-height:32vh; } }
|
| 196 |
</style>
|
| 197 |
</head>
|
| 198 |
<body>
|
|
|
|
| 216 |
<div class="deck-label">Turbo LoRA</div>
|
| 217 |
<select id="lora"></select>
|
| 218 |
</div>
|
| 219 |
+
<label class="check"><input type="checkbox" id="continue-scene" checked> Continue scene from last frame</label>
|
| 220 |
+
<div style="font-size:12px;color:var(--dim)">
|
| 221 |
+
When enabled, the final frame becomes the next First frame.
|
| 222 |
+
Generate continues and automatically stitches when enabled. Keep the same canvas.
|
| 223 |
+
The bottom button always generates a continuation and stitches it.
|
| 224 |
+
Stored in this tab's memory only; reloading closes the chain.
|
| 225 |
+
The next generation uploads the First frame to the model.
|
| 226 |
+
</div>
|
| 227 |
+
<div style="display:flex;flex-wrap:wrap;gap:10px;font-size:12px">
|
| 228 |
+
<a id="download-clip" download="h3-latest-clip.mp4" hidden style="color:var(--accent)">Download latest clip</a>
|
| 229 |
+
<button id="retry-local" type="button" hidden>Retry local processing (no generation)</button>
|
| 230 |
+
</div>
|
| 231 |
|
| 232 |
<div>
|
| 233 |
<div class="deck-label">Keyframes (optional)</div>
|
|
|
|
| 235 |
<div class="dropzone" id="dz-first">First frame<span class="clear">×</span></div>
|
| 236 |
<div class="dropzone" id="dz-last">Last frame<span class="clear">×</span></div>
|
| 237 |
</div>
|
| 238 |
+
<button id="put-last-frame" type="button" disabled>Put last frame for generation</button>
|
| 239 |
</div>
|
| 240 |
|
| 241 |
<details open>
|
|
|
|
| 246 |
<select id="canvas"></select>
|
| 247 |
</div>
|
| 248 |
<div>
|
| 249 |
+
<div class="slider-row"><span>Duration</span><output id="duration-out">14 s</output></div>
|
| 250 |
+
<input type="range" id="duration" min="2" max="14" step="1" value="14">
|
| 251 |
</div>
|
| 252 |
<div>
|
| 253 |
+
<div class="slider-row"><span>Steps</span><output id="steps-out">4</output></div>
|
| 254 |
+
<input type="range" id="steps" min="2" max="40" step="1" value="4">
|
| 255 |
</div>
|
| 256 |
<div>
|
| 257 |
+
<div class="deck-label">Seed (randomized on every generation)</div>
|
| 258 |
+
<input type="number" id="seed" value="42" step="1" readonly title="A new random seed is chosen for every generation">
|
| 259 |
</div>
|
| 260 |
</div>
|
| 261 |
</details>
|
| 262 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
<button id="run">▶ Generate</button>
|
| 264 |
</aside>
|
| 265 |
|
|
|
|
| 282 |
</div>
|
| 283 |
</section>
|
| 284 |
</main>
|
| 285 |
+
<section id="stitched-panel" aria-label="Stitched video" style="flex:none;border-top:1px solid var(--edge);background:var(--panel);padding:10px 18px;max-height:42vh;overflow:auto">
|
| 286 |
+
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:8px">
|
| 287 |
+
<b>Stitched video</b>
|
| 288 |
+
<button id="stitch-last" type="button" disabled style="padding:8px 12px;border:1px solid var(--edge-hi);border-radius:6px;background:var(--panel-2);color:var(--text);cursor:pointer">Stitch to last generated video</button>
|
| 289 |
+
<span id="chain-info" style="font-size:12px;color:var(--dim)" aria-live="polite">Generate a clip to enable continuation.</span>
|
| 290 |
+
<a id="download-video" hidden style="color:var(--accent);font-size:12px">Download stitched video</a>
|
| 291 |
+
</div>
|
| 292 |
+
<video id="stitched-video" controls playsinline hidden style="max-width:100%;max-height:29vh;margin:auto"></video>
|
| 293 |
+
<p style="font-size:11px;color:var(--dim)">This button uses the latest clip's final frame and the current prompt to generate a new clip, then automatically appends it. Refreshing discards local videos.</p>
|
| 294 |
+
</section>
|
| 295 |
|
| 296 |
<input type="file" id="file-first" accept="image/*" hidden>
|
| 297 |
<input type="file" id="file-last" accept="image/*" hidden>
|
|
|
|
| 300 |
// @gradio/client is REQUIRED here (not plain fetch): it forwards the HF iframe auth headers that ZeroGPU
|
| 301 |
// quota handling depends on — without them every booking lands on the pod IP's shared quota.
|
| 302 |
import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
|
|
|
|
| 303 |
const $ = (id) => document.getElementById(id);
|
| 304 |
const state = { first: null, last: null, busy: false, timer: null };
|
| 305 |
+
/* This code is inserted into index.html by build.py. No backend changes. */
|
| 306 |
+
const LOCAL_LIMIT = 512 * 1024 * 1024;
|
| 307 |
+
Object.assign(state, { chain: null, chainCount: 0, firstURL: null, lastURL: null,
|
| 308 |
+
videoURL: null, clipURL: null, retry: null });
|
| 309 |
+
|
| 310 |
+
function setFrame(key, file, automatic = false) {
|
| 311 |
+
if (key === "first") state.firstAuto = automatic;
|
| 312 |
+
const dz = $(key === "first" ? "dz-first" : "dz-last");
|
| 313 |
+
const urlKey = key + "URL";
|
| 314 |
+
if (state[urlKey]) URL.revokeObjectURL(state[urlKey]);
|
| 315 |
+
dz.querySelectorAll("img").forEach(img => img.remove());
|
| 316 |
+
state[key] = file || null;
|
| 317 |
+
dz.classList.toggle("filled", !!file);
|
| 318 |
+
if (file) {
|
| 319 |
+
state[urlKey] = URL.createObjectURL(file);
|
| 320 |
+
const img = document.createElement("img");
|
| 321 |
+
img.src = state[urlKey]; img.alt = key + " frame";
|
| 322 |
+
dz.appendChild(img);
|
| 323 |
+
} else state[urlKey] = null;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
function showLocalVideo(blob) {
|
| 327 |
+
const previousURL = state.videoURL;
|
| 328 |
+
state.videoURL = URL.createObjectURL(blob);
|
| 329 |
+
$("video").src = state.videoURL;
|
| 330 |
+
$("monitor").classList.remove("hidden-video");
|
| 331 |
+
$("placeholder").style.display = "none";
|
| 332 |
+
$("download-clip").href = state.videoURL;
|
| 333 |
+
$("download-clip").hidden = false;
|
| 334 |
+
if (previousURL) URL.revokeObjectURL(previousURL);
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
// A classic worker hosts the single-threaded WASM core directly. It needs no
|
| 338 |
+
// SharedArrayBuffer / COOP / COEP headers and does not block the UI thread.
|
| 339 |
+
function localVideoWorker() {
|
| 340 |
+
self.onmessage = async ({ data }) => {
|
| 341 |
+
const logs = [];
|
| 342 |
+
let coreURL, wasmURL;
|
| 343 |
+
try {
|
| 344 |
+
const base = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd";
|
| 345 |
+
const download = async (url, type) => {
|
| 346 |
+
const r = await fetch(url, { cache: "force-cache" });
|
| 347 |
+
if (!r.ok) throw new Error(`Cannot load FFmpeg (${r.status}).`);
|
| 348 |
+
return URL.createObjectURL(new Blob([await r.arrayBuffer()], { type }));
|
| 349 |
+
};
|
| 350 |
+
self.postMessage({ status: "Loading browser video tools…" });
|
| 351 |
+
[coreURL, wasmURL] = await Promise.all([
|
| 352 |
+
download(base + "/ffmpeg-core.js", "text/javascript"),
|
| 353 |
+
download(base + "/ffmpeg-core.wasm", "application/wasm"),
|
| 354 |
+
]);
|
| 355 |
+
importScripts(coreURL);
|
| 356 |
+
const ff = await self.createFFmpegCore({
|
| 357 |
+
mainScriptUrlOrBlob: coreURL + "#" + btoa(JSON.stringify({ wasmURL })),
|
| 358 |
+
});
|
| 359 |
+
ff.setLogger(({ message }) => { logs.push(message); if (logs.length > 30) logs.shift(); });
|
| 360 |
+
const run = args => {
|
| 361 |
+
ff.setTimeout(240000);
|
| 362 |
+
ff.exec(...args);
|
| 363 |
+
const code = ff.ret;
|
| 364 |
+
ff.reset();
|
| 365 |
+
if (code !== 0) throw new Error("FFmpeg failed: " + logs.slice(-5).join(" "));
|
| 366 |
+
};
|
| 367 |
+
// WORKERFS reads the browser Blobs without copying all inputs into the
|
| 368 |
+
// WASM heap. All output files are in this worker's temporary RAM only.
|
| 369 |
+
ff.FS.mkdir("/input");
|
| 370 |
+
ff.FS.mount(ff.FS.filesystems.WORKERFS, { blobs: [
|
| 371 |
+
{ name: "clip.mp4", data: data.clip },
|
| 372 |
+
...(data.previous ? [{ name: "previous.mp4", data: data.previous }] : []),
|
| 373 |
+
] }, "/input");
|
| 374 |
+
|
| 375 |
+
let frame = null;
|
| 376 |
+
if (data.extract) {
|
| 377 |
+
self.postMessage({ status: "Extracting the final frame locally…" });
|
| 378 |
+
// Reverse only the last second, then save its first frame: the decoded
|
| 379 |
+
// final frame, rather than an approximate seek into a <video> element.
|
| 380 |
+
run(["-sseof", "-1", "-i", "/input/clip.mp4", "-map", "0:v:0",
|
| 381 |
+
"-vf", "reverse", "-frames:v", "1", "-update", "1", "last.png"]);
|
| 382 |
+
frame = ff.FS.readFile("last.png");
|
| 383 |
+
}
|
| 384 |
+
let merged = null;
|
| 385 |
+
if (data.previous) {
|
| 386 |
+
self.postMessage({ status: "Stitching video and audio locally…" });
|
| 387 |
+
const probe = name => {
|
| 388 |
+
logs.length = 0;
|
| 389 |
+
ff.setTimeout(30000);
|
| 390 |
+
// An input-only ffmpeg invocation reports streams and exits with 1
|
| 391 |
+
// because no output was requested. Avoid mixing ffprobe and ffmpeg
|
| 392 |
+
// entry points in a single WASM instance.
|
| 393 |
+
ff.exec("-i", name);
|
| 394 |
+
ff.reset();
|
| 395 |
+
const streams = logs.filter(s => /Stream #.*(?:Video|Audio):/.test(s))
|
| 396 |
+
.map(s => s.match(/(?:Video|Audio): .*/)[0]
|
| 397 |
+
.replace(/,\s*[\d.]+ kb\/s/g, "").replace(/\s*\(default\)$/, ""));
|
| 398 |
+
if (!streams.some(s => s.startsWith("Video:"))) {
|
| 399 |
+
throw new Error("Cannot inspect video streams: " + logs.slice(-5).join(" "));
|
| 400 |
+
}
|
| 401 |
+
return JSON.stringify(streams);
|
| 402 |
+
};
|
| 403 |
+
if (probe("/input/previous.mp4") !== probe("/input/clip.mp4")) {
|
| 404 |
+
throw new Error("The clips have different video/audio formats. Keep the same canvas " +
|
| 405 |
+
"and frame aspect ratio, or uncheck Continue scene from last frame to start a new chain.");
|
| 406 |
+
}
|
| 407 |
+
ff.FS.writeFile("list.txt", "file '/input/previous.mp4'\nfile '/input/clip.mp4'\n");
|
| 408 |
+
// Remux both audio and video without repeatedly re-encoding the chain.
|
| 409 |
+
run(["-f", "concat", "-safe", "0", "-i", "list.txt", "-map", "0:v:0",
|
| 410 |
+
"-map", "0:a?", "-c", "copy", "-movflags", "+faststart", "joined.mp4"]);
|
| 411 |
+
merged = ff.FS.readFile("joined.mp4");
|
| 412 |
+
}
|
| 413 |
+
// Transfer independent byte buffers, never the WASM heap's backing store.
|
| 414 |
+
const frameBuffer = frame ? frame.slice().buffer : null;
|
| 415 |
+
const mergedBuffer = merged ? merged.slice().buffer : null;
|
| 416 |
+
self.postMessage({ ok: true, frame: frameBuffer, merged: mergedBuffer },
|
| 417 |
+
[frameBuffer, mergedBuffer].filter(Boolean));
|
| 418 |
+
} catch (error) {
|
| 419 |
+
self.postMessage({ error: String(error.message || error) });
|
| 420 |
+
} finally {
|
| 421 |
+
if (coreURL) URL.revokeObjectURL(coreURL);
|
| 422 |
+
if (wasmURL) URL.revokeObjectURL(wasmURL);
|
| 423 |
+
}
|
| 424 |
+
};
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
function processLocal(clip, previous, extract = true) {
|
| 428 |
+
if (clip.size + (previous?.size || 0) > LOCAL_LIMIT) {
|
| 429 |
+
throw new Error("This browser chain exceeds the 512 MiB processing limit. " +
|
| 430 |
+
"Download the current chain. Generating without stitching starts a new chain.");
|
| 431 |
+
}
|
| 432 |
+
return new Promise((resolve, reject) => {
|
| 433 |
+
const url = URL.createObjectURL(new Blob([
|
| 434 |
+
"(" + localVideoWorker.toString() + ")();"
|
| 435 |
+
], { type: "text/javascript" }));
|
| 436 |
+
const worker = new Worker(url);
|
| 437 |
+
const finish = (error, result) => {
|
| 438 |
+
clearTimeout(timeout); worker.terminate(); URL.revokeObjectURL(url);
|
| 439 |
+
if (error) reject(error); else resolve(result);
|
| 440 |
+
};
|
| 441 |
+
const timeout = setTimeout(() => finish(new Error("Local processing timed out. " +
|
| 442 |
+
"The existing chain is still available to download.")), 300000);
|
| 443 |
+
worker.onerror = e => finish(new Error(e.message || "Browser video worker failed."));
|
| 444 |
+
worker.onmessage = ({ data }) => {
|
| 445 |
+
if (data.status) { $("report").textContent = data.status; return; }
|
| 446 |
+
if (data.error) finish(new Error(data.error));
|
| 447 |
+
else if (data.ok) finish(null, data);
|
| 448 |
+
};
|
| 449 |
+
worker.postMessage({ clip, previous, extract });
|
| 450 |
+
});
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
function updateActions() {
|
| 454 |
+
$("run").disabled = state.busy;
|
| 455 |
+
$("put-last-frame").disabled = state.busy || !state.latest;
|
| 456 |
+
$("stitch-last").disabled = state.busy || !state.latest;
|
| 457 |
+
$("continue-scene").disabled = state.busy;
|
| 458 |
+
$("retry-local").disabled = state.busy;
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
async function putLastFrame(automatic = false) {
|
| 462 |
+
if (!state.latest) throw new Error("Generate a video first.");
|
| 463 |
+
if (!state.latestFrame) {
|
| 464 |
+
const result = await processLocal(state.latest, null);
|
| 465 |
+
state.latestFrame = new File([result.frame], "h3-last-frame.png", { type: "image/png" });
|
| 466 |
+
}
|
| 467 |
+
setFrame("first", state.latestFrame, automatic);
|
| 468 |
+
$("file-first").value = "";
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
async function finishClip(task) {
|
| 472 |
+
setJob("LOCAL"); setBar(null);
|
| 473 |
+
const result = task.continueScene ? await processLocal(task.clip, task.previous) : null;
|
| 474 |
+
if (result) {
|
| 475 |
+
state.latestFrame = new File([result.frame], "h3-last-frame.png", { type: "image/png" });
|
| 476 |
+
setFrame("first", state.latestFrame, true);
|
| 477 |
+
$("file-first").value = "";
|
| 478 |
+
}
|
| 479 |
+
if (result?.merged) {
|
| 480 |
+
const merged = new Blob([result.merged], { type: "video/mp4" });
|
| 481 |
+
const count = task.previousCount + 1;
|
| 482 |
+
const old = state.mergedURL;
|
| 483 |
+
state.mergedURL = URL.createObjectURL(merged);
|
| 484 |
+
$("stitched-video").src = state.mergedURL;
|
| 485 |
+
$("stitched-video").hidden = false;
|
| 486 |
+
$("download-video").href = state.mergedURL;
|
| 487 |
+
$("download-video").download = `h3-chain-${count}-clips.mp4`;
|
| 488 |
+
$("download-video").hidden = false;
|
| 489 |
+
if (old) URL.revokeObjectURL(old);
|
| 490 |
+
state.chain = merged; state.chainCount = count;
|
| 491 |
+
$("chain-info").textContent = `${count} clips · ${(merged.size / 1048576).toFixed(1)} MiB · this tab only`;
|
| 492 |
+
}
|
| 493 |
+
$("report").textContent = `${task.report || ""}` +
|
| 494 |
+
(result ? " · final frame → First frame" : "") +
|
| 495 |
+
(result?.merged ? ` · ${state.chainCount} clips stitched locally` : "");
|
| 496 |
+
state.retry = null; $("retry-local").hidden = true;
|
| 497 |
+
setJob("DONE", "done"); setBar(100);
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
$("put-last-frame").addEventListener("click", async () => {
|
| 501 |
+
if (state.busy || !state.latest) return;
|
| 502 |
+
state.busy = true; updateActions(); setJob("LOCAL"); setBar(null);
|
| 503 |
+
try {
|
| 504 |
+
await putLastFrame();
|
| 505 |
+
$("report").textContent = "Last frame placed into First frame. No video was generated.";
|
| 506 |
+
setJob("READY", "done"); setBar(100);
|
| 507 |
+
} catch (e) {
|
| 508 |
+
setJob("LOCAL FAILED", "failed"); setBar(0); $("report").textContent = e.message;
|
| 509 |
+
} finally { state.busy = false; updateActions(); }
|
| 510 |
+
});
|
| 511 |
+
|
| 512 |
+
$("retry-local").addEventListener("click", async () => {
|
| 513 |
+
if (state.busy || !state.retry) return;
|
| 514 |
+
state.busy = true; updateActions();
|
| 515 |
+
try { await finishClip(state.retry); }
|
| 516 |
+
catch (e) { setJob("LOCAL FAILED", "failed"); setBar(0); $("report").textContent = e.message; }
|
| 517 |
+
finally { state.busy = false; updateActions(); }
|
| 518 |
+
});
|
| 519 |
+
|
| 520 |
+
$("continue-scene").addEventListener("change", () => {
|
| 521 |
+
if (!$("continue-scene").checked && state.firstAuto) {
|
| 522 |
+
setFrame("first", null); $("file-first").value = "";
|
| 523 |
+
}
|
| 524 |
+
});
|
| 525 |
+
|
| 526 |
+
$("stitch-last").addEventListener("click", () => {
|
| 527 |
+
if (state.busy || !state.latest) return;
|
| 528 |
+
state.forceContinue = true;
|
| 529 |
+
$("run").click();
|
| 530 |
+
});
|
| 531 |
+
|
| 532 |
+
async function acceptGeneratedVideo(videoUrl, report) {
|
| 533 |
+
setJob("LOCAL"); setBar(null);
|
| 534 |
+
$("report").textContent = "Downloading the generated clip into this browser tab…";
|
| 535 |
+
const response = await fetch(videoUrl);
|
| 536 |
+
if (!response.ok) throw new Error(`Cannot download generated clip (${response.status}).`);
|
| 537 |
+
const clip = new Blob([await response.arrayBuffer()], { type: "video/mp4" });
|
| 538 |
+
if (!clip.size) throw new Error("The generated video is empty.");
|
| 539 |
+
showLocalVideo(clip);
|
| 540 |
+
const task = { clip, previous: state.generationPrevious,
|
| 541 |
+
previousCount: state.generationPreviousCount, report, continueScene: state.continueThisRun };
|
| 542 |
+
state.latest = clip; state.latestFrame = null;
|
| 543 |
+
state.chain = clip; state.chainCount = 1;
|
| 544 |
+
state.retry = task; $("retry-local").hidden = false;
|
| 545 |
+
await finishClip(task);
|
| 546 |
+
}
|
| 547 |
|
| 548 |
/* ---- config + status ---- */
|
| 549 |
fetch("/studio-config").then(r => r.json()).then(cfg => {
|
|
|
|
| 551 |
for (const label of cfg.canvases) {
|
| 552 |
const o = document.createElement("option");
|
| 553 |
o.value = o.textContent = label;
|
| 554 |
+
if (label === "544x544 · 1:1 fast") o.selected = true;
|
| 555 |
sel.appendChild(o);
|
| 556 |
}
|
| 557 |
$("duration").min = cfg.min_duration; $("duration").max = cfg.max_duration;
|
|
|
|
| 558 |
const loraSel = $("lora");
|
| 559 |
for (const [value, spec] of Object.entries(cfg.loras || { larry: { label: "larry", steps: 6 } })) {
|
| 560 |
const o = document.createElement("option");
|
|
|
|
| 570 |
if (spec && spec.steps) { $("steps").value = spec.steps; $("steps-out").textContent = spec.steps; }
|
| 571 |
});
|
| 572 |
});
|
|
|
|
| 573 |
async function pollStatus() {
|
| 574 |
try {
|
| 575 |
const s = await (await fetch("/status")).json();
|
|
|
|
| 582 |
setTimeout(pollStatus, 5000);
|
| 583 |
}
|
| 584 |
pollStatus();
|
|
|
|
| 585 |
/* ---- sliders ---- */
|
| 586 |
const bind = (id, fmt) => $(id).addEventListener("input", e => $(id + "-out").textContent = fmt(e.target.value));
|
| 587 |
bind("duration", v => v + " s");
|
| 588 |
bind("steps", v => v);
|
|
|
|
| 589 |
/* ---- dropzones ---- */
|
| 590 |
function wireDropzone(dzId, inputId, key) {
|
| 591 |
const dz = $(dzId), input = $(inputId);
|
| 592 |
const set = (file) => {
|
| 593 |
+
if (state.busy || !file) return;
|
| 594 |
+
if (!file.type.startsWith("image/")) return;
|
| 595 |
+
setFrame(key, file);
|
|
|
|
|
|
|
|
|
|
| 596 |
};
|
| 597 |
dz.addEventListener("click", (e) => {
|
| 598 |
+
if (state.busy) return;
|
| 599 |
if (e.target.classList.contains("clear")) {
|
| 600 |
+
setFrame(key, null); input.value = "";
|
|
|
|
| 601 |
} else input.click();
|
| 602 |
});
|
| 603 |
input.addEventListener("change", () => set(input.files[0]));
|
|
|
|
| 607 |
}
|
| 608 |
wireDropzone("dz-first", "file-first", "first");
|
| 609 |
wireDropzone("dz-last", "file-last", "last");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
/* ---- generate ---- */
|
| 611 |
const client = await Client.connect(window.location.origin, { events: ["data", "status"] });
|
|
|
|
| 612 |
function setJob(label, cls) {
|
| 613 |
const el = $("job-state");
|
| 614 |
el.textContent = label; el.className = cls || "";
|
| 615 |
}
|
|
|
|
| 616 |
function setBar(pct) {
|
| 617 |
const bar = $("bar");
|
| 618 |
if (pct === null) { bar.className = "indeterminate"; return; }
|
| 619 |
bar.className = "";
|
| 620 |
bar.style.width = Math.max(0, Math.min(100, pct)) + "%";
|
| 621 |
}
|
|
|
|
| 622 |
$("run").addEventListener("click", async () => {
|
| 623 |
if (state.busy) return;
|
| 624 |
const prompt = $("prompt").value.trim();
|
| 625 |
if (!prompt) { setJob("FAILED", "failed"); $("report").textContent = "a prompt is required"; return; }
|
| 626 |
+
// Uniform unsigned 32-bit seed; stored in the field before the request.
|
| 627 |
+
let newSeed;
|
| 628 |
+
do { newSeed = crypto.getRandomValues(new Uint32Array(1))[0]; }
|
| 629 |
+
while (newSeed === Number($("seed").value));
|
| 630 |
+
$("seed").value = String(newSeed);
|
| 631 |
+
state.continueThisRun = !!state.forceContinue || $("continue-scene").checked;
|
| 632 |
+
state.forceContinue = false;
|
| 633 |
+
state.generationPrevious = state.continueThisRun ? state.chain : null;
|
| 634 |
+
state.generationPreviousCount = state.chainCount;
|
| 635 |
+
state.retry = null; $("retry-local").hidden = true;
|
| 636 |
state.busy = true;
|
| 637 |
+
updateActions();
|
| 638 |
$("refined-bar").style.display = "none";
|
| 639 |
$("report").textContent = "";
|
| 640 |
setJob("ROLLING", "");
|
| 641 |
const t0 = Date.now();
|
| 642 |
state.timer = setInterval(() => $("elapsed").textContent = ((Date.now() - t0) / 1000).toFixed(0) + "s", 500);
|
|
|
|
| 643 |
try {
|
| 644 |
+
if (state.continueThisRun && state.latest) await putLastFrame(true);
|
| 645 |
const submission = client.submit("/generate", {
|
| 646 |
prompt,
|
| 647 |
image_path: state.first ? handle_file(state.first) : null,
|
|
|
|
| 650 |
duration: Number($("duration").value),
|
| 651 |
steps: Number($("steps").value),
|
| 652 |
seed: Number($("seed").value),
|
| 653 |
+
upsample: false,
|
| 654 |
lora: $("lora").value,
|
| 655 |
});
|
| 656 |
let data = null;
|
|
|
|
| 681 |
const videoUrl = (video && (video.url || (video.path && `/gradio_api/file=${video.path}`)))
|
| 682 |
|| (typeof video === "string" && `/gradio_api/file=${video}`);
|
| 683 |
if (!videoUrl) throw new Error("the backend returned no video reference");
|
| 684 |
+
await acceptGeneratedVideo(videoUrl, report);
|
|
|
|
|
|
|
|
|
|
| 685 |
if (refined) {
|
| 686 |
$("refined").textContent = refined;
|
| 687 |
$("refined-bar").style.display = "block";
|
|
|
|
| 689 |
setJob("DONE", "done");
|
| 690 |
setBar(100);
|
| 691 |
} catch (e) {
|
| 692 |
+
setJob(state.retry ? "LOCAL FAILED" : "FAILED", "failed");
|
| 693 |
setBar(0);
|
| 694 |
$("report").textContent = (e && e.message) ? e.message.slice(0, 300) : String(e);
|
| 695 |
} finally {
|
| 696 |
clearInterval(state.timer);
|
| 697 |
state.busy = false;
|
| 698 |
+
updateActions();
|
| 699 |
}
|
| 700 |
});
|
| 701 |
</script>
|