BetyaPetya commited on
Commit
f4169cb
·
verified ·
1 Parent(s): 157975f

Update index.html

Browse files
Files changed (1) hide show
  1. 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="upsample"> Upsample prompt</label>
 
 
 
 
 
 
 
 
 
 
 
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">5 s</output></div>
251
- <input type="range" id="duration" min="2" max="14" step="1" value="5">
252
  </div>
253
  <div>
254
- <div class="slider-row"><span>Steps</span><output id="steps-out">6</output></div>
255
- <input type="range" id="steps" min="2" max="40" step="1" value="6">
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">▶&nbsp; 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 === cfg.default_canvas) o.selected = true;
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
- state[key] = file;
354
- const img = document.createElement("img");
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
- state[key] = null; dz.classList.remove("filled");
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
- $("run").disabled = true;
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: $("upsample").checked,
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
- $("video").src = videoUrl;
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
- $("run").disabled = false;
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">▶&nbsp; 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>