Files changed (1) hide show
  1. index.html +803 -49
index.html CHANGED
@@ -1,10 +1,10 @@
1
  <!doctype html>
2
- <html lang="en">
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width,initial-scale=1" />
6
  <meta name="color-scheme" content="dark" />
7
- <title>AIDMA Studio</title>
8
  <style>
9
  :root{
10
  --bg:#070910;--panel:#111522;--panel2:#0b0e18;--line:rgba(255,255,255,.09);
@@ -20,7 +20,7 @@
20
  radial-gradient(circle at 90% 12%,rgba(76,230,206,.13),transparent 25%),
21
  linear-gradient(180deg,#0b0e18 0%,var(--bg) 62%);
22
  }
23
- .shell{width:min(1160px,calc(100% - 28px));margin:auto;padding:28px 0 45px}
24
  header{display:flex;align-items:center;justify-content:space-between;gap:18px;margin-bottom:22px}
25
  .brand{display:flex;align-items:center;gap:13px}
26
  .logo{
@@ -75,8 +75,100 @@
75
  .queuebox{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
76
  .qchip{font-size:11px;border:1px solid var(--line);border-radius:999px;padding:7px 10px;color:#bcc4d5;background:rgba(255,255,255,.025)}
77
  .error{color:var(--red)!important}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  @media(max-width:900px){.grid{grid-template-columns:1fr}.viewer{min-height:520px}header{align-items:flex-start;flex-direction:column}}
79
- @media(max-width:520px){.shell{width:calc(100% - 18px);padding-top:17px}.panel{border-radius:19px}.controls{padding:16px}.viewer{padding:9px}.row{grid-template-columns:1fr}}
 
 
 
80
  </style>
81
  </head>
82
  <body>
@@ -86,7 +178,7 @@
86
  <div class="logo">A6</div>
87
  <div>
88
  <h1>AIDMA Studio</h1>
89
- <div class="sub">Static HTML interface · AIDMA-only remote generation</div>
90
  </div>
91
  </div>
92
  <div class="tag">Fresh automatic seed every time</div>
@@ -145,15 +237,49 @@
145
  <input id="boost" type="checkbox" checked />
146
  </div>
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  <button id="generate">Add to private browser queue</button>
149
 
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
 
@@ -162,7 +288,7 @@
162
  <div class="empty" id="empty">
163
  <div class="orb">✦</div>
164
  <strong>Your next frame begins here.</strong>
165
- <div style="margin-top:8px">Write a prompt and add it to your queue.</div>
166
  </div>
167
  <div class="loading" id="loading">
168
  <div class="spinner"></div>
@@ -181,19 +307,361 @@
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 +708,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 +768,43 @@ 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,6 +818,7 @@ 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;
@@ -347,30 +832,242 @@ function buildPayload(spec, values, fallback) {
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,25 +1082,24 @@ 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) {
@@ -414,7 +1110,6 @@ function extractImageUrl(value, seen = new Set()) {
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
  }
@@ -424,14 +1119,12 @@ function extractImageUrl(value, seen = new Set()) {
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) {
429
  const found = extractImageUrl(item, seen);
430
  if (found) return found;
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)) {
437
  const found = extractImageUrl(value[key], seen);
@@ -444,6 +1137,7 @@ function extractImageUrl(value, seen = new Set()) {
444
  }
445
  return null;
446
  }
 
447
  function showImage(url, job) {
448
  $("image").src = url;
449
  $("download").href = url;
@@ -451,6 +1145,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(
@@ -468,11 +1163,13 @@ async function loadSelectedLora(job) {
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,27 +1190,24 @@ 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
499
- progress event. predict() waits until the generator completes and returns
500
- the final three outputs: [image, seed, progress].
501
- */
502
  const fallbackWithoutState = [
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,27 +1215,33 @@ 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();
535
  $("download").style.display = "none";
536
  $("loadingTitle").textContent = `Generating job #${job.id}…`;
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";
@@ -550,25 +1250,40 @@ async function processQueue() {
550
  '<div class="orb">!</div><strong>Generation failed.</strong>' +
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();
569
  localQueue.push(job);
570
  $("status").className = "";
571
- $("status").textContent = `Job #${job.id} added to your browser queue.`;
 
 
 
 
 
 
572
  updateQueueUI();
573
  processQueue();
574
  } catch (error) {
@@ -577,13 +1292,52 @@ $("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>
 
1
  <!doctype html>
2
+ <html lang="ar" dir="rtl">
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width,initial-scale=1" />
6
  <meta name="color-scheme" content="dark" />
7
+ <title>AIDMA Studio - مع نظام التوكنز</title>
8
  <style>
9
  :root{
10
  --bg:#070910;--panel:#111522;--panel2:#0b0e18;--line:rgba(255,255,255,.09);
 
20
  radial-gradient(circle at 90% 12%,rgba(76,230,206,.13),transparent 25%),
21
  linear-gradient(180deg,#0b0e18 0%,var(--bg) 62%);
22
  }
23
+ .shell{width:min(1180px,calc(100% - 28px));margin:auto;padding:28px 0 45px}
24
  header{display:flex;align-items:center;justify-content:space-between;gap:18px;margin-bottom:22px}
25
  .brand{display:flex;align-items:center;gap:13px}
26
  .logo{
 
75
  .queuebox{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
76
  .qchip{font-size:11px;border:1px solid var(--line);border-radius:999px;padding:7px 10px;color:#bcc4d5;background:rgba(255,255,255,.025)}
77
  .error{color:var(--red)!important}
78
+
79
+ /* ===== Token System Styles ===== */
80
+ .tokenSection{
81
+ margin:18px 0 20px;
82
+ padding:16px;
83
+ border:1px solid rgba(139,108,255,.18);
84
+ border-radius:18px;
85
+ background: linear-gradient(180deg, rgba(139,108,255,.08), rgba(255,255,255,.02));
86
+ }
87
+ .tokenHeader{
88
+ display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap
89
+ }
90
+ .tokenHeader label{margin:0 !important;font-size:13px !important;letter-spacing:0 !important;text-transform:none !important;color:#d8dcf0 !important}
91
+ .tokenBulkInput{
92
+ min-height:88px !important;
93
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace !important;
94
+ font-size:12px !important;
95
+ direction:ltr;
96
+ }
97
+ .btn-secondary{
98
+ height:42px !important;
99
+ font-size:13px !important;
100
+ font-weight:700 !important;
101
+ background: rgba(255,255,255,.06) !important;
102
+ color: #d6daea !important;
103
+ border:1px solid var(--line) !important;
104
+ box-shadow:none !important;
105
+ }
106
+ .btn-secondary:hover{background:rgba(255,255,255,.09) !important}
107
+ .tokenList{
108
+ display:flex;flex-direction:column;gap:8px;
109
+ max-height:320px;overflow:auto;margin-bottom:12px;
110
+ padding-right:2px;
111
+ }
112
+ .tokenList::-webkit-scrollbar{width:6px}
113
+ .tokenList::-webkit-scrollbar-thumb{background:rgba(255,255,255,.12);border-radius:999px}
114
+ .tokenCard{
115
+ border:1px solid var(--line);
116
+ background: rgba(11,14,24,.85);
117
+ border-radius:14px;
118
+ padding:10px 11px;
119
+ transition:.18s;
120
+ direction:ltr;
121
+ }
122
+ .tokenCard.active{
123
+ border-color: rgba(76,230,206,.55);
124
+ background: rgba(76,230,206,.09);
125
+ box-shadow: 0 0 0 3px rgba(76,230,206,.10);
126
+ }
127
+ .tokenCard.exhausted{
128
+ opacity:.55;
129
+ border-style:dashed;
130
+ }
131
+ .tokenCardTop{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px}
132
+ .tokenMask{font-family:ui-monospace,monospace;font-size:12px;font-weight:700;letter-spacing:.2px}
133
+ .tokenBadge{font-size:10px;padding:4px 8px;border-radius:999px;border:1px solid var(--line);background:rgba(255,255,255,.04);white-space:nowrap}
134
+ .tokenBadge.active{background:var(--mint);color:#06201b;border-color:var(--mint);font-weight:800}
135
+ .tokenBadge.exhausted{background:rgba(255,108,130,.15);color:var(--red);border-color:rgba(255,108,130,.25)}
136
+ .tokenProgress{height:6px;background:rgba(255,255,255,.08);border-radius:999px;overflow:hidden;margin-bottom:7px}
137
+ .tokenProgressFill{height:100%;border-radius:999px;transition: width .5s ease}
138
+ .tokenMeta{font-size:11px;color:var(--muted);display:flex;justify-content:space-between;gap:8px}
139
+ .tokenActions{display:flex;gap:6px;margin-top:8px}
140
+ .miniBtn{
141
+ height:28px !important;font-size:11px !important;font-weight:700 !important;
142
+ border-radius:9px !important;padding:0 10px !important;width:auto !important;flex:1;
143
+ background:rgba(255,255,255,.06) !important;color:#d0d5e6 !important;border:1px solid var(--line) !important;box-shadow:none !important
144
+ }
145
+ .miniBtn.activeBtn{background:var(--mint) !important;color:#06201b !important;border-color:var(--mint) !important}
146
+ .miniBtn.danger:hover{background:rgba(255,108,130,.15) !important;color:var(--red) !important;border-color:rgba(255,108,130,.3) !important}
147
+ .tokenGlobalStats{
148
+ display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:4px
149
+ }
150
+ .statBox{
151
+ border:1px solid var(--line);border-radius:12px;padding:10px;
152
+ background:rgba(255,255,255,.02);text-align:center
153
+ }
154
+ .statVal{font-size:18px;font-weight:900;letter-spacing:-.5px;color:var(--text)}
155
+ .statLabel{font-size:10px;color:var(--muted);margin-top:2px;text-transform:uppercase;letter-spacing:.06em}
156
+ .tokenLive{
157
+ display:flex;align-items:center;gap:8px;
158
+ padding:8px 10px;border-radius:11px;
159
+ background:rgba(76,230,206,.10);border:1px solid rgba(76,230,206,.18);
160
+ font-size:12px;color:#c7f0e8;margin-bottom:10px;
161
+ direction:ltr;
162
+ }
163
+ .liveDot{width:8px;height:8px;border-radius:50%;background:var(--mint);box-shadow:0 0 0 4px rgba(76,230,206,.18);animation:pulse 1.2s infinite}
164
+ @keyframes pulse{0%{box-shadow:0 0 0 4px rgba(76,230,206,.18)}50%{box-shadow:0 0 0 8px rgba(76,230,206,.08)}100%{box-shadow:0 0 0 4px rgba(76,230,206,.18)}}
165
+ .emptyTokens{padding:18px;text-align:center;color:var(--muted);font-size:13px;border:1px dashed var(--line);border-radius:12px;background:rgba(255,255,255,.01)}
166
+
167
  @media(max-width:900px){.grid{grid-template-columns:1fr}.viewer{min-height:520px}header{align-items:flex-start;flex-direction:column}}
168
+ @media(max-width:520px){
169
+ .shell{width:calc(100% - 18px);padding-top:17px}.panel{border-radius:19px}.controls{padding:16px}.viewer{padding:9px}.row{grid-template-columns:1fr}
170
+ .tokenGlobalStats{grid-template-columns:1fr 1fr}
171
+ }
172
  </style>
173
  </head>
174
  <body>
 
178
  <div class="logo">A6</div>
179
  <div>
180
  <h1>AIDMA Studio</h1>
181
+ <div class="sub">Static HTML interface · AIDMA-only remote generation · نظام التوكنز الذكي</div>
182
  </div>
183
  </div>
184
  <div class="tag">Fresh automatic seed every time</div>
 
237
  <input id="boost" type="checkbox" checked />
238
  </div>
239
 
240
+ <!-- ========== نظام التوكنز الجديد ========== -->
241
+ <div class="tokenSection" id="tokenSection">
242
+ <div class="tokenHeader">
243
+ <label>🎟️ إدارة التوكنز - كل توكن = 5 دقائق quota</label>
244
+ <span class="qchip" id="tokenStatsChip">0 توكن</span>
245
+ </div>
246
+
247
+ <div id="tokenLiveIndicator" style="display:none" class="tokenLive">
248
+ <div class="liveDot"></div>
249
+ <span id="tokenLiveText">يتم الاستهلاك من التوكن النشط...</span>
250
+ </div>
251
+
252
+ <div class="field" style="margin-bottom:10px">
253
+ <textarea id="tokenBulkInput" class="tokenBulkInput" placeholder="الصق التوكنات هنا، كل توكن في سطر:&#10;hf_xxxxxxxxxxxxxxxxxxxxxxxx&#10;hf_yyyyyyyyyyyyyyyyyyyyyyyy&#10;hf_zzzzzzzzzzzzzzzzzzzzzzzz&#10;&#10;مثال: لو حطيت 30 توكن = 150 دقيقة"></textarea>
254
+ </div>
255
+
256
+ <div class="row" style="margin-bottom:12px">
257
+ <button type="button" class="btn-secondary" id="addTokensBtn">+ إضافة التوكنز</button>
258
+ <button type="button" class="btn-secondary" id="clearTokensBtn">🗑️ مسح الكل</button>
259
+ </div>
260
+
261
+ <div id="tokenPoolList" class="tokenList"></div>
262
+
263
+ <div id="tokenGlobalStats" class="tokenGlobalStats"></div>
264
+
265
+ <div class="fine" style="margin-top:8px">
266
+ ⚠️ التوكنز تُحفظ محلياً في متصفحك فقط (localStorage) ولا تُرسل إلا إلى HuggingFace مباشرة.<br>
267
+ النظام يبدأ بأول توكن، يستهلك 5 دقائق، ثم ينتقل تلقائياً للتالي وهكذا.<br>
268
+ عند انتهاء quota للتوكن الحالي، يتم التبديل فوراً بدون توقف الطابور.
269
+ </div>
270
+ </div>
271
+ <!-- ========== نهاية نظام التوكنز ========== -->
272
+
273
  <button id="generate">Add to private browser queue</button>
274
 
275
  <div class="queuebox">
276
  <span class="qchip" id="queuedChip">Queued: 0</span>
277
  <span class="qchip" id="activeChip">Active: none</span>
278
+ <span class="qchip" id="engineChip">Engines: 20</span>
279
  </div>
280
 
281
  <div class="fine">
282
+ This Static Space keeps a private FIFO queue in your browser and checks up to 20 AIDMA backend candidates, automatically skipping unavailable or incompatible Spaces. With token pool, each token provides 5 min ZeroGPU and auto-rotates.
283
  </div>
284
  </div>
285
 
 
288
  <div class="empty" id="empty">
289
  <div class="orb">✦</div>
290
  <strong>Your next frame begins here.</strong>
291
+ <div style="margin-top:8px">Write a prompt and add it to your queue. أضف توكناتك أولاً للحصول على quota.</div>
292
  </div>
293
  <div class="loading" id="loading">
294
  <div class="spinner"></div>
 
307
 
308
  <script type="module">
309
  import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/+esm";
310
+
311
+ function spaceOrigin(spaceId) {
312
+ return `https://${spaceId.replace("/", "-").toLowerCase()}.hf.space`;
313
+ }
314
+
315
+ const BACKEND_SPACES = [
316
+ { id: "multimodalart/flux-lora-the-explorer", origin: spaceOrigin("multimodalart/flux-lora-the-explorer"), label: "AIDMA Engine 1" },
317
+ { id: "John6666/flux-lora-the-explorer", origin: spaceOrigin("John6666/flux-lora-the-explorer"), label: "AIDMA Engine 2" },
318
+ { id: "Svngoku/flux-lora-the-explorer", origin: spaceOrigin("Svngoku/flux-lora-the-explorer"), label: "AIDMA Engine 3" },
319
+ { id: "Nymbo/flux-lora-the-explorer", origin: spaceOrigin("Nymbo/flux-lora-the-explorer"), label: "AIDMA Engine 4" },
320
+ { id: "Emuixom/flux-lora-the-explorer", origin: spaceOrigin("Emuixom/flux-lora-the-explorer"), label: "AIDMA Engine 5" },
321
+ { id: "codermert/flux-lora-the-explorer", origin: spaceOrigin("codermert/flux-lora-the-explorer"), label: "AIDMA Engine 6" },
322
+ { id: "reza74ii/flux-lora-the-explorer", origin: spaceOrigin("reza74ii/flux-lora-the-explorer"), label: "AIDMA Engine 7" },
323
+ { id: "x2778/flux-lora-the-explorer-a", origin: spaceOrigin("x2778/flux-lora-the-explorer-a"), label: "AIDMA Engine 8" },
324
+ { id: "Ckjdjdjf/flux-lora-the-explorer", origin: spaceOrigin("Ckjdjdjf/flux-lora-the-explorer"), label: "AIDMA Engine 9" },
325
+ { id: "ZENLLC/flux-lora-the-explorer", origin: spaceOrigin("ZENLLC/flux-lora-the-explorer"), label: "AIDMA Engine 10" },
326
+ { id: "tenet/flux-lora-the-explorer", origin: spaceOrigin("tenet/flux-lora-the-explorer"), label: "AIDMA Engine 11" },
327
+ { id: "seawolf2357/flxloraexp", origin: spaceOrigin("seawolf2357/flxloraexp"), label: "AIDMA Engine 12" },
328
+ { id: "ginipick/flxloraexp", origin: spaceOrigin("ginipick/flxloraexp"), label: "AIDMA Engine 13" },
329
+ { id: "John6666/flux-lora-the-explorer-crash-progress", origin: spaceOrigin("John6666/flux-lora-the-explorer-crash-progress"), label: "AIDMA Engine 14" },
330
+ { id: "killwithabass/flux-gay-lora-explorer", origin: spaceOrigin("killwithabass/flux-gay-lora-explorer"), label: "AIDMA Engine 15" },
331
+ { id: "multimodalart/flux-lora-lab", origin: spaceOrigin("multimodalart/flux-lora-lab"), label: "AIDMA Engine 16" },
332
+ { id: "prithivMLmods/FLUX-LoRA-DLC2", origin: spaceOrigin("prithivMLmods/FLUX-LoRA-DLC2"), label: "AIDMA Engine 17" },
333
+ { id: "enzostvs/lora-studio", origin: spaceOrigin("enzostvs/lora-studio"), label: "AIDMA Engine 18" },
334
+ { id: "ovi054/FLUX.Dev-LoRA", origin: spaceOrigin("ovi054/FLUX.Dev-LoRA"), label: "AIDMA Engine 19" },
335
+ { id: "waloneai/FLUX.Dev-LoRA-Serverless", origin: spaceOrigin("waloneai/FLUX.Dev-LoRA-Serverless"), label: "AIDMA Engine 20" }
336
+ ];
337
+
338
  const $ = id => document.getElementById(id);
339
  const localQueue = [];
340
  let processing = false;
341
  let client = null;
342
  let api = null;
343
+ let activeBackend = null;
344
+ let preferredBackendIndex = 0;
345
  let jobCounter = 0;
346
+ const backendFailures = new Map();
347
+ const BACKEND_FAILURE_COOLDOWN_MS = 10 * 60 * 1000;
348
+ const BACKEND_CONNECT_TIMEOUT_MS = 18 * 1000;
349
+
350
+ // ===================== نظام التوكنز =====================
351
+ const TOKEN_QUOTA_SECONDS = 5 * 60; // كل توكن = 5 دقائق
352
+ const LS_KEY = 'aidma_token_pool_v2';
353
+ let activeGenInterval = null;
354
+
355
+ function maskToken(token){
356
+ if(!token) return 'بدون توكن';
357
+ const t = token.trim();
358
+ if(t.length <= 12) return t.slice(0,4)+'***';
359
+ return t.slice(0,7) + '***' + t.slice(-4);
360
+ }
361
+ function formatTime(sec){
362
+ sec = Math.max(0, Math.round(sec));
363
+ const m = Math.floor(sec/60);
364
+ const s = sec%60;
365
+ return `${m}:${String(s).padStart(2,'0')}`;
366
+ }
367
+ function formatTimeArabic(sec){
368
+ sec = Math.max(0, Math.round(sec));
369
+ const m = Math.floor(sec/60);
370
+ const s = sec%60;
371
+ if(m===0) return `${s} ثانية`;
372
+ if(s===0) return `${m} دقيقة`;
373
+ return `${m} دقيقة و ${s} ثانية`;
374
+ }
375
+
376
+ const tokenManager = {
377
+ tokens: [],
378
+ activeId: null,
379
+
380
+ load(){
381
+ try{
382
+ const raw = localStorage.getItem(LS_KEY);
383
+ if(raw){
384
+ const parsed = JSON.parse(raw);
385
+ this.tokens = (parsed.tokens||[]).map(t=>({
386
+ id: t.id || Math.random().toString(36).slice(2),
387
+ value: t.value,
388
+ total: TOKEN_QUOTA_SECONDS,
389
+ remaining: typeof t.remaining === 'number' ? Math.max(0, Math.min(TOKEN_QUOTA_SECONDS, t.remaining)) : TOKEN_QUOTA_SECONDS,
390
+ used: typeof t.used === 'number' ? t.used : 0,
391
+ status: t.status || 'ready',
392
+ addedAt: t.addedAt || Date.now(),
393
+ }));
394
+ this.activeId = parsed.activeId || null;
395
+ }
396
+ }catch(e){ console.warn('load tokens failed', e); this.tokens=[];}
397
+
398
+ // تنظيف وتصحيح
399
+ this.tokens = this.tokens.filter(t=>t.value && t.value.trim().length>10);
400
+ // إذا لا يوجد active أو الحالي منتهي، اختر أول جاهز
401
+ const activeExists = this.tokens.find(t=>t.id===this.activeId && t.remaining>0);
402
+ if(!activeExists && this.tokens.length){
403
+ const firstReady = this.tokens.find(t=>t.remaining>0);
404
+ if(firstReady){
405
+ this.activeId = firstReady.id;
406
+ firstReady.status = 'active';
407
+ }
408
+ }
409
+ // تأكد من حالات
410
+ this.tokens.forEach(t=>{
411
+ if(t.remaining<=0){ t.status='exhausted'; t.remaining=0; }
412
+ else if(t.id===this.activeId){ t.status='active'; }
413
+ else if(t.status==='active'){ t.status='ready'; }
414
+ });
415
+ },
416
+ save(){
417
+ try{
418
+ localStorage.setItem(LS_KEY, JSON.stringify({tokens:this.tokens, activeId:this.activeId}));
419
+ }catch(e){ console.warn('save failed', e); }
420
+ },
421
+ parseBulk(text){
422
+ if(!text) return [];
423
+ return text.split(/[\n, \t]+/)
424
+ .map(s=>s.trim())
425
+ .filter(s=>s.length>10)
426
+ .filter(s=>s.startsWith('hf_') || s.length>20) // قبول hf_ أو توكن طويل
427
+ .map(s=>s.replace(/['\"]/g,'').trim())
428
+ .filter((v,i,a)=>a.indexOf(v)===i); // unique
429
+ },
430
+ addBulk(rawText){
431
+ const list = this.parseBulk(rawText);
432
+ if(!list.length) return {added:0, skipped:0};
433
+ let added=0, skipped=0;
434
+ const existingValues = new Set(this.tokens.map(t=>t.value));
435
+ for(const val of list){
436
+ if(existingValues.has(val)){ skipped++; continue; }
437
+ const tok = {
438
+ id: (Date.now()+Math.random()).toString(36).slice(2) + added,
439
+ value: val,
440
+ total: TOKEN_QUOTA_SECONDS,
441
+ remaining: TOKEN_QUOTA_SECONDS,
442
+ used: 0,
443
+ status: 'ready',
444
+ addedAt: Date.now()
445
+ };
446
+ this.tokens.push(tok);
447
+ existingValues.add(val);
448
+ added++;
449
+ }
450
+ if(this.tokens.length && !this.activeId){
451
+ const first = this.tokens.find(t=>t.remaining>0);
452
+ if(first){ this.activeId = first.id; first.status='active'; }
453
+ }
454
+ this.save();
455
+ return {added, skipped, total:list.length};
456
+ },
457
+ remove(id){
458
+ const idx = this.tokens.findIndex(t=>t.id===id);
459
+ if(idx>=0){
460
+ this.tokens.splice(idx,1);
461
+ if(this.activeId===id){
462
+ const next = this.tokens.find(t=>t.remaining>0);
463
+ this.activeId = next ? next.id : null;
464
+ if(next) next.status='active';
465
+ }
466
+ this.save();
467
+ }
468
+ },
469
+ clear(){
470
+ this.tokens = [];
471
+ this.activeId = null;
472
+ this.save();
473
+ },
474
+ getActive(){
475
+ if(this.activeId){
476
+ const t = this.tokens.find(x=>x.id===this.activeId);
477
+ if(t && t.remaining>0) return t;
478
+ }
479
+ return this.tokens.find(t=>t.remaining>0) || null;
480
+ },
481
+ setActive(id){
482
+ const tok = this.tokens.find(t=>t.id===id);
483
+ if(!tok || tok.remaining<=0) return false;
484
+ this.tokens.forEach(t=>{ if(t.status==='active') t.status='ready'; });
485
+ tok.status='active';
486
+ this.activeId = id;
487
+ this.save();
488
+ return true;
489
+ },
490
+ exhaust(id, reason=''){
491
+ const t = this.tokens.find(x=>x.id===id);
492
+ if(!t) return;
493
+ t.remaining = 0;
494
+ t.used = t.total;
495
+ t.status = 'exhausted';
496
+ t.exhaustedReason = reason;
497
+ t.exhaustedAt = Date.now();
498
+ if(this.activeId===id){
499
+ const next = this.tokens.find(x=>x.remaining>0 && x.id!==id);
500
+ this.activeId = next ? next.id : null;
501
+ if(next) next.status='active';
502
+ }
503
+ this.save();
504
+ },
505
+ getStats(){
506
+ const total = this.tokens.length;
507
+ const ready = this.tokens.filter(t=>t.remaining>0).length;
508
+ const exhausted = total - ready;
509
+ const totalRemaining = this.tokens.reduce((s,t)=>s+t.remaining,0);
510
+ const totalUsed = this.tokens.reduce((s,t)=>s+t.used,0);
511
+ const totalQuota = total * TOKEN_QUOTA_SECONDS;
512
+ const active = this.getActive();
513
+ return {total, ready, exhausted, totalRemaining, totalUsed, totalQuota, active};
514
+ }
515
+ };
516
+
517
+ function renderTokens(){
518
+ const listEl = $("tokenPoolList");
519
+ const chip = $("tokenStatsChip");
520
+ const statsEl = $("tokenGlobalStats");
521
+ const stats = tokenManager.getStats();
522
+
523
+ // Chip
524
+ if(stats.total===0){
525
+ chip.textContent = '0 توكن';
526
+ } else {
527
+ chip.textContent = `${stats.total} توكن | ${stats.ready} جاهز | ${formatTime(stats.totalRemaining)} متبقي`;
528
+ }
529
+
530
+ // List
531
+ if(stats.total===0){
532
+ listEl.innerHTML = `<div class="emptyTokens">🔑 لا يوجد توكنز<br><span style="font-size:11px">الصق 30 توكن مثلاً = 150 دقيقة quota متواصلة</span></div>`;
533
+ } else {
534
+ listEl.innerHTML = tokenManager.tokens.map(t=>{
535
+ const isActive = t.id===tokenManager.activeId && t.remaining>0;
536
+ const pct = t.total>0 ? (t.remaining / t.total * 100) : 0;
537
+ const pctUsed = 100-pct;
538
+ let color = 'linear-gradient(90deg,var(--mint),var(--purple))';
539
+ if(t.remaining<=0) color = '#ff6c82';
540
+ else if(pct<25) color = '#ffbe5c';
541
+ else if(isActive) color = 'linear-gradient(90deg,var(--mint),#8bffde)';
542
+ const statusText = t.status==='exhausted' ? 'منتهي' : (isActive ? 'نشط الآن' : 'جاهز');
543
+ const badgeClass = t.status==='exhausted' ? 'exhausted' : (isActive ? 'active' : '');
544
+ return `
545
+ <div class="tokenCard ${t.status} ${isActive?'active':''}">
546
+ <div class="tokenCardTop">
547
+ <span class="tokenMask">${maskToken(t.value)}</span>
548
+ <span class="tokenBadge ${badgeClass}">${statusText}</span>
549
+ </div>
550
+ <div class="tokenProgress"><div class="tokenProgressFill" style="width:${pct}%;background:${color}"></div></div>
551
+ <div class="tokenMeta">
552
+ <span>⏳ متبقي: ${formatTime(t.remaining)} / ${formatTime(t.total)}</span>
553
+ <span>🔥 مستهلك: ${formatTime(t.used)}</span>
554
+ </div>
555
+ <div class="tokenActions">
556
+ <button class="miniBtn ${isActive?'activeBtn':''}" data-action="activate" data-id="${t.id}" ${t.remaining<=0?'disabled':''}>${isActive?'✓ نشط':'تفعيل'}</button>
557
+ <button class="miniBtn danger" data-action="remove" data-id="${t.id}">حذف</button>
558
+ </div>
559
+ </div>
560
+ `;
561
+ }).join('');
562
+
563
+ // attach listeners via delegation
564
+ listEl.querySelectorAll('button[data-action]').forEach(btn=>{
565
+ btn.addEventListener('click', ()=>{
566
+ const id = btn.getAttribute('data-id');
567
+ const action = btn.getAttribute('data-action');
568
+ if(action==='activate'){
569
+ tokenManager.setActive(id);
570
+ renderTokens();
571
+ $("status").textContent = `تم تفعيل التوكن ${maskToken(tokenManager.tokens.find(x=>x.id===id).value)}`;
572
+ } else if(action==='remove'){
573
+ if(confirm('حذف هذا التوكن؟')){ tokenManager.remove(id); renderTokens(); }
574
+ }
575
+ });
576
+ });
577
+ }
578
+
579
+ // Global stats boxes
580
+ if(stats.total>0){
581
+ statsEl.innerHTML = `
582
+ <div class="statBox">
583
+ <div class="statVal">${stats.total}</div>
584
+ <div class="statLabel">إجمالي التوكنز</div>
585
+ </div>
586
+ <div class="statBox">
587
+ <div class="statVal" style="color:var(--mint)">${formatTime(stats.totalRemaining)}</div>
588
+ <div class="statLabel">الوقت المتبقي الكلي (${Math.floor(stats.totalRemaining/60)} دقيقة)</div>
589
+ </div>
590
+ <div class="statBox">
591
+ <div class="statVal">${stats.ready}</div>
592
+ <div class="statLabel">جاهز للاستخدام</div>
593
+ </div>
594
+ <div class="statBox">
595
+ <div class="statVal" style="color:${stats.exhausted>0?'var(--red)':'var(--muted)'}">${stats.exhausted}</div>
596
+ <div class="statLabel">منتهي</div>
597
+ </div>
598
+ `;
599
+ statsEl.style.display = 'grid';
600
+ } else {
601
+ statsEl.innerHTML = '';
602
+ statsEl.style.display = 'none';
603
+ }
604
+
605
+ // update live indicator
606
+ updateLiveIndicator();
607
+ }
608
+
609
+ function updateLiveIndicator(){
610
+ const ind = $("tokenLiveIndicator");
611
+ const txt = $("tokenLiveText");
612
+ const active = tokenManager.getActive();
613
+ if(processing && active && activeGenInterval){
614
+ ind.style.display='flex';
615
+ txt.textContent = `🔴 يستهلك من ${maskToken(active.value)} - متبقي ${formatTime(active.remaining)} - إجمالي متبقي ${formatTime(tokenManager.getStats().totalRemaining)}`;
616
+ } else {
617
+ ind.style.display='none';
618
+ }
619
+ }
620
+
621
+ function startTokenCountdown(tokenId){
622
+ stopTokenCountdown();
623
+ const start = Date.now();
624
+ activeGenInterval = setInterval(()=>{
625
+ const tok = tokenManager.tokens.find(t=>t.id===tokenId);
626
+ if(!tok || tok.remaining<=0){
627
+ stopTokenCountdown();
628
+ renderTokens();
629
+ return;
630
+ }
631
+ // نقص ثانية كل ثانية
632
+ tok.remaining = Math.max(0, tok.remaining - 1);
633
+ tok.used = tok.total - tok.remaining;
634
+ if(tok.remaining<=0){
635
+ tokenManager.exhaust(tokenId, 'انتهى الوقت المحلي - 5 دقائق');
636
+ $("queueStatus").textContent = `التوكن ${maskToken(tok.value)} انتهى (5 دقائق). سيتم التبديل تلقائيا...`;
637
+ }
638
+ tokenManager.save();
639
+ // تحديث سريع بدون إعادة رسم كامل كل ثانية لتقليل التقطيع؟ بس نعمل render خفيف
640
+ const stats = tokenManager.getStats();
641
+ $("tokenStatsChip").textContent = `${stats.total} توكن | ${stats.ready} جاهز | ${formatTime(stats.totalRemaining)} متبقي`;
642
+ // تحديث البطاقة النشطة فقط
643
+ const activeCard = document.querySelector(`.tokenCard.active .tokenMeta`);
644
+ if(activeCard){
645
+ activeCard.innerHTML = `<span>⏳ متبقي: ${formatTime(tok.remaining)} / ${formatTime(tok.total)}</span><span>🔥 مستهلك: ${formatTime(tok.used)}</span>`;
646
+ const fill = document.querySelector(`.tokenCard.active .tokenProgressFill`);
647
+ if(fill) fill.style.width = `${(tok.remaining/tok.total*100)}%`;
648
+ }
649
+ updateLiveIndicator();
650
+ // تحديث global remaining
651
+ const remainEl = document.querySelector('#tokenGlobalStats .statBox:nth-child(2) .statVal');
652
+ if(remainEl) remainEl.textContent = formatTime(stats.totalRemaining);
653
+ }, 1000);
654
+ }
655
+
656
+ function stopTokenCountdown(){
657
+ if(activeGenInterval){
658
+ clearInterval(activeGenInterval);
659
+ activeGenInterval = null;
660
+ }
661
+ }
662
+
663
+ // ===================== نهاية نظام التوكنز =====================
664
+
665
  const STYLES = [
666
  "No extra style","Photorealistic","Ultra realistic photography","Cinematic",
667
  "Hollywood blockbuster","IMAX film still","35mm analog film","70mm epic cinema",
 
708
  "Clean commercial","Hyper-detailed","Ethereal","Whimsical","Moody","Haunting",
709
  "Cozy","Playful","Epic"
710
  ];
711
+
712
  const STYLE_DETAIL = {
713
  "No extra style":"",
714
  "Photorealistic":"photorealistic, realistic materials, physically accurate lighting, natural texture",
 
768
  "Cozy":"cozy atmosphere, warm practical light, tactile materials, inviting detail",
769
  "Epic":"epic scale, heroic composition, monumental depth, dramatic visual impact"
770
  };
771
+
772
  const PROMPT_BOOST =
773
  "masterful composition, coherent anatomy, accurate hands and facial structure, " +
774
  "clear subject separation, refined color harmony, realistic material response, " +
775
  "controlled detail, intentional lighting, professional visual storytelling";
776
+
777
  for (const style of STYLES) {
778
  const option = document.createElement("option");
779
  option.value = style;
780
  $("styles").appendChild(option);
781
  }
782
+
783
  function normalise(value) {
784
  return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
785
  }
786
+
787
  function updateQueueUI() {
788
  $("queuedChip").textContent = `Queued: ${localQueue.length}`;
789
+ if (!processing) {
790
+ const active = tokenManager.getActive();
791
+ if(active) $("activeChip").textContent = `Active Token: ${maskToken(active.value)} (${formatTime(active.remaining)})`;
792
+ else $("activeChip").textContent = tokenManager.tokens.length? "Active: كل التوكنز منتهية" : "Active: none";
793
+ return;
794
+ }
795
+ const engine = activeBackend?.label || "connecting";
796
+ const activeTok = tokenManager.getActive();
797
+ if(activeTok) $("activeChip").textContent = `${engine} | ${maskToken(activeTok.value)}`;
798
+ else $("activeChip").textContent = `Active: ${engine}`;
799
  }
800
+
801
  function setViewer(mode, message = "") {
802
  $("empty").style.display = mode === "empty" ? "block" : "none";
803
  $("loading").style.display = mode === "loading" ? "block" : "none";
804
  $("image").style.display = mode === "image" ? "block" : "none";
805
  if (message) $("status").textContent = message;
806
  }
807
+
808
  function endpointInfo(names) {
809
  const named = api?.named_endpoints || {};
810
  const entries = Object.entries(named);
 
818
  }
819
  return { endpoint: names[0], spec: null };
820
  }
821
+
822
  function buildPayload(spec, values, fallback) {
823
  const params = spec?.parameters;
824
  if (!Array.isArray(params) || !params.length) return fallback;
 
832
  return null;
833
  });
834
  }
835
+
836
+ function withTimeout(promise, milliseconds, message) {
837
+ let timer;
838
+ const timeout = new Promise((_, reject) => {
839
+ timer = setTimeout(() => reject(new Error(message)), milliseconds);
840
+ });
841
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
842
+ }
843
+
844
+ function backendIsCoolingDown(spaceId) {
845
+ const failedAt = backendFailures.get(spaceId);
846
+ if (!failedAt) return false;
847
+ if (Date.now() - failedAt >= BACKEND_FAILURE_COOLDOWN_MS) {
848
+ backendFailures.delete(spaceId);
849
+ return false;
850
+ }
851
+ return true;
852
+ }
853
+
854
+ function markBackendFailed(spaceId) {
855
+ backendFailures.set(spaceId, Date.now());
856
+ }
857
+
858
+ function backendSupportsAidmaApi(apiDescription) {
859
+ const named = apiDescription?.named_endpoints || {};
860
+ const names = Object.keys(named).map(normalise);
861
+ const hasAdd = names.some(name => name.includes(normalise("add_custom_lora")));
862
+ const hasRun = names.some(name => name.includes(normalise("run_lora")));
863
+ return hasAdd && hasRun;
864
+ }
865
+
866
+ function resetBackendConnection() {
867
+ client = null;
868
+ api = null;
869
+ activeBackend = null;
870
+ updateQueueUI();
871
+ }
872
+
873
+ async function connectBackend(index, hfToken) {
874
+ const backend = BACKEND_SPACES[index];
875
+ if (!backend) throw new Error("Unknown backend index.");
876
+
877
+ // إذا لدينا نفس الباكند ونفس التوكن، أعد استخدام العميل
878
+ if (client && activeBackend?.id === backend.id && activeBackend?.token === (hfToken||null)) return client;
879
+
880
+ resetBackendConnection();
881
+ activeBackend = {...backend, token: hfToken||null};
882
+ updateQueueUI();
883
+
884
+ $("loadingTitle").textContent = `Connecting to ${backend.label}…`;
885
+ const tokenInfo = hfToken ? `باستخدام ${maskToken(hfToken)}` : 'بدون توكن';
886
+ $("queueStatus").textContent = `${backend.id} | ${tokenInfo}`;
887
+
888
+ const connectOptions = {
889
  events: ["data", "status"],
890
  space_status: status => {
891
  if (status?.status && status.status !== "running") {
892
+ $("queueStatus").textContent = `${backend.label}: ${status.status} | ${tokenInfo}`;
893
  }
894
  }
895
+ };
896
+ if(hfToken){
897
+ connectOptions.hf_token = hfToken;
898
+ }
899
+
900
+ const newClient = await withTimeout(
901
+ Client.connect(backend.id, connectOptions),
902
+ BACKEND_CONNECT_TIMEOUT_MS,
903
+ `${backend.label} connection timed out`
904
+ );
905
+
906
+ const newApi = await withTimeout(
907
+ newClient.view_api(),
908
+ BACKEND_CONNECT_TIMEOUT_MS,
909
+ `${backend.label} API check timed out`
910
+ );
911
+
912
+ if (!backendSupportsAidmaApi(newApi)) {
913
+ throw new Error(`${backend.label} is not compatible with the required AIDMA API`);
914
+ }
915
+
916
+ client = newClient;
917
+ api = newApi;
918
  return client;
919
  }
920
+
921
+ function errorText(error) {
922
+ return String(error?.message || error || "").replace(/\s+/g, " ").trim();
923
+ }
924
+
925
+ function isCallerZeroGpuQuotaError(error) {
926
+ const text = errorText(error).toLowerCase();
927
+ return (
928
+ text.includes("exceeded your free zerogpu quota") ||
929
+ text.includes("zerogpu quota") && text.includes("left") ||
930
+ text.includes("daily gpu quota") ||
931
+ text.includes("try again in") && text.includes("quota") ||
932
+ text.includes("quota exceeded") ||
933
+ text.includes("no gpu") && text.includes("quota")
934
+ );
935
+ }
936
+
937
+ async function generateWithBackendFailover(job, hfToken) {
938
+ const errors = [];
939
+
940
+ for (let offset = 0; offset < BACKEND_SPACES.length; offset++) {
941
+ const index = (preferredBackendIndex + offset) % BACKEND_SPACES.length;
942
+ const backend = BACKEND_SPACES[index];
943
+
944
+ if (backendIsCoolingDown(backend.id)) {
945
+ continue;
946
+ }
947
+
948
+ try {
949
+ await connectBackend(index, hfToken);
950
+ $("queueStatus").textContent = `${backend.label}: loading AIDMA LoRA | Token ${hfToken?maskToken(hfToken):'anon'}`;
951
+ const loaded = await loadSelectedLora(job);
952
+
953
+ $("queueStatus").textContent =
954
+ `${backend.label}: waiting for remote GPU and final image | ${hfToken?maskToken(hfToken):''}`;
955
+ await runGeneration(job, loaded.selectedIndex, loaded.trigger);
956
+
957
+ preferredBackendIndex = index;
958
+ backendFailures.delete(backend.id);
959
+ return;
960
+ } catch (error) {
961
+ const message = errorText(error);
962
+ console.error(`${backend.label} failed:`, error);
963
+
964
+ if (isCallerZeroGpuQuotaError(error)) {
965
+ resetBackendConnection();
966
+ throw new Error(message);
967
+ }
968
+
969
+ errors.push(`${backend.label}: ${message || "unknown error"}`);
970
+ markBackendFailed(backend.id);
971
+ resetBackendConnection();
972
+
973
+ if (offset + 1 < BACKEND_SPACES.length) {
974
+ let nextBackend = null;
975
+ for (let ahead = offset + 1; ahead < BACKEND_SPACES.length; ahead++) {
976
+ const nextIndex = (preferredBackendIndex + ahead) % BACKEND_SPACES.length;
977
+ const candidate = BACKEND_SPACES[nextIndex];
978
+ if (!backendIsCoolingDown(candidate.id)) {
979
+ nextBackend = candidate;
980
+ break;
981
+ }
982
+ }
983
+ const tokenInfo = hfToken? maskToken(hfToken): '';
984
+ $("queueStatus").textContent = nextBackend
985
+ ? `${backend.label} failed. Switching to ${nextBackend.label}… ${tokenInfo}`
986
+ : `${backend.label} failed. No healthy backend remains.`;
987
+ }
988
+ }
989
+ }
990
+
991
+ throw new Error(
992
+ "All configured AIDMA Spaces failed. " + errors.join(" | ")
993
+ );
994
+ }
995
+
996
+ async function attemptJobWithTokenRotation(job){
997
+ while(true){
998
+ const activeToken = tokenManager.getActive();
999
+ const hasPool = tokenManager.tokens.length>0;
1000
+
1001
+ if(hasPool && !activeToken){
1002
+ throw new Error("انتهت جميع التوكنات! كل توكن استهلك 5 دقائق. أضف توكنات جديدة للمتابعة. (لو حطيت 30 توكن = 150 دقيقة كانت متاحة)");
1003
+ }
1004
+
1005
+ const tokenValue = activeToken ? activeToken.value : null;
1006
+ const tokenId = activeToken ? activeToken.id : null;
1007
+
1008
+ if(tokenId){
1009
+ tokenManager.setActive(tokenId);
1010
+ renderTokens();
1011
+ $("activeChip").textContent = `Token: ${maskToken(activeToken.value)} | ${formatTime(activeToken.remaining)} left`;
1012
+ }
1013
+
1014
+ if(tokenId) startTokenCountdown(tokenId);
1015
+
1016
+ try{
1017
+ const jobStart = Date.now();
1018
+ await generateWithBackendFailover(job, tokenValue);
1019
+ stopTokenCountdown();
1020
+ // إذا كان هناك عداد زمني شغال، فاستهلاكه تم بالفعل عبر الانترفال
1021
+ // لكن لتكون دقيقه نحسب ما تبقى من الانترفال ونزامن
1022
+ renderTokens();
1023
+ const t = tokenId ? tokenManager.tokens.find(x=>x.id===tokenId) : null;
1024
+ if(t){
1025
+ if(t.remaining <= 15 && tokenManager.tokens.filter(x=>x.remaining>0).length>1){
1026
+ const next = tokenManager.tokens.find(x=>x.remaining>0 && x.id!==tokenId);
1027
+ if(next){
1028
+ $("status").textContent = `التوكن ${maskToken(t.value)} أوشك على الانتهاء (${formatTime(t.remaining)} متبقي). التالي سيكون ${maskToken(next.value)} تلقائياً.`;
1029
+ }
1030
+ }
1031
+ if(t.remaining<=0){
1032
+ const next = tokenManager.tokens.find(x=>x.remaining>0);
1033
+ if(next){
1034
+ $("queueStatus").textContent = `انتهى ${maskToken(t.value)} (5 دقائق). الانتقال تلقائياً إلى ${maskToken(next.value)}`;
1035
+ }
1036
+ }
1037
+ }
1038
+ return;
1039
+ }catch(err){
1040
+ stopTokenCountdown();
1041
+ const isQuota = isCallerZeroGpuQuotaError(err);
1042
+ if(isQuota && tokenId){
1043
+ console.warn(`Token exhausted: ${maskToken(tokenManager.tokens.find(x=>x.id===tokenId)?.value)}`);
1044
+ tokenManager.exhaust(tokenId, err.message);
1045
+ renderTokens();
1046
+ const stats = tokenManager.getStats();
1047
+ $("queueStatus").textContent = `🚨 التوكن ${maskToken(activeToken.value)} انتهت حصته (5 دقائق). متبقي ${stats.ready} توكن و ${formatTime(stats.totalRemaining)} - جاري التبديل...`;
1048
+ // انتظر قليلا ثم حاول بالتوكن التالي لنفس الوظيفة
1049
+ await new Promise(r=>setTimeout(r,1200));
1050
+ continue;
1051
+ }else{
1052
+ throw err;
1053
+ }
1054
+ }
1055
+ }
1056
+ }
1057
+
1058
  function snapshotJob() {
1059
  const prompt = $("prompt").value.trim();
1060
  if (!prompt) throw new Error("Write a prompt first.");
1061
+
1062
  const modelOption = $("model").selectedOptions[0];
1063
  const [width, height] = $("ratio").value.split("x").map(Number);
1064
  const styleName = $("style").value.trim();
1065
  const styleText = STYLE_DETAIL[styleName] ?? styleName;
1066
+
1067
  const parts = [prompt];
1068
  if (styleText && normalise(styleName) !== normalise("No extra style")) parts.push(styleText);
1069
  if ($("boost").checked) parts.push(PROMPT_BOOST);
1070
+
1071
  return {
1072
  id: ++jobCounter,
1073
  repo: modelOption.value,
 
1082
  loraScale: 0.95
1083
  };
1084
  }
1085
+
1086
  function remoteFileUrl(value) {
1087
  if (typeof value !== "string") return null;
1088
  const text = value.trim();
1089
  if (!text) return null;
 
1090
  if (/^(https?:\/\/|blob:|data:image\/)/i.test(text)) return text;
1091
+ if (text.startsWith("/gradio_api/")) return `${activeBackend?.origin || ""}${text}`;
1092
+ if (text.startsWith("gradio_api/")) return `${activeBackend?.origin || ""}/${text}`;
 
 
1093
  if (
1094
  text.startsWith("/tmp/") ||
1095
  text.includes("/gradio/") ||
1096
  /\.(png|jpe?g|webp|gif|bmp)(\?|$)/i.test(text)
1097
  ) {
1098
+ return `${activeBackend?.origin || ""}/gradio_api/file=${encodeURI(text)}`;
1099
  }
1100
  return null;
1101
  }
1102
+
1103
  function extractImageUrl(value, seen = new Set()) {
1104
  if (value == null) return null;
1105
  if (typeof Blob !== "undefined" && value instanceof Blob) {
 
1110
  }
1111
  if (typeof value === "string") {
1112
  const trimmed = value.trim();
 
1113
  if (/^[A-Za-z0-9+/=\s]{1000,}$/.test(trimmed)) {
1114
  return `data:image/png;base64,${trimmed.replace(/\s+/g, "")}`;
1115
  }
 
1119
  if (seen.has(value)) return null;
1120
  seen.add(value);
1121
  if (Array.isArray(value)) {
 
1122
  for (const item of value) {
1123
  const found = extractImageUrl(item, seen);
1124
  if (found) return found;
1125
  }
1126
  return null;
1127
  }
 
1128
  for (const key of ["url", "path", "orig_name", "name", "image", "data"]) {
1129
  if (Object.prototype.hasOwnProperty.call(value, key)) {
1130
  const found = extractImageUrl(value[key], seen);
 
1137
  }
1138
  return null;
1139
  }
1140
+
1141
  function showImage(url, job) {
1142
  $("image").src = url;
1143
  $("download").href = url;
 
1145
  $("download").style.display = "inline-flex";
1146
  setViewer("image", `${job.modelLabel} · seed ${job.seed}`);
1147
  }
1148
+
1149
  async function loadSelectedLora(job) {
1150
  const { endpoint, spec } = endpointInfo(["/add_custom_lora", "add_custom_lora"]);
1151
  const payload = buildPayload(
 
1163
  const trigger = typeof data[5] === "string" && data[5].trim() ? data[5].trim() : job.trigger;
1164
  return { selectedIndex, trigger };
1165
  }
1166
+
1167
  async function runGeneration(job, selectedIndex, trigger) {
1168
  const { endpoint, spec } = endpointInfo(["/run_lora", "run_lora"]);
1169
  const finalPrompt = normalise(job.prompt).includes(normalise(trigger))
1170
  ? job.prompt
1171
  : `${trigger}, ${job.prompt}`;
1172
+
1173
  const values = {
1174
  prompt: finalPrompt,
1175
  prompttext: finalPrompt,
 
1190
  lorascale: job.loraScale,
1191
  adapterstrength: job.loraScale
1192
  };
1193
+
 
 
 
 
 
1194
  const fallbackWithoutState = [
1195
  finalPrompt, null, 0.75, job.cfg, job.steps, false,
1196
  job.seed, job.width, job.height, job.loraScale
1197
  ];
1198
+
1199
  const payload = buildPayload(spec, values, fallbackWithoutState);
1200
+
1201
  $("queueStatus").textContent = "Waiting for the remote GPU and final image";
1202
  const response = await client.predict(endpoint, payload);
1203
+
 
1204
  const outputs = response?.data ?? response;
1205
  console.log("AIDMA final Gradio outputs:", outputs);
1206
+
1207
  const imageOutput = Array.isArray(outputs) ? outputs[0] : outputs;
1208
  const returnedSeed = Array.isArray(outputs) ? Number(outputs[1]) : NaN;
1209
  const finalUrl = extractImageUrl(imageOutput) || extractImageUrl(outputs);
1210
+
1211
  if (!finalUrl) {
1212
  console.error("Unrecognized final Gradio image payload:", outputs);
1213
  throw new Error(
 
1215
  "It may have rejected this LoRA or changed its API."
1216
  );
1217
  }
1218
+
1219
  if (Number.isFinite(returnedSeed)) job.seed = returnedSeed;
1220
  showImage(finalUrl, job);
1221
  return finalUrl;
1222
  }
1223
+
1224
  async function processQueue() {
1225
  if (processing || !localQueue.length) return;
1226
  processing = true;
1227
  updateQueueUI();
1228
+
1229
  while (localQueue.length) {
1230
  const job = localQueue.shift();
1231
  updateQueueUI();
1232
  $("download").style.display = "none";
1233
  $("loadingTitle").textContent = `Generating job #${job.id}…`;
1234
+ const activeTok = tokenManager.getActive();
1235
+ if(activeTok){
1236
+ $("queueStatus").textContent = `Preparing AIDMA LoRA | Token ${maskToken(activeTok.value)} - ${formatTimeArabic(activeTok.remaining)} متبقي`;
1237
+ } else {
1238
+ $("queueStatus").textContent = "Preparing AIDMA LoRA";
1239
+ }
1240
  $("status").className = "";
1241
  setViewer("loading", `Job #${job.id} is running.`);
1242
+
1243
  try {
1244
+ await attemptJobWithTokenRotation(job);
 
 
 
1245
  } catch (error) {
1246
  console.error(error);
1247
  $("status").className = "error";
 
1250
  '<div class="orb">!</div><strong>Generation failed.</strong>' +
1251
  '<div style="margin-top:8px">' + escapeHtml(error?.message || String(error)) + '</div>';
1252
  setViewer("empty");
1253
+ resetBackendConnection();
1254
+ stopTokenCountdown();
1255
+ } finally {
1256
+ renderTokens();
1257
  }
1258
  }
1259
+
1260
  processing = false;
1261
  updateQueueUI();
1262
+ updateLiveIndicator();
1263
  }
1264
+
1265
  function escapeHtml(value) {
1266
  return String(value).replace(/[&<>"']/g, char => ({
1267
  "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"
1268
  })[char]);
1269
  }
1270
+
1271
+ // ===== Events =====
1272
  $("generate").addEventListener("click", () => {
1273
  try {
1274
+ if(tokenManager.tokens.length>0 && !tokenManager.getActive()){
1275
+ throw new Error("انتهت جميع التوكنات (5 دقائق لكل توكن). أضف توكنات جديدة.");
1276
+ }
1277
  const job = snapshotJob();
1278
  localQueue.push(job);
1279
  $("status").className = "";
1280
+ const activeTok = tokenManager.getActive();
1281
+ if(activeTok){
1282
+ const stats = tokenManager.getStats();
1283
+ $("status").textContent = `Job #${job.id} أضيف للطابور. سيتم استخدام ${maskToken(activeTok.value)} - متبقي كلي ${formatTime(stats.totalRemaining)}`;
1284
+ } else {
1285
+ $("status").textContent = `Job #${job.id} added to your browser queue.`;
1286
+ }
1287
  updateQueueUI();
1288
  processQueue();
1289
  } catch (error) {
 
1292
  $("prompt").focus();
1293
  }
1294
  });
1295
+
1296
  $("prompt").addEventListener("keydown", event => {
1297
  if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
1298
  event.preventDefault();
1299
  $("generate").click();
1300
  }
1301
  });
1302
+
1303
+ // Token Events
1304
+ $("addTokensBtn").addEventListener("click", ()=>{
1305
+ const raw = $("tokenBulkInput").value;
1306
+ if(!raw.trim()){
1307
+ $("status").textContent = "الصق توكن واحد على الأقل";
1308
+ $("status").className = "error";
1309
+ return;
1310
+ }
1311
+ const result = tokenManager.addBulk(raw);
1312
+ $("tokenBulkInput").value = "";
1313
+ renderTokens();
1314
+ $("status").className = "";
1315
+ if(result.added>0){
1316
+ $("status").textContent = `✅ تمت إضافة ${result.added} توكن جديد! ${result.skipped>0?`(${result.skipped} مكرر تم تخطيه)` : ''} - إجمالي الوقت: ${formatTimeArabic(result.added*TOKEN_QUOTA_SECONDS)}`;
1317
+ if(result.added>=30){
1318
+ $("status").textContent += ` | 30 توكن = ${30*5}=150 دقيقة كما طلبت!`;
1319
+ }
1320
+ } else {
1321
+ $("status").textContent = result.skipped>0 ? `جميع التوكنات مكررة (${result.skipped})` : "لم يتم العثور على توكنات صالحة";
1322
+ $("status").className = "error";
1323
+ }
1324
+ });
1325
+
1326
+ $("clearTokensBtn").addEventListener("click", ()=>{
1327
+ if(tokenManager.tokens.length===0){ $("status").textContent = "لا يوجد توكنات لمسحها"; return; }
1328
+ if(confirm(`هل أنت متأكد من مسح جميع التوكنات (${tokenManager.tokens.length})؟`)){
1329
+ tokenManager.clear();
1330
+ renderTokens();
1331
+ $("status").textContent = "تم مسح جميع التوكنات";
1332
+ }
1333
+ });
1334
+
1335
+ // Init
1336
+ tokenManager.load();
1337
+ renderTokens();
1338
  updateQueueUI();
1339
+
1340
+ console.log(`Token system ready: each token = ${TOKEN_QUOTA_SECONDS}s`);
1341
  </script>
1342
  </body>
1343
  </html>