Files changed (1) hide show
  1. index.html +470 -24
index.html CHANGED
@@ -42,6 +42,7 @@
42
  border-radius:14px;outline:none;font:inherit;transition:.18s ease
43
  }
44
  textarea{min-height:168px;resize:vertical;padding:14px;line-height:1.52}
 
45
  select,input{height:48px;padding:0 13px}
46
  textarea:focus,select:focus,input:focus{border-color:rgba(139,108,255,.9);box-shadow:0 0 0 4px rgba(139,108,255,.12)}
47
  .row{display:grid;grid-template-columns:1fr 1fr;gap:11px}
@@ -106,6 +107,11 @@
106
  </select>
107
  </div>
108
 
 
 
 
 
 
109
  <div class="field">
110
  <label for="prompt">Prompt</label>
111
  <textarea id="prompt" maxlength="5000" placeholder="Describe the subject, environment, camera, lighting, mood and composition..."></textarea>
@@ -150,10 +156,12 @@
150
  <div class="queuebox">
151
  <span class="qchip" id="queuedChip">Queued: 0</span>
152
  <span class="qchip" id="activeChip">Active: none</span>
 
 
153
  </div>
154
 
155
  <div class="fine">
156
- This Static Space keeps its own FIFO queue inside your browser tab. The remote GPU is provided by the connected public Hugging Face generation Space.
157
  </div>
158
  </div>
159
 
@@ -181,19 +189,146 @@
181
 
182
  <script type="module">
183
  import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/+esm";
 
184
  /*
185
  Static frontend only.
186
  Change this single constant if you later choose another compatible
187
  public AIDMA/FLUX LoRA generation Space.
188
  */
189
- const BACKEND_SPACE = "multimodalart/flux-lora-the-explorer";
190
- const BACKEND_ORIGIN = "https://multimodalart-flux-lora-the-explorer.hf.space";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  const $ = id => document.getElementById(id);
192
  const localQueue = [];
193
  let processing = false;
194
  let client = null;
195
  let api = null;
 
 
196
  let jobCounter = 0;
 
 
 
 
 
 
 
 
 
197
  const STYLES = [
198
  "No extra style","Photorealistic","Ultra realistic photography","Cinematic",
199
  "Hollywood blockbuster","IMAX film still","35mm analog film","70mm epic cinema",
@@ -240,6 +375,7 @@ const STYLES = [
240
  "Clean commercial","Hyper-detailed","Ethereal","Whimsical","Moody","Haunting",
241
  "Cozy","Playful","Epic"
242
  ];
 
243
  const STYLE_DETAIL = {
244
  "No extra style":"",
245
  "Photorealistic":"photorealistic, realistic materials, physically accurate lighting, natural texture",
@@ -299,28 +435,73 @@ const STYLE_DETAIL = {
299
  "Cozy":"cozy atmosphere, warm practical light, tactile materials, inviting detail",
300
  "Epic":"epic scale, heroic composition, monumental depth, dramatic visual impact"
301
  };
 
302
  const PROMPT_BOOST =
303
  "masterful composition, coherent anatomy, accurate hands and facial structure, " +
304
  "clear subject separation, refined color harmony, realistic material response, " +
305
  "controlled detail, intentional lighting, professional visual storytelling";
 
306
  for (const style of STYLES) {
307
  const option = document.createElement("option");
308
  option.value = style;
309
  $("styles").appendChild(option);
310
  }
 
311
  function normalise(value) {
312
  return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
313
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  function updateQueueUI() {
315
  $("queuedChip").textContent = `Queued: ${localQueue.length}`;
316
- $("activeChip").textContent = processing ? "Active: generating" : "Active: none";
 
 
 
 
 
 
 
 
 
317
  }
 
318
  function setViewer(mode, message = "") {
319
  $("empty").style.display = mode === "empty" ? "block" : "none";
320
  $("loading").style.display = mode === "loading" ? "block" : "none";
321
  $("image").style.display = mode === "image" ? "block" : "none";
322
  if (message) $("status").textContent = message;
323
  }
 
324
  function endpointInfo(names) {
325
  const named = api?.named_endpoints || {};
326
  const entries = Object.entries(named);
@@ -334,43 +515,269 @@ function endpointInfo(names) {
334
  }
335
  return { endpoint: names[0], spec: null };
336
  }
 
337
  function buildPayload(spec, values, fallback) {
338
  const params = spec?.parameters;
339
  if (!Array.isArray(params) || !params.length) return fallback;
 
340
  return params.map(param => {
341
  const name = normalise(param.parameter_name || param.label || param.name || param.component);
342
  if (Object.prototype.hasOwnProperty.call(values, name)) return values[name];
 
343
  for (const [alias, value] of Object.entries(values)) {
344
  if (alias && name && (name.includes(alias) || alias.includes(name))) return value;
345
  }
 
346
  if ("parameter_default" in param) return param.parameter_default;
347
  return null;
348
  });
349
  }
350
- async function connectBackend() {
351
- if (client) return client;
352
- $("loadingTitle").textContent = "Connecting to the remote AIDMA engine…";
353
- client = await Client.connect(BACKEND_SPACE, {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  events: ["data", "status"],
355
- space_status: status => {
356
- if (status?.status && status.status !== "running") {
357
- $("queueStatus").textContent = `Remote Space: ${status.status}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  }
359
  }
360
- });
361
- api = await client.view_api();
362
- return client;
 
 
 
 
363
  }
 
364
  function snapshotJob() {
 
365
  const prompt = $("prompt").value.trim();
366
  if (!prompt) throw new Error("Write a prompt first.");
 
367
  const modelOption = $("model").selectedOptions[0];
368
  const [width, height] = $("ratio").value.split("x").map(Number);
369
  const styleName = $("style").value.trim();
370
  const styleText = STYLE_DETAIL[styleName] ?? styleName;
 
371
  const parts = [prompt];
372
  if (styleText && normalise(styleName) !== normalise("No extra style")) parts.push(styleText);
373
  if ($("boost").checked) parts.push(PROMPT_BOOST);
 
374
  return {
375
  id: ++jobCounter,
376
  repo: modelOption.value,
@@ -385,44 +792,57 @@ function snapshotJob() {
385
  loraScale: 0.95
386
  };
387
  }
 
388
  function remoteFileUrl(value) {
389
  if (typeof value !== "string") return null;
390
  const text = value.trim();
391
  if (!text) return null;
 
392
  // Already usable in the browser.
393
  if (/^(https?:\/\/|blob:|data:image\/)/i.test(text)) return text;
 
394
  // Modern Gradio may return a relative file route.
395
- if (text.startsWith("/gradio_api/")) return `${BACKEND_ORIGIN}${text}`;
396
- if (text.startsWith("gradio_api/")) return `${BACKEND_ORIGIN}/${text}`;
 
397
  // Some Gradio versions return only the server-side temporary path.
398
  if (
399
  text.startsWith("/tmp/") ||
400
  text.includes("/gradio/") ||
401
  /\.(png|jpe?g|webp|gif|bmp)(\?|$)/i.test(text)
402
  ) {
403
- return `${BACKEND_ORIGIN}/gradio_api/file=${encodeURI(text)}`;
404
  }
 
405
  return null;
406
  }
 
407
  function extractImageUrl(value, seen = new Set()) {
408
  if (value == null) return null;
 
409
  if (typeof Blob !== "undefined" && value instanceof Blob) {
410
  return URL.createObjectURL(value);
411
  }
 
412
  if (typeof File !== "undefined" && value instanceof File) {
413
  return URL.createObjectURL(value);
414
  }
 
415
  if (typeof value === "string") {
416
  const trimmed = value.trim();
 
417
  // Some component serializers return raw base64 rather than a FileData URL.
418
  if (/^[A-Za-z0-9+/=\s]{1000,}$/.test(trimmed)) {
419
  return `data:image/png;base64,${trimmed.replace(/\s+/g, "")}`;
420
  }
 
421
  return remoteFileUrl(trimmed);
422
  }
 
423
  if (typeof value !== "object") return null;
424
  if (seen.has(value)) return null;
425
  seen.add(value);
 
426
  if (Array.isArray(value)) {
427
  // The first output of /run_lora is the generated image.
428
  for (const item of value) {
@@ -431,6 +851,7 @@ function extractImageUrl(value, seen = new Set()) {
431
  }
432
  return null;
433
  }
 
434
  // Gradio FileData commonly uses url/path/orig_name/name.
435
  for (const key of ["url", "path", "orig_name", "name", "image", "data"]) {
436
  if (Object.prototype.hasOwnProperty.call(value, key)) {
@@ -438,12 +859,15 @@ function extractImageUrl(value, seen = new Set()) {
438
  if (found) return found;
439
  }
440
  }
 
441
  for (const item of Object.values(value)) {
442
  const found = extractImageUrl(item, seen);
443
  if (found) return found;
444
  }
 
445
  return null;
446
  }
 
447
  function showImage(url, job) {
448
  $("image").src = url;
449
  $("download").href = url;
@@ -451,6 +875,7 @@ function showImage(url, job) {
451
  $("download").style.display = "inline-flex";
452
  setViewer("image", `${job.modelLabel} · seed ${job.seed}`);
453
  }
 
454
  async function loadSelectedLora(job) {
455
  const { endpoint, spec } = endpointInfo(["/add_custom_lora", "add_custom_lora"]);
456
  const payload = buildPayload(
@@ -462,17 +887,20 @@ async function loadSelectedLora(job) {
462
  },
463
  [job.repo]
464
  );
 
465
  const result = await client.predict(endpoint, payload);
466
  const data = result?.data || [];
467
  const selectedIndex = Number.isFinite(Number(data[4])) ? Number(data[4]) : null;
468
  const trigger = typeof data[5] === "string" && data[5].trim() ? data[5].trim() : job.trigger;
469
  return { selectedIndex, trigger };
470
  }
 
471
  async function runGeneration(job, selectedIndex, trigger) {
472
  const { endpoint, spec } = endpointInfo(["/run_lora", "run_lora"]);
473
  const finalPrompt = normalise(job.prompt).includes(normalise(trigger))
474
  ? job.prompt
475
  : `${trigger}, ${job.prompt}`;
 
476
  const values = {
477
  prompt: finalPrompt,
478
  prompttext: finalPrompt,
@@ -493,6 +921,7 @@ async function runGeneration(job, selectedIndex, trigger) {
493
  lorascale: job.loraScale,
494
  adapterstrength: job.loraScale
495
  };
 
496
  /*
497
  The explorer's generation function is a Python generator that emits preview
498
  frames. Using submit() here can leave a Static frontend holding only a
@@ -503,17 +932,22 @@ async function runGeneration(job, selectedIndex, trigger) {
503
  finalPrompt, null, 0.75, job.cfg, job.steps, false,
504
  job.seed, job.width, job.height, job.loraScale
505
  ];
 
506
  const payload = buildPayload(spec, values, fallbackWithoutState);
 
507
  $("queueStatus").textContent = "Waiting for the remote GPU and final image";
508
  const response = await client.predict(endpoint, payload);
 
509
  // @gradio/client normally returns { type: "data", data: [...] }.
510
  // Keep compatibility with clients that return the output array directly.
511
  const outputs = response?.data ?? response;
512
  console.log("AIDMA final Gradio outputs:", outputs);
 
513
  // /run_lora outputs: [generated image, final seed, progress component].
514
  const imageOutput = Array.isArray(outputs) ? outputs[0] : outputs;
515
  const returnedSeed = Array.isArray(outputs) ? Number(outputs[1]) : NaN;
516
  const finalUrl = extractImageUrl(imageOutput) || extractImageUrl(outputs);
 
517
  if (!finalUrl) {
518
  console.error("Unrecognized final Gradio image payload:", outputs);
519
  throw new Error(
@@ -521,14 +955,17 @@ async function runGeneration(job, selectedIndex, trigger) {
521
  "It may have rejected this LoRA or changed its API."
522
  );
523
  }
 
524
  if (Number.isFinite(returnedSeed)) job.seed = returnedSeed;
525
  showImage(finalUrl, job);
526
  return finalUrl;
527
  }
 
528
  async function processQueue() {
529
  if (processing || !localQueue.length) return;
530
  processing = true;
531
  updateQueueUI();
 
532
  while (localQueue.length) {
533
  const job = localQueue.shift();
534
  updateQueueUI();
@@ -537,11 +974,9 @@ async function processQueue() {
537
  $("queueStatus").textContent = "Preparing AIDMA LoRA";
538
  $("status").className = "";
539
  setViewer("loading", `Job #${job.id} is running.`);
 
540
  try {
541
- await connectBackend();
542
- const loaded = await loadSelectedLora(job);
543
- $("queueStatus").textContent = "Submitting to remote GPU and waiting for final output";
544
- await runGeneration(job, loaded.selectedIndex, loaded.trigger);
545
  } catch (error) {
546
  console.error(error);
547
  $("status").className = "error";
@@ -551,18 +986,20 @@ async function processQueue() {
551
  '<div style="margin-top:8px">' + escapeHtml(error?.message || String(error)) + '</div>';
552
  setViewer("empty");
553
  // Force a clean connection for the next queued job.
554
- client = null;
555
- api = null;
556
  }
557
  }
 
558
  processing = false;
559
  updateQueueUI();
560
  }
 
561
  function escapeHtml(value) {
562
  return String(value).replace(/[&<>"']/g, char => ({
563
  "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"
564
  })[char]);
565
  }
 
566
  $("generate").addEventListener("click", () => {
567
  try {
568
  const job = snapshotJob();
@@ -577,13 +1014,22 @@ $("generate").addEventListener("click", () => {
577
  $("prompt").focus();
578
  }
579
  });
 
 
 
 
 
 
 
 
580
  $("prompt").addEventListener("keydown", event => {
581
  if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
582
  event.preventDefault();
583
  $("generate").click();
584
  }
585
  });
 
586
  updateQueueUI();
587
  </script>
588
  </body>
589
- </html>
 
42
  border-radius:14px;outline:none;font:inherit;transition:.18s ease
43
  }
44
  textarea{min-height:168px;resize:vertical;padding:14px;line-height:1.52}
45
+ textarea.token-pool{min-height:108px;font:12px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace}
46
  select,input{height:48px;padding:0 13px}
47
  textarea:focus,select:focus,input:focus{border-color:rgba(139,108,255,.9);box-shadow:0 0 0 4px rgba(139,108,255,.12)}
48
  .row{display:grid;grid-template-columns:1fr 1fr;gap:11px}
 
107
  </select>
108
  </div>
109
 
110
+ <div class="field">
111
+ <label for="hfTokens">Hugging Face tokens · one per line · max 50</label>
112
+ <textarea id="hfTokens" class="token-pool" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="hf_token_1&#10;hf_token_2&#10;hf_token_3"></textarea>
113
+ </div>
114
+
115
  <div class="field">
116
  <label for="prompt">Prompt</label>
117
  <textarea id="prompt" maxlength="5000" placeholder="Describe the subject, environment, camera, lighting, mood and composition..."></textarea>
 
156
  <div class="queuebox">
157
  <span class="qchip" id="queuedChip">Queued: 0</span>
158
  <span class="qchip" id="activeChip">Active: none</span>
159
+ <span class="qchip" id="tokenChip">Token: anonymous</span>
160
+ <span class="qchip" id="engineChip">Engines: 20</span>
161
  </div>
162
 
163
  <div class="fine">
164
+ This Static Space keeps a private FIFO queue in your browser, uses the tokens in order without pre-checking them, switches only after quota/rate-limit/authentication failure, and keeps the existing 20-engine backend failover. Tokens are kept only in this open page.
165
  </div>
166
  </div>
167
 
 
189
 
190
  <script type="module">
191
  import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/+esm";
192
+
193
  /*
194
  Static frontend only.
195
  Change this single constant if you later choose another compatible
196
  public AIDMA/FLUX LoRA generation Space.
197
  */
198
+ /*
199
+ Ordered AIDMA backend failover list.
200
+ The current backend remains preferred until it fails, then the next one is used.
201
+ Add more compatible Spaces here later using the same object format.
202
+ */
203
+ function spaceOrigin(spaceId) {
204
+ return `https://${spaceId.replace("/", "-").toLowerCase()}.hf.space`;
205
+ }
206
+
207
+ /*
208
+ Twenty-slot backend pool.
209
+ Every candidate is API-checked at runtime. Paused, broken, sleeping,
210
+ incompatible, or unreachable Spaces are skipped automatically.
211
+ */
212
+ const BACKEND_SPACES = [
213
+ {
214
+ id: "multimodalart/flux-lora-the-explorer",
215
+ origin: spaceOrigin("multimodalart/flux-lora-the-explorer"),
216
+ label: "AIDMA Engine 1"
217
+ },
218
+ {
219
+ id: "John6666/flux-lora-the-explorer",
220
+ origin: spaceOrigin("John6666/flux-lora-the-explorer"),
221
+ label: "AIDMA Engine 2"
222
+ },
223
+ {
224
+ id: "Svngoku/flux-lora-the-explorer",
225
+ origin: spaceOrigin("Svngoku/flux-lora-the-explorer"),
226
+ label: "AIDMA Engine 3"
227
+ },
228
+ {
229
+ id: "Nymbo/flux-lora-the-explorer",
230
+ origin: spaceOrigin("Nymbo/flux-lora-the-explorer"),
231
+ label: "AIDMA Engine 4"
232
+ },
233
+ {
234
+ id: "Emuixom/flux-lora-the-explorer",
235
+ origin: spaceOrigin("Emuixom/flux-lora-the-explorer"),
236
+ label: "AIDMA Engine 5"
237
+ },
238
+ {
239
+ id: "codermert/flux-lora-the-explorer",
240
+ origin: spaceOrigin("codermert/flux-lora-the-explorer"),
241
+ label: "AIDMA Engine 6"
242
+ },
243
+ {
244
+ id: "reza74ii/flux-lora-the-explorer",
245
+ origin: spaceOrigin("reza74ii/flux-lora-the-explorer"),
246
+ label: "AIDMA Engine 7"
247
+ },
248
+ {
249
+ id: "x2778/flux-lora-the-explorer-a",
250
+ origin: spaceOrigin("x2778/flux-lora-the-explorer-a"),
251
+ label: "AIDMA Engine 8"
252
+ },
253
+ {
254
+ id: "Ckjdjdjf/flux-lora-the-explorer",
255
+ origin: spaceOrigin("Ckjdjdjf/flux-lora-the-explorer"),
256
+ label: "AIDMA Engine 9"
257
+ },
258
+ {
259
+ id: "ZENLLC/flux-lora-the-explorer",
260
+ origin: spaceOrigin("ZENLLC/flux-lora-the-explorer"),
261
+ label: "AIDMA Engine 10"
262
+ },
263
+ {
264
+ id: "tenet/flux-lora-the-explorer",
265
+ origin: spaceOrigin("tenet/flux-lora-the-explorer"),
266
+ label: "AIDMA Engine 11"
267
+ },
268
+ {
269
+ id: "seawolf2357/flxloraexp",
270
+ origin: spaceOrigin("seawolf2357/flxloraexp"),
271
+ label: "AIDMA Engine 12"
272
+ },
273
+ {
274
+ id: "ginipick/flxloraexp",
275
+ origin: spaceOrigin("ginipick/flxloraexp"),
276
+ label: "AIDMA Engine 13"
277
+ },
278
+ {
279
+ id: "John6666/flux-lora-the-explorer-crash-progress",
280
+ origin: spaceOrigin("John6666/flux-lora-the-explorer-crash-progress"),
281
+ label: "AIDMA Engine 14"
282
+ },
283
+ {
284
+ id: "killwithabass/flux-gay-lora-explorer",
285
+ origin: spaceOrigin("killwithabass/flux-gay-lora-explorer"),
286
+ label: "AIDMA Engine 15"
287
+ },
288
+ {
289
+ id: "multimodalart/flux-lora-lab",
290
+ origin: spaceOrigin("multimodalart/flux-lora-lab"),
291
+ label: "AIDMA Engine 16"
292
+ },
293
+ {
294
+ id: "prithivMLmods/FLUX-LoRA-DLC2",
295
+ origin: spaceOrigin("prithivMLmods/FLUX-LoRA-DLC2"),
296
+ label: "AIDMA Engine 17"
297
+ },
298
+ {
299
+ id: "enzostvs/lora-studio",
300
+ origin: spaceOrigin("enzostvs/lora-studio"),
301
+ label: "AIDMA Engine 18"
302
+ },
303
+ {
304
+ id: "ovi054/FLUX.Dev-LoRA",
305
+ origin: spaceOrigin("ovi054/FLUX.Dev-LoRA"),
306
+ label: "AIDMA Engine 19"
307
+ },
308
+ {
309
+ id: "waloneai/FLUX.Dev-LoRA-Serverless",
310
+ origin: spaceOrigin("waloneai/FLUX.Dev-LoRA-Serverless"),
311
+ label: "AIDMA Engine 20"
312
+ }
313
+ ];
314
+
315
  const $ = id => document.getElementById(id);
316
  const localQueue = [];
317
  let processing = false;
318
  let client = null;
319
  let api = null;
320
+ let activeBackend = null;
321
+ let preferredBackendIndex = 0;
322
  let jobCounter = 0;
323
+ const MAX_HF_TOKENS = 50;
324
+ let hfTokens = [];
325
+ let activeTokenIndex = 0;
326
+ let connectedTokenIndex = -1;
327
+ let tokenPoolSignature = "";
328
+ const backendFailures = new Map();
329
+ const BACKEND_FAILURE_COOLDOWN_MS = 10 * 60 * 1000;
330
+ const BACKEND_CONNECT_TIMEOUT_MS = 18 * 1000;
331
+
332
  const STYLES = [
333
  "No extra style","Photorealistic","Ultra realistic photography","Cinematic",
334
  "Hollywood blockbuster","IMAX film still","35mm analog film","70mm epic cinema",
 
375
  "Clean commercial","Hyper-detailed","Ethereal","Whimsical","Moody","Haunting",
376
  "Cozy","Playful","Epic"
377
  ];
378
+
379
  const STYLE_DETAIL = {
380
  "No extra style":"",
381
  "Photorealistic":"photorealistic, realistic materials, physically accurate lighting, natural texture",
 
435
  "Cozy":"cozy atmosphere, warm practical light, tactile materials, inviting detail",
436
  "Epic":"epic scale, heroic composition, monumental depth, dramatic visual impact"
437
  };
438
+
439
  const PROMPT_BOOST =
440
  "masterful composition, coherent anatomy, accurate hands and facial structure, " +
441
  "clear subject separation, refined color harmony, realistic material response, " +
442
  "controlled detail, intentional lighting, professional visual storytelling";
443
+
444
  for (const style of STYLES) {
445
  const option = document.createElement("option");
446
  option.value = style;
447
  $("styles").appendChild(option);
448
  }
449
+
450
  function normalise(value) {
451
  return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
452
  }
453
+
454
+ function parseHfTokens(value) {
455
+ const unique = new Set();
456
+ for (const part of String(value || "").split(/[\s,;]+/)) {
457
+ const token = part.trim();
458
+ if (!token || !token.startsWith("hf_") || unique.has(token)) continue;
459
+ unique.add(token);
460
+ if (unique.size >= MAX_HF_TOKENS) break;
461
+ }
462
+ return [...unique];
463
+ }
464
+
465
+ function syncTokenPool() {
466
+ const nextTokens = parseHfTokens($("hfTokens").value);
467
+ const nextSignature = nextTokens.join("\n");
468
+
469
+ if (nextSignature !== tokenPoolSignature) {
470
+ hfTokens = nextTokens;
471
+ tokenPoolSignature = nextSignature;
472
+ activeTokenIndex = 0;
473
+ resetBackendConnection();
474
+ }
475
+
476
+ updateQueueUI();
477
+ return hfTokens;
478
+ }
479
+
480
+ function activeToken() {
481
+ return hfTokens[activeTokenIndex] || "";
482
+ }
483
+
484
  function updateQueueUI() {
485
  $("queuedChip").textContent = `Queued: ${localQueue.length}`;
486
+ $("tokenChip").textContent = hfTokens.length
487
+ ? `Token: ${activeTokenIndex + 1}/${hfTokens.length}`
488
+ : "Token: anonymous";
489
+
490
+ if (!processing) {
491
+ $("activeChip").textContent = "Active: none";
492
+ return;
493
+ }
494
+ const engine = activeBackend?.label || "connecting";
495
+ $("activeChip").textContent = `Active: ${engine}`;
496
  }
497
+
498
  function setViewer(mode, message = "") {
499
  $("empty").style.display = mode === "empty" ? "block" : "none";
500
  $("loading").style.display = mode === "loading" ? "block" : "none";
501
  $("image").style.display = mode === "image" ? "block" : "none";
502
  if (message) $("status").textContent = message;
503
  }
504
+
505
  function endpointInfo(names) {
506
  const named = api?.named_endpoints || {};
507
  const entries = Object.entries(named);
 
515
  }
516
  return { endpoint: names[0], spec: null };
517
  }
518
+
519
  function buildPayload(spec, values, fallback) {
520
  const params = spec?.parameters;
521
  if (!Array.isArray(params) || !params.length) return fallback;
522
+
523
  return params.map(param => {
524
  const name = normalise(param.parameter_name || param.label || param.name || param.component);
525
  if (Object.prototype.hasOwnProperty.call(values, name)) return values[name];
526
+
527
  for (const [alias, value] of Object.entries(values)) {
528
  if (alias && name && (name.includes(alias) || alias.includes(name))) return value;
529
  }
530
+
531
  if ("parameter_default" in param) return param.parameter_default;
532
  return null;
533
  });
534
  }
535
+
536
+ function withTimeout(promise, milliseconds, message) {
537
+ let timer;
538
+ const timeout = new Promise((_, reject) => {
539
+ timer = setTimeout(() => reject(new Error(message)), milliseconds);
540
+ });
541
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
542
+ }
543
+
544
+ function backendIsCoolingDown(spaceId) {
545
+ const failedAt = backendFailures.get(spaceId);
546
+ if (!failedAt) return false;
547
+ if (Date.now() - failedAt >= BACKEND_FAILURE_COOLDOWN_MS) {
548
+ backendFailures.delete(spaceId);
549
+ return false;
550
+ }
551
+ return true;
552
+ }
553
+
554
+ function markBackendFailed(spaceId) {
555
+ backendFailures.set(spaceId, Date.now());
556
+ }
557
+
558
+ function backendSupportsAidmaApi(apiDescription) {
559
+ const named = apiDescription?.named_endpoints || {};
560
+ const names = Object.keys(named).map(normalise);
561
+ const hasAdd = names.some(name => name.includes(normalise("add_custom_lora")));
562
+ const hasRun = names.some(name => name.includes(normalise("run_lora")));
563
+ return hasAdd && hasRun;
564
+ }
565
+
566
+ function resetBackendConnection() {
567
+ client = null;
568
+ api = null;
569
+ activeBackend = null;
570
+ connectedTokenIndex = -1;
571
+ updateQueueUI();
572
+ }
573
+
574
+ async function connectBackend(index) {
575
+ const backend = BACKEND_SPACES[index];
576
+ if (!backend) throw new Error("Unknown backend index.");
577
+
578
+ if (
579
+ client &&
580
+ activeBackend?.id === backend.id &&
581
+ connectedTokenIndex === activeTokenIndex
582
+ ) return client;
583
+
584
+ resetBackendConnection();
585
+ activeBackend = backend;
586
+ updateQueueUI();
587
+
588
+ $("loadingTitle").textContent = `Connecting to ${backend.label}…`;
589
+ $("queueStatus").textContent = backend.id;
590
+
591
+ const handleSpaceStatus = status => {
592
+ if (status?.status && status.status !== "running") {
593
+ $("queueStatus").textContent = `${backend.label}: ${status.status}`;
594
+ }
595
+ };
596
+
597
+ const connectOptions = {
598
  events: ["data", "status"],
599
+ space_status: handleSpaceStatus,
600
+ status_callback: handleSpaceStatus
601
+ };
602
+ const token = activeToken();
603
+ if (token) connectOptions.token = token;
604
+
605
+ const newClient = await withTimeout(
606
+ Client.connect(backend.id, connectOptions),
607
+ BACKEND_CONNECT_TIMEOUT_MS,
608
+ `${backend.label} connection timed out`
609
+ );
610
+
611
+ const newApi = await withTimeout(
612
+ newClient.view_api(),
613
+ BACKEND_CONNECT_TIMEOUT_MS,
614
+ `${backend.label} API check timed out`
615
+ );
616
+
617
+ if (!backendSupportsAidmaApi(newApi)) {
618
+ throw new Error(`${backend.label} is not compatible with the required AIDMA API`);
619
+ }
620
+
621
+ client = newClient;
622
+ api = newApi;
623
+ connectedTokenIndex = activeTokenIndex;
624
+ return client;
625
+ }
626
+
627
+ function errorText(error) {
628
+ return String(error?.message || error || "").replace(/\s+/g, " ").trim();
629
+ }
630
+
631
+ function isCallerZeroGpuQuotaError(error) {
632
+ const text = errorText(error).toLowerCase();
633
+ return (
634
+ text.includes("exceeded your free zerogpu quota") ||
635
+ text.includes("zerogpu quota") && text.includes("left") ||
636
+ text.includes("daily gpu quota") ||
637
+ text.includes("gpu quota") && text.includes("exceeded") ||
638
+ text.includes("try again in") && text.includes("quota")
639
+ );
640
+ }
641
+
642
+ function isTokenRateLimitError(error) {
643
+ const text = errorText(error).toLowerCase();
644
+ return (
645
+ isCallerZeroGpuQuotaError(error) ||
646
+ text.includes("too many requests") ||
647
+ text.includes("rate limit") ||
648
+ text.includes("rate-limit") ||
649
+ /(^|\D)429(\D|$)/.test(text)
650
+ );
651
+ }
652
+
653
+ function isTokenAuthenticationError(error) {
654
+ const text = errorText(error).toLowerCase();
655
+ return (
656
+ text.includes("invalid token") ||
657
+ text.includes("token is invalid") ||
658
+ text.includes("unauthorized") ||
659
+ text.includes("authentication failed") ||
660
+ text.includes("invalid credentials") ||
661
+ /(^|\D)401(\D|$)/.test(text)
662
+ );
663
+ }
664
+
665
+ function rotateToNextToken(reason) {
666
+ if (!hfTokens.length || activeTokenIndex + 1 >= hfTokens.length) return false;
667
+
668
+ const previousNumber = activeTokenIndex + 1;
669
+ activeTokenIndex += 1;
670
+ resetBackendConnection();
671
+ $("queueStatus").textContent =
672
+ `Token ${previousNumber} ${reason}. Switching to token ${activeTokenIndex + 1}/${hfTokens.length}…`;
673
+ updateQueueUI();
674
+ return true;
675
+ }
676
+
677
+ async function generateWithBackendFailover(job) {
678
+ const errors = [];
679
+ const remainingTokenAttempts = hfTokens.length
680
+ ? hfTokens.length - activeTokenIndex
681
+ : 1;
682
+ let attemptedTokens = 0;
683
+
684
+ while (attemptedTokens < remainingTokenAttempts) {
685
+ let switchedToken = false;
686
+
687
+ for (let offset = 0; offset < BACKEND_SPACES.length; offset++) {
688
+ const index = (preferredBackendIndex + offset) % BACKEND_SPACES.length;
689
+ const backend = BACKEND_SPACES[index];
690
+
691
+ if (backendIsCoolingDown(backend.id)) {
692
+ continue;
693
+ }
694
+
695
+ try {
696
+ await connectBackend(index);
697
+ $("queueStatus").textContent = `${backend.label}: loading AIDMA LoRA`;
698
+ const loaded = await loadSelectedLora(job);
699
+
700
+ $("queueStatus").textContent =
701
+ `${backend.label}: waiting for remote GPU and final image`;
702
+ await runGeneration(job, loaded.selectedIndex, loaded.trigger);
703
+
704
+ preferredBackendIndex = index;
705
+ backendFailures.delete(backend.id);
706
+ return;
707
+ } catch (error) {
708
+ const message = errorText(error);
709
+ console.error(`${backend.label} failed:`, error);
710
+
711
+ const quotaOrRateLimit = isTokenRateLimitError(error);
712
+ const authenticationFailure = isTokenAuthenticationError(error);
713
+
714
+ if (hfTokens.length && (quotaOrRateLimit || authenticationFailure)) {
715
+ attemptedTokens += 1;
716
+ errors.push(`Token ${activeTokenIndex + 1}: ${message || "unavailable"}`);
717
+ const reason = authenticationFailure ? "was rejected" : "reached its quota/rate limit";
718
+
719
+ if (attemptedTokens < remainingTokenAttempts && rotateToNextToken(reason)) {
720
+ switchedToken = true;
721
+ break;
722
+ }
723
+
724
+ resetBackendConnection();
725
+ activeTokenIndex = 0;
726
+ updateQueueUI();
727
+ throw new Error(
728
+ "All configured Hugging Face tokens reached quota/rate-limit or were rejected. " +
729
+ errors.join(" | ")
730
+ );
731
+ }
732
+
733
+ if (!hfTokens.length && quotaOrRateLimit) {
734
+ resetBackendConnection();
735
+ throw new Error(message);
736
+ }
737
+
738
+ errors.push(`${backend.label}: ${message || "unknown error"}`);
739
+ markBackendFailed(backend.id);
740
+ resetBackendConnection();
741
+
742
+ if (offset + 1 < BACKEND_SPACES.length) {
743
+ let nextBackend = null;
744
+ for (let ahead = offset + 1; ahead < BACKEND_SPACES.length; ahead++) {
745
+ const nextIndex = (preferredBackendIndex + ahead) % BACKEND_SPACES.length;
746
+ const candidate = BACKEND_SPACES[nextIndex];
747
+ if (!backendIsCoolingDown(candidate.id)) {
748
+ nextBackend = candidate;
749
+ break;
750
+ }
751
+ }
752
+ $("queueStatus").textContent = nextBackend
753
+ ? `${backend.label} failed. Switching to ${nextBackend.label}…`
754
+ : `${backend.label} failed. No healthy backend remains.`;
755
+ }
756
  }
757
  }
758
+
759
+ if (switchedToken) continue;
760
+
761
+ throw new Error(
762
+ "All configured AIDMA Spaces failed. " + errors.join(" | ")
763
+ );
764
+ }
765
  }
766
+
767
  function snapshotJob() {
768
+ syncTokenPool();
769
  const prompt = $("prompt").value.trim();
770
  if (!prompt) throw new Error("Write a prompt first.");
771
+
772
  const modelOption = $("model").selectedOptions[0];
773
  const [width, height] = $("ratio").value.split("x").map(Number);
774
  const styleName = $("style").value.trim();
775
  const styleText = STYLE_DETAIL[styleName] ?? styleName;
776
+
777
  const parts = [prompt];
778
  if (styleText && normalise(styleName) !== normalise("No extra style")) parts.push(styleText);
779
  if ($("boost").checked) parts.push(PROMPT_BOOST);
780
+
781
  return {
782
  id: ++jobCounter,
783
  repo: modelOption.value,
 
792
  loraScale: 0.95
793
  };
794
  }
795
+
796
  function remoteFileUrl(value) {
797
  if (typeof value !== "string") return null;
798
  const text = value.trim();
799
  if (!text) return null;
800
+
801
  // Already usable in the browser.
802
  if (/^(https?:\/\/|blob:|data:image\/)/i.test(text)) return text;
803
+
804
  // Modern Gradio may return a relative file route.
805
+ if (text.startsWith("/gradio_api/")) return `${activeBackend?.origin || ""}${text}`;
806
+ if (text.startsWith("gradio_api/")) return `${activeBackend?.origin || ""}/${text}`;
807
+
808
  // Some Gradio versions return only the server-side temporary path.
809
  if (
810
  text.startsWith("/tmp/") ||
811
  text.includes("/gradio/") ||
812
  /\.(png|jpe?g|webp|gif|bmp)(\?|$)/i.test(text)
813
  ) {
814
+ return `${activeBackend?.origin || ""}/gradio_api/file=${encodeURI(text)}`;
815
  }
816
+
817
  return null;
818
  }
819
+
820
  function extractImageUrl(value, seen = new Set()) {
821
  if (value == null) return null;
822
+
823
  if (typeof Blob !== "undefined" && value instanceof Blob) {
824
  return URL.createObjectURL(value);
825
  }
826
+
827
  if (typeof File !== "undefined" && value instanceof File) {
828
  return URL.createObjectURL(value);
829
  }
830
+
831
  if (typeof value === "string") {
832
  const trimmed = value.trim();
833
+
834
  // Some component serializers return raw base64 rather than a FileData URL.
835
  if (/^[A-Za-z0-9+/=\s]{1000,}$/.test(trimmed)) {
836
  return `data:image/png;base64,${trimmed.replace(/\s+/g, "")}`;
837
  }
838
+
839
  return remoteFileUrl(trimmed);
840
  }
841
+
842
  if (typeof value !== "object") return null;
843
  if (seen.has(value)) return null;
844
  seen.add(value);
845
+
846
  if (Array.isArray(value)) {
847
  // The first output of /run_lora is the generated image.
848
  for (const item of value) {
 
851
  }
852
  return null;
853
  }
854
+
855
  // Gradio FileData commonly uses url/path/orig_name/name.
856
  for (const key of ["url", "path", "orig_name", "name", "image", "data"]) {
857
  if (Object.prototype.hasOwnProperty.call(value, key)) {
 
859
  if (found) return found;
860
  }
861
  }
862
+
863
  for (const item of Object.values(value)) {
864
  const found = extractImageUrl(item, seen);
865
  if (found) return found;
866
  }
867
+
868
  return null;
869
  }
870
+
871
  function showImage(url, job) {
872
  $("image").src = url;
873
  $("download").href = url;
 
875
  $("download").style.display = "inline-flex";
876
  setViewer("image", `${job.modelLabel} · seed ${job.seed}`);
877
  }
878
+
879
  async function loadSelectedLora(job) {
880
  const { endpoint, spec } = endpointInfo(["/add_custom_lora", "add_custom_lora"]);
881
  const payload = buildPayload(
 
887
  },
888
  [job.repo]
889
  );
890
+
891
  const result = await client.predict(endpoint, payload);
892
  const data = result?.data || [];
893
  const selectedIndex = Number.isFinite(Number(data[4])) ? Number(data[4]) : null;
894
  const trigger = typeof data[5] === "string" && data[5].trim() ? data[5].trim() : job.trigger;
895
  return { selectedIndex, trigger };
896
  }
897
+
898
  async function runGeneration(job, selectedIndex, trigger) {
899
  const { endpoint, spec } = endpointInfo(["/run_lora", "run_lora"]);
900
  const finalPrompt = normalise(job.prompt).includes(normalise(trigger))
901
  ? job.prompt
902
  : `${trigger}, ${job.prompt}`;
903
+
904
  const values = {
905
  prompt: finalPrompt,
906
  prompttext: finalPrompt,
 
921
  lorascale: job.loraScale,
922
  adapterstrength: job.loraScale
923
  };
924
+
925
  /*
926
  The explorer's generation function is a Python generator that emits preview
927
  frames. Using submit() here can leave a Static frontend holding only a
 
932
  finalPrompt, null, 0.75, job.cfg, job.steps, false,
933
  job.seed, job.width, job.height, job.loraScale
934
  ];
935
+
936
  const payload = buildPayload(spec, values, fallbackWithoutState);
937
+
938
  $("queueStatus").textContent = "Waiting for the remote GPU and final image";
939
  const response = await client.predict(endpoint, payload);
940
+
941
  // @gradio/client normally returns { type: "data", data: [...] }.
942
  // Keep compatibility with clients that return the output array directly.
943
  const outputs = response?.data ?? response;
944
  console.log("AIDMA final Gradio outputs:", outputs);
945
+
946
  // /run_lora outputs: [generated image, final seed, progress component].
947
  const imageOutput = Array.isArray(outputs) ? outputs[0] : outputs;
948
  const returnedSeed = Array.isArray(outputs) ? Number(outputs[1]) : NaN;
949
  const finalUrl = extractImageUrl(imageOutput) || extractImageUrl(outputs);
950
+
951
  if (!finalUrl) {
952
  console.error("Unrecognized final Gradio image payload:", outputs);
953
  throw new Error(
 
955
  "It may have rejected this LoRA or changed its API."
956
  );
957
  }
958
+
959
  if (Number.isFinite(returnedSeed)) job.seed = returnedSeed;
960
  showImage(finalUrl, job);
961
  return finalUrl;
962
  }
963
+
964
  async function processQueue() {
965
  if (processing || !localQueue.length) return;
966
  processing = true;
967
  updateQueueUI();
968
+
969
  while (localQueue.length) {
970
  const job = localQueue.shift();
971
  updateQueueUI();
 
974
  $("queueStatus").textContent = "Preparing AIDMA LoRA";
975
  $("status").className = "";
976
  setViewer("loading", `Job #${job.id} is running.`);
977
+
978
  try {
979
+ await generateWithBackendFailover(job);
 
 
 
980
  } catch (error) {
981
  console.error(error);
982
  $("status").className = "error";
 
986
  '<div style="margin-top:8px">' + escapeHtml(error?.message || String(error)) + '</div>';
987
  setViewer("empty");
988
  // Force a clean connection for the next queued job.
989
+ resetBackendConnection();
 
990
  }
991
  }
992
+
993
  processing = false;
994
  updateQueueUI();
995
  }
996
+
997
  function escapeHtml(value) {
998
  return String(value).replace(/[&<>"']/g, char => ({
999
  "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"
1000
  })[char]);
1001
  }
1002
+
1003
  $("generate").addEventListener("click", () => {
1004
  try {
1005
  const job = snapshotJob();
 
1014
  $("prompt").focus();
1015
  }
1016
  });
1017
+
1018
+ $("hfTokens").addEventListener("input", () => {
1019
+ const preview = parseHfTokens($("hfTokens").value);
1020
+ $("tokenChip").textContent = preview.length
1021
+ ? `Tokens entered: ${preview.length}/${MAX_HF_TOKENS}`
1022
+ : "Token: anonymous";
1023
+ });
1024
+
1025
  $("prompt").addEventListener("keydown", event => {
1026
  if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
1027
  event.preventDefault();
1028
  $("generate").click();
1029
  }
1030
  });
1031
+
1032
  updateQueueUI();
1033
  </script>
1034
  </body>
1035
+ </html>