Spaces:
Running
Running
| <html lang="ar" dir="rtl"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <meta name="color-scheme" content="dark" /> | |
| <title>AIDMA Studio - مع نظام التوكنز</title> | |
| <style> | |
| :root{ | |
| --bg:#070910;--panel:#111522;--panel2:#0b0e18;--line:rgba(255,255,255,.09); | |
| --text:#f6f7fb;--muted:#9aa3b8;--purple:#8b6cff;--mint:#4ce6ce;--red:#ff6c82; | |
| --shadow:0 30px 90px rgba(0,0,0,.42) | |
| } | |
| *{box-sizing:border-box} | |
| body{ | |
| margin:0;min-height:100vh;color:var(--text); | |
| font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif; | |
| background: | |
| radial-gradient(circle at 12% 5%,rgba(139,108,255,.20),transparent 29%), | |
| radial-gradient(circle at 90% 12%,rgba(76,230,206,.13),transparent 25%), | |
| linear-gradient(180deg,#0b0e18 0%,var(--bg) 62%); | |
| } | |
| .shell{width:min(1180px,calc(100% - 28px));margin:auto;padding:28px 0 45px} | |
| header{display:flex;align-items:center;justify-content:space-between;gap:18px;margin-bottom:22px} | |
| .brand{display:flex;align-items:center;gap:13px} | |
| .logo{ | |
| width:48px;height:48px;border-radius:16px;display:grid;place-items:center; | |
| font-weight:950;color:#060910;background:linear-gradient(135deg,var(--purple),var(--mint)); | |
| box-shadow:0 16px 38px rgba(139,108,255,.28) | |
| } | |
| h1{font-size:clamp(25px,4vw,37px);letter-spacing:-1.3px;margin:0} | |
| .sub{font-size:13px;color:var(--muted);margin-top:4px} | |
| .tag{font-size:12px;color:#cfd4e4;border:1px solid var(--line);border-radius:999px;padding:8px 11px;background:rgba(255,255,255,.035)} | |
| .grid{display:grid;grid-template-columns:minmax(0,.85fr) minmax(0,1.15fr);gap:19px} | |
| .panel{background:rgba(17,21,34,.83);border:1px solid var(--line);border-radius:24px;box-shadow:var(--shadow);backdrop-filter:blur(18px)} | |
| .controls{padding:21px} | |
| .viewer{padding:13px;min-height:625px;display:flex;flex-direction:column} | |
| label{display:block;margin:0 0 8px;font-size:11px;font-weight:800;letter-spacing:.09em;text-transform:uppercase;color:#bcc3d4} | |
| .field{margin-bottom:16px} | |
| textarea,select,input{ | |
| width:100%;border:1px solid var(--line);background:var(--panel2);color:var(--text); | |
| border-radius:14px;outline:none;font:inherit;transition:.18s ease | |
| } | |
| textarea{min-height:168px;resize:vertical;padding:14px;line-height:1.52} | |
| select,input{height:48px;padding:0 13px} | |
| textarea:focus,select:focus,input:focus{border-color:rgba(139,108,255,.9);box-shadow:0 0 0 4px rgba(139,108,255,.12)} | |
| .row{display:grid;grid-template-columns:1fr 1fr;gap:11px} | |
| .boost{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:0 1px 17px;color:#d4d9e7;font-size:13px} | |
| .boost input{width:18px;height:18px;accent-color:var(--purple)} | |
| button{ | |
| width:100%;height:53px;border:0;border-radius:16px;cursor:pointer;color:#07100e; | |
| font:850 15px Inter,system-ui;background:linear-gradient(135deg,var(--purple),var(--mint)); | |
| box-shadow:0 17px 38px rgba(139,108,255,.24);transition:.18s ease | |
| } | |
| button:hover{transform:translateY(-1px);filter:brightness(1.05)} | |
| button:disabled{cursor:not-allowed;opacity:.58;transform:none} | |
| .fine{font-size:11px;line-height:1.5;color:#757f95;margin:13px 2px 0} | |
| .canvas{ | |
| position:relative;display:grid;place-items:center;flex:1;min-height:520px;overflow:hidden; | |
| border-radius:18px;border:1px solid var(--line); | |
| background: | |
| linear-gradient(45deg,rgba(255,255,255,.026) 25%,transparent 25%,transparent 75%,rgba(255,255,255,.026) 75%), | |
| linear-gradient(45deg,rgba(255,255,255,.026) 25%,transparent 25%,transparent 75%,rgba(255,255,255,.026) 75%),#090c15; | |
| background-position:0 0,14px 14px;background-size:28px 28px | |
| } | |
| #image{display:none;width:100%;height:100%;object-fit:contain;background:#080a10} | |
| .empty,.loading{text-align:center;color:var(--muted);padding:30px;max-width:470px} | |
| .orb{width:73px;height:73px;margin:0 auto 17px;border-radius:25px;display:grid;place-items:center;font-size:29px;background:linear-gradient(135deg,rgba(139,108,255,.28),rgba(76,230,206,.19));border:1px solid rgba(255,255,255,.1)} | |
| .loading{display:none} | |
| .spinner{width:43px;height:43px;margin:0 auto 14px;border-radius:50%;border:3px solid rgba(255,255,255,.1);border-top-color:var(--mint);animation:spin .8s linear infinite} | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| .bar{display:flex;align-items:center;justify-content:space-between;gap:13px;padding:13px 4px 1px;min-height:48px;color:var(--muted);font-size:13px} | |
| #status{overflow-wrap:anywhere} | |
| #download{display:none;text-decoration:none;color:#06110e;background:var(--mint);border-radius:11px;padding:9px 13px;font-weight:850;white-space:nowrap} | |
| .queuebox{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px} | |
| .qchip{font-size:11px;border:1px solid var(--line);border-radius:999px;padding:7px 10px;color:#bcc4d5;background:rgba(255,255,255,.025)} | |
| .error{color:var(--red)!important} | |
| /* ===== Token System Styles ===== */ | |
| .tokenSection{ | |
| margin:18px 0 20px; | |
| padding:16px; | |
| border:1px solid rgba(139,108,255,.18); | |
| border-radius:18px; | |
| background: linear-gradient(180deg, rgba(139,108,255,.08), rgba(255,255,255,.02)); | |
| } | |
| .tokenHeader{ | |
| display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap | |
| } | |
| .tokenHeader label{margin:0 !important;font-size:13px !important;letter-spacing:0 !important;text-transform:none !important;color:#d8dcf0 !important} | |
| .tokenBulkInput{ | |
| min-height:88px !important; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, monospace !important; | |
| font-size:12px !important; | |
| direction:ltr; | |
| } | |
| .btn-secondary{ | |
| height:42px !important; | |
| font-size:13px !important; | |
| font-weight:700 !important; | |
| background: rgba(255,255,255,.06) !important; | |
| color: #d6daea !important; | |
| border:1px solid var(--line) !important; | |
| box-shadow:none !important; | |
| } | |
| .btn-secondary:hover{background:rgba(255,255,255,.09) !important} | |
| .tokenList{ | |
| display:flex;flex-direction:column;gap:8px; | |
| max-height:320px;overflow:auto;margin-bottom:12px; | |
| padding-right:2px; | |
| } | |
| .tokenList::-webkit-scrollbar{width:6px} | |
| .tokenList::-webkit-scrollbar-thumb{background:rgba(255,255,255,.12);border-radius:999px} | |
| .tokenCard{ | |
| border:1px solid var(--line); | |
| background: rgba(11,14,24,.85); | |
| border-radius:14px; | |
| padding:10px 11px; | |
| transition:.18s; | |
| direction:ltr; | |
| } | |
| .tokenCard.active{ | |
| border-color: rgba(76,230,206,.55); | |
| background: rgba(76,230,206,.09); | |
| box-shadow: 0 0 0 3px rgba(76,230,206,.10); | |
| } | |
| .tokenCard.exhausted{ | |
| opacity:.55; | |
| border-style:dashed; | |
| } | |
| .tokenCardTop{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px} | |
| .tokenMask{font-family:ui-monospace,monospace;font-size:12px;font-weight:700;letter-spacing:.2px} | |
| .tokenBadge{font-size:10px;padding:4px 8px;border-radius:999px;border:1px solid var(--line);background:rgba(255,255,255,.04);white-space:nowrap} | |
| .tokenBadge.active{background:var(--mint);color:#06201b;border-color:var(--mint);font-weight:800} | |
| .tokenBadge.exhausted{background:rgba(255,108,130,.15);color:var(--red);border-color:rgba(255,108,130,.25)} | |
| .tokenProgress{height:6px;background:rgba(255,255,255,.08);border-radius:999px;overflow:hidden;margin-bottom:7px} | |
| .tokenProgressFill{height:100%;border-radius:999px;transition: width .5s ease} | |
| .tokenMeta{font-size:11px;color:var(--muted);display:flex;justify-content:space-between;gap:8px} | |
| .tokenActions{display:flex;gap:6px;margin-top:8px} | |
| .miniBtn{ | |
| height:28px !important;font-size:11px !important;font-weight:700 !important; | |
| border-radius:9px !important;padding:0 10px !important;width:auto !important;flex:1; | |
| background:rgba(255,255,255,.06) !important;color:#d0d5e6 !important;border:1px solid var(--line) !important;box-shadow:none !important | |
| } | |
| .miniBtn.activeBtn{background:var(--mint) !important;color:#06201b !important;border-color:var(--mint) !important} | |
| .miniBtn.danger:hover{background:rgba(255,108,130,.15) !important;color:var(--red) !important;border-color:rgba(255,108,130,.3) !important} | |
| .tokenGlobalStats{ | |
| display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:4px | |
| } | |
| .statBox{ | |
| border:1px solid var(--line);border-radius:12px;padding:10px; | |
| background:rgba(255,255,255,.02);text-align:center | |
| } | |
| .statVal{font-size:18px;font-weight:900;letter-spacing:-.5px;color:var(--text)} | |
| .statLabel{font-size:10px;color:var(--muted);margin-top:2px;text-transform:uppercase;letter-spacing:.06em} | |
| .tokenLive{ | |
| display:flex;align-items:center;gap:8px; | |
| padding:8px 10px;border-radius:11px; | |
| background:rgba(76,230,206,.10);border:1px solid rgba(76,230,206,.18); | |
| font-size:12px;color:#c7f0e8;margin-bottom:10px; | |
| direction:ltr; | |
| } | |
| .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} | |
| @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)}} | |
| .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)} | |
| @media(max-width:900px){.grid{grid-template-columns:1fr}.viewer{min-height:520px}header{align-items:flex-start;flex-direction:column}} | |
| @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} | |
| .tokenGlobalStats{grid-template-columns:1fr 1fr} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <main class="shell"> | |
| <header> | |
| <div class="brand"> | |
| <div class="logo">A6</div> | |
| <div> | |
| <h1>AIDMA Studio</h1> | |
| <div class="sub">Static HTML interface · AIDMA-only remote generation · نظام التوكنز الذكي</div> | |
| </div> | |
| </div> | |
| <div class="tag">Fresh automatic seed every time</div> | |
| </header> | |
| <section class="grid"> | |
| <div class="panel controls"> | |
| <div class="field"> | |
| <label for="model">AIDMA model</label> | |
| <select id="model"> | |
| <option value="Keltezaa/midjourney-v6-1-meets-flux-sdxl" data-trigger="(aidmaMJ6.1)"> | |
| AIDMA MJ6.1 · FLUX v0.4 | |
| </option> | |
| <option value="demo001s/MidjourneyMeetsFlux" data-trigger="aidmaMJ6.1"> | |
| AIDMA MJ6.1 · FLUX v0.5 Experimental | |
| </option> | |
| </select> | |
| </div> | |
| <div class="field"> | |
| <label for="prompt">Prompt</label> | |
| <textarea id="prompt" maxlength="5000" placeholder="Describe the subject, environment, camera, lighting, mood and composition..."></textarea> | |
| </div> | |
| <div class="field"> | |
| <label for="style">Style</label> | |
| <input id="style" list="styles" value="Cinematic" maxlength="300" placeholder="Choose or type any style" /> | |
| <datalist id="styles"></datalist> | |
| </div> | |
| <div class="row"> | |
| <div class="field"> | |
| <label for="ratio">Aspect ratio</label> | |
| <select id="ratio"> | |
| <option value="1024x1024">1:1</option> | |
| <option value="1280x720" selected>16:9</option> | |
| <option value="720x1280">9:16</option> | |
| <option value="1152x864">4:3</option> | |
| <option value="864x1152">3:4</option> | |
| <option value="1216x832">3:2</option> | |
| <option value="832x1216">2:3</option> | |
| </select> | |
| </div> | |
| <div class="field"> | |
| <label for="quality">Quality</label> | |
| <select id="quality"> | |
| <option value="20">Fast</option> | |
| <option value="28" selected>Balanced</option> | |
| <option value="40">Ultra</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="boost"> | |
| <span>Professional prompt boost</span> | |
| <input id="boost" type="checkbox" checked /> | |
| </div> | |
| <!-- ========== نظام التوكنز الجديد ========== --> | |
| <div class="tokenSection" id="tokenSection"> | |
| <div class="tokenHeader"> | |
| <label>🎟️ إدارة التوكنز - كل توكن = 5 دقائق quota</label> | |
| <span class="qchip" id="tokenStatsChip">0 توكن</span> | |
| </div> | |
| <div id="tokenLiveIndicator" style="display:none" class="tokenLive"> | |
| <div class="liveDot"></div> | |
| <span id="tokenLiveText">يتم الاستهلاك من التوكن النشط...</span> | |
| </div> | |
| <div class="field" style="margin-bottom:10px"> | |
| <textarea id="tokenBulkInput" class="tokenBulkInput" placeholder="الصق التوكنات هنا، كل توكن في سطر: hf_xxxxxxxxxxxxxxxxxxxxxxxx hf_yyyyyyyyyyyyyyyyyyyyyyyy hf_zzzzzzzzzzzzzzzzzzzzzzzz مثال: لو حطيت 30 توكن = 150 دقيقة"></textarea> | |
| </div> | |
| <div class="row" style="margin-bottom:12px"> | |
| <button type="button" class="btn-secondary" id="addTokensBtn">+ إضافة التوكنز</button> | |
| <button type="button" class="btn-secondary" id="clearTokensBtn">🗑️ مسح الكل</button> | |
| </div> | |
| <div id="tokenPoolList" class="tokenList"></div> | |
| <div id="tokenGlobalStats" class="tokenGlobalStats"></div> | |
| <div class="fine" style="margin-top:8px"> | |
| ⚠️ التوكنز تُحفظ محلياً في متصفحك فقط (localStorage) ولا تُرسل إلا إلى HuggingFace مباشرة.<br> | |
| النظام يبدأ بأول توكن، يستهلك 5 دقائق، ثم ينتقل تلقائياً للتالي وهكذا.<br> | |
| عند انتهاء quota للتوكن الحالي، يتم التبديل فوراً بدون توقف الطابور. | |
| </div> | |
| </div> | |
| <!-- ========== نهاية نظام التوكنز ========== --> | |
| <button id="generate">Add to private browser queue</button> | |
| <div class="queuebox"> | |
| <span class="qchip" id="queuedChip">Queued: 0</span> | |
| <span class="qchip" id="activeChip">Active: none</span> | |
| <span class="qchip" id="engineChip">Engines: 20</span> | |
| </div> | |
| <div class="fine"> | |
| 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. | |
| </div> | |
| </div> | |
| <div class="panel viewer"> | |
| <div class="canvas"> | |
| <div class="empty" id="empty"> | |
| <div class="orb">✦</div> | |
| <strong>Your next frame begins here.</strong> | |
| <div style="margin-top:8px">Write a prompt and add it to your queue. أضف توكناتك أولاً للحصول على quota.</div> | |
| </div> | |
| <div class="loading" id="loading"> | |
| <div class="spinner"></div> | |
| <strong id="loadingTitle">Connecting to the AIDMA engine…</strong> | |
| <div id="queueStatus" style="margin-top:8px">Preparing request</div> | |
| </div> | |
| <img id="image" alt="Generated image" /> | |
| </div> | |
| <div class="bar"> | |
| <div id="status">Ready.</div> | |
| <a id="download" download="aidma-image.png">Download</a> | |
| </div> | |
| </div> | |
| </section> | |
| </main> | |
| <script type="module"> | |
| import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/+esm"; | |
| function spaceOrigin(spaceId) { | |
| return `https://${spaceId.replace("/", "-").toLowerCase()}.hf.space`; | |
| } | |
| const BACKEND_SPACES = [ | |
| { id: "multimodalart/flux-lora-the-explorer", origin: spaceOrigin("multimodalart/flux-lora-the-explorer"), label: "AIDMA Engine 1" }, | |
| { id: "John6666/flux-lora-the-explorer", origin: spaceOrigin("John6666/flux-lora-the-explorer"), label: "AIDMA Engine 2" }, | |
| { id: "Svngoku/flux-lora-the-explorer", origin: spaceOrigin("Svngoku/flux-lora-the-explorer"), label: "AIDMA Engine 3" }, | |
| { id: "Nymbo/flux-lora-the-explorer", origin: spaceOrigin("Nymbo/flux-lora-the-explorer"), label: "AIDMA Engine 4" }, | |
| { id: "Emuixom/flux-lora-the-explorer", origin: spaceOrigin("Emuixom/flux-lora-the-explorer"), label: "AIDMA Engine 5" }, | |
| { id: "codermert/flux-lora-the-explorer", origin: spaceOrigin("codermert/flux-lora-the-explorer"), label: "AIDMA Engine 6" }, | |
| { id: "reza74ii/flux-lora-the-explorer", origin: spaceOrigin("reza74ii/flux-lora-the-explorer"), label: "AIDMA Engine 7" }, | |
| { id: "x2778/flux-lora-the-explorer-a", origin: spaceOrigin("x2778/flux-lora-the-explorer-a"), label: "AIDMA Engine 8" }, | |
| { id: "Ckjdjdjf/flux-lora-the-explorer", origin: spaceOrigin("Ckjdjdjf/flux-lora-the-explorer"), label: "AIDMA Engine 9" }, | |
| { id: "ZENLLC/flux-lora-the-explorer", origin: spaceOrigin("ZENLLC/flux-lora-the-explorer"), label: "AIDMA Engine 10" }, | |
| { id: "tenet/flux-lora-the-explorer", origin: spaceOrigin("tenet/flux-lora-the-explorer"), label: "AIDMA Engine 11" }, | |
| { id: "seawolf2357/flxloraexp", origin: spaceOrigin("seawolf2357/flxloraexp"), label: "AIDMA Engine 12" }, | |
| { id: "ginipick/flxloraexp", origin: spaceOrigin("ginipick/flxloraexp"), label: "AIDMA Engine 13" }, | |
| { id: "John6666/flux-lora-the-explorer-crash-progress", origin: spaceOrigin("John6666/flux-lora-the-explorer-crash-progress"), label: "AIDMA Engine 14" }, | |
| { id: "killwithabass/flux-gay-lora-explorer", origin: spaceOrigin("killwithabass/flux-gay-lora-explorer"), label: "AIDMA Engine 15" }, | |
| { id: "multimodalart/flux-lora-lab", origin: spaceOrigin("multimodalart/flux-lora-lab"), label: "AIDMA Engine 16" }, | |
| { id: "prithivMLmods/FLUX-LoRA-DLC2", origin: spaceOrigin("prithivMLmods/FLUX-LoRA-DLC2"), label: "AIDMA Engine 17" }, | |
| { id: "enzostvs/lora-studio", origin: spaceOrigin("enzostvs/lora-studio"), label: "AIDMA Engine 18" }, | |
| { id: "ovi054/FLUX.Dev-LoRA", origin: spaceOrigin("ovi054/FLUX.Dev-LoRA"), label: "AIDMA Engine 19" }, | |
| { id: "waloneai/FLUX.Dev-LoRA-Serverless", origin: spaceOrigin("waloneai/FLUX.Dev-LoRA-Serverless"), label: "AIDMA Engine 20" } | |
| ]; | |
| const $ = id => document.getElementById(id); | |
| const localQueue = []; | |
| let processing = false; | |
| let client = null; | |
| let api = null; | |
| let activeBackend = null; | |
| let preferredBackendIndex = 0; | |
| let jobCounter = 0; | |
| const backendFailures = new Map(); | |
| const BACKEND_FAILURE_COOLDOWN_MS = 10 * 60 * 1000; | |
| const BACKEND_CONNECT_TIMEOUT_MS = 18 * 1000; | |
| // ===================== نظام التوكنز ===================== | |
| const TOKEN_QUOTA_SECONDS = 5 * 60; // كل توكن = 5 دقائق | |
| const LS_KEY = 'aidma_token_pool_v2'; | |
| let activeGenInterval = null; | |
| function maskToken(token){ | |
| if(!token) return 'بدون توكن'; | |
| const t = token.trim(); | |
| if(t.length <= 12) return t.slice(0,4)+'***'; | |
| return t.slice(0,7) + '***' + t.slice(-4); | |
| } | |
| function formatTime(sec){ | |
| sec = Math.max(0, Math.round(sec)); | |
| const m = Math.floor(sec/60); | |
| const s = sec%60; | |
| return `${m}:${String(s).padStart(2,'0')}`; | |
| } | |
| function formatTimeArabic(sec){ | |
| sec = Math.max(0, Math.round(sec)); | |
| const m = Math.floor(sec/60); | |
| const s = sec%60; | |
| if(m===0) return `${s} ثانية`; | |
| if(s===0) return `${m} دقيقة`; | |
| return `${m} دقيقة و ${s} ثانية`; | |
| } | |
| const tokenManager = { | |
| tokens: [], | |
| activeId: null, | |
| load(){ | |
| try{ | |
| const raw = localStorage.getItem(LS_KEY); | |
| if(raw){ | |
| const parsed = JSON.parse(raw); | |
| this.tokens = (parsed.tokens||[]).map(t=>({ | |
| id: t.id || Math.random().toString(36).slice(2), | |
| value: t.value, | |
| total: TOKEN_QUOTA_SECONDS, | |
| remaining: typeof t.remaining === 'number' ? Math.max(0, Math.min(TOKEN_QUOTA_SECONDS, t.remaining)) : TOKEN_QUOTA_SECONDS, | |
| used: typeof t.used === 'number' ? t.used : 0, | |
| status: t.status || 'ready', | |
| addedAt: t.addedAt || Date.now(), | |
| })); | |
| this.activeId = parsed.activeId || null; | |
| } | |
| }catch(e){ console.warn('load tokens failed', e); this.tokens=[];} | |
| // تنظيف وتصحيح | |
| this.tokens = this.tokens.filter(t=>t.value && t.value.trim().length>10); | |
| // إذا لا يوجد active أو الحالي منتهي، اختر أول جاهز | |
| const activeExists = this.tokens.find(t=>t.id===this.activeId && t.remaining>0); | |
| if(!activeExists && this.tokens.length){ | |
| const firstReady = this.tokens.find(t=>t.remaining>0); | |
| if(firstReady){ | |
| this.activeId = firstReady.id; | |
| firstReady.status = 'active'; | |
| } | |
| } | |
| // تأكد من حالات | |
| this.tokens.forEach(t=>{ | |
| if(t.remaining<=0){ t.status='exhausted'; t.remaining=0; } | |
| else if(t.id===this.activeId){ t.status='active'; } | |
| else if(t.status==='active'){ t.status='ready'; } | |
| }); | |
| }, | |
| save(){ | |
| try{ | |
| localStorage.setItem(LS_KEY, JSON.stringify({tokens:this.tokens, activeId:this.activeId})); | |
| }catch(e){ console.warn('save failed', e); } | |
| }, | |
| parseBulk(text){ | |
| if(!text) return []; | |
| return text.split(/[\n, \t]+/) | |
| .map(s=>s.trim()) | |
| .filter(s=>s.length>10) | |
| .filter(s=>s.startsWith('hf_') || s.length>20) // قبول hf_ أو توكن طويل | |
| .map(s=>s.replace(/['\"]/g,'').trim()) | |
| .filter((v,i,a)=>a.indexOf(v)===i); // unique | |
| }, | |
| addBulk(rawText){ | |
| const list = this.parseBulk(rawText); | |
| if(!list.length) return {added:0, skipped:0}; | |
| let added=0, skipped=0; | |
| const existingValues = new Set(this.tokens.map(t=>t.value)); | |
| for(const val of list){ | |
| if(existingValues.has(val)){ skipped++; continue; } | |
| const tok = { | |
| id: (Date.now()+Math.random()).toString(36).slice(2) + added, | |
| value: val, | |
| total: TOKEN_QUOTA_SECONDS, | |
| remaining: TOKEN_QUOTA_SECONDS, | |
| used: 0, | |
| status: 'ready', | |
| addedAt: Date.now() | |
| }; | |
| this.tokens.push(tok); | |
| existingValues.add(val); | |
| added++; | |
| } | |
| if(this.tokens.length && !this.activeId){ | |
| const first = this.tokens.find(t=>t.remaining>0); | |
| if(first){ this.activeId = first.id; first.status='active'; } | |
| } | |
| this.save(); | |
| return {added, skipped, total:list.length}; | |
| }, | |
| remove(id){ | |
| const idx = this.tokens.findIndex(t=>t.id===id); | |
| if(idx>=0){ | |
| this.tokens.splice(idx,1); | |
| if(this.activeId===id){ | |
| const next = this.tokens.find(t=>t.remaining>0); | |
| this.activeId = next ? next.id : null; | |
| if(next) next.status='active'; | |
| } | |
| this.save(); | |
| } | |
| }, | |
| clear(){ | |
| this.tokens = []; | |
| this.activeId = null; | |
| this.save(); | |
| }, | |
| getActive(){ | |
| if(this.activeId){ | |
| const t = this.tokens.find(x=>x.id===this.activeId); | |
| if(t && t.remaining>0) return t; | |
| } | |
| return this.tokens.find(t=>t.remaining>0) || null; | |
| }, | |
| setActive(id){ | |
| const tok = this.tokens.find(t=>t.id===id); | |
| if(!tok || tok.remaining<=0) return false; | |
| this.tokens.forEach(t=>{ if(t.status==='active') t.status='ready'; }); | |
| tok.status='active'; | |
| this.activeId = id; | |
| this.save(); | |
| return true; | |
| }, | |
| exhaust(id, reason=''){ | |
| const t = this.tokens.find(x=>x.id===id); | |
| if(!t) return; | |
| t.remaining = 0; | |
| t.used = t.total; | |
| t.status = 'exhausted'; | |
| t.exhaustedReason = reason; | |
| t.exhaustedAt = Date.now(); | |
| if(this.activeId===id){ | |
| const next = this.tokens.find(x=>x.remaining>0 && x.id!==id); | |
| this.activeId = next ? next.id : null; | |
| if(next) next.status='active'; | |
| } | |
| this.save(); | |
| }, | |
| getStats(){ | |
| const total = this.tokens.length; | |
| const ready = this.tokens.filter(t=>t.remaining>0).length; | |
| const exhausted = total - ready; | |
| const totalRemaining = this.tokens.reduce((s,t)=>s+t.remaining,0); | |
| const totalUsed = this.tokens.reduce((s,t)=>s+t.used,0); | |
| const totalQuota = total * TOKEN_QUOTA_SECONDS; | |
| const active = this.getActive(); | |
| return {total, ready, exhausted, totalRemaining, totalUsed, totalQuota, active}; | |
| } | |
| }; | |
| function renderTokens(){ | |
| const listEl = $("tokenPoolList"); | |
| const chip = $("tokenStatsChip"); | |
| const statsEl = $("tokenGlobalStats"); | |
| const stats = tokenManager.getStats(); | |
| // Chip | |
| if(stats.total===0){ | |
| chip.textContent = '0 توكن'; | |
| } else { | |
| chip.textContent = `${stats.total} توكن | ${stats.ready} جاهز | ${formatTime(stats.totalRemaining)} متبقي`; | |
| } | |
| // List | |
| if(stats.total===0){ | |
| listEl.innerHTML = `<div class="emptyTokens">🔑 لا يوجد توكنز<br><span style="font-size:11px">الصق 30 توكن مثلاً = 150 دقيقة quota متواصلة</span></div>`; | |
| } else { | |
| listEl.innerHTML = tokenManager.tokens.map(t=>{ | |
| const isActive = t.id===tokenManager.activeId && t.remaining>0; | |
| const pct = t.total>0 ? (t.remaining / t.total * 100) : 0; | |
| const pctUsed = 100-pct; | |
| let color = 'linear-gradient(90deg,var(--mint),var(--purple))'; | |
| if(t.remaining<=0) color = '#ff6c82'; | |
| else if(pct<25) color = '#ffbe5c'; | |
| else if(isActive) color = 'linear-gradient(90deg,var(--mint),#8bffde)'; | |
| const statusText = t.status==='exhausted' ? 'منتهي' : (isActive ? 'نشط الآن' : 'جاهز'); | |
| const badgeClass = t.status==='exhausted' ? 'exhausted' : (isActive ? 'active' : ''); | |
| return ` | |
| <div class="tokenCard ${t.status} ${isActive?'active':''}"> | |
| <div class="tokenCardTop"> | |
| <span class="tokenMask">${maskToken(t.value)}</span> | |
| <span class="tokenBadge ${badgeClass}">${statusText}</span> | |
| </div> | |
| <div class="tokenProgress"><div class="tokenProgressFill" style="width:${pct}%;background:${color}"></div></div> | |
| <div class="tokenMeta"> | |
| <span>⏳ متبقي: ${formatTime(t.remaining)} / ${formatTime(t.total)}</span> | |
| <span>🔥 مستهلك: ${formatTime(t.used)}</span> | |
| </div> | |
| <div class="tokenActions"> | |
| <button class="miniBtn ${isActive?'activeBtn':''}" data-action="activate" data-id="${t.id}" ${t.remaining<=0?'disabled':''}>${isActive?'✓ نشط':'تفعيل'}</button> | |
| <button class="miniBtn danger" data-action="remove" data-id="${t.id}">حذف</button> | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| // attach listeners via delegation | |
| listEl.querySelectorAll('button[data-action]').forEach(btn=>{ | |
| btn.addEventListener('click', ()=>{ | |
| const id = btn.getAttribute('data-id'); | |
| const action = btn.getAttribute('data-action'); | |
| if(action==='activate'){ | |
| tokenManager.setActive(id); | |
| renderTokens(); | |
| $("status").textContent = `تم تفعيل التوكن ${maskToken(tokenManager.tokens.find(x=>x.id===id).value)}`; | |
| } else if(action==='remove'){ | |
| if(confirm('حذف هذا التوكن؟')){ tokenManager.remove(id); renderTokens(); } | |
| } | |
| }); | |
| }); | |
| } | |
| // Global stats boxes | |
| if(stats.total>0){ | |
| statsEl.innerHTML = ` | |
| <div class="statBox"> | |
| <div class="statVal">${stats.total}</div> | |
| <div class="statLabel">إجمالي التوكنز</div> | |
| </div> | |
| <div class="statBox"> | |
| <div class="statVal" style="color:var(--mint)">${formatTime(stats.totalRemaining)}</div> | |
| <div class="statLabel">الوقت المتبقي الكلي (${Math.floor(stats.totalRemaining/60)} دقيقة)</div> | |
| </div> | |
| <div class="statBox"> | |
| <div class="statVal">${stats.ready}</div> | |
| <div class="statLabel">جاهز للاستخدام</div> | |
| </div> | |
| <div class="statBox"> | |
| <div class="statVal" style="color:${stats.exhausted>0?'var(--red)':'var(--muted)'}">${stats.exhausted}</div> | |
| <div class="statLabel">منتهي</div> | |
| </div> | |
| `; | |
| statsEl.style.display = 'grid'; | |
| } else { | |
| statsEl.innerHTML = ''; | |
| statsEl.style.display = 'none'; | |
| } | |
| // update live indicator | |
| updateLiveIndicator(); | |
| } | |
| function updateLiveIndicator(){ | |
| const ind = $("tokenLiveIndicator"); | |
| const txt = $("tokenLiveText"); | |
| const active = tokenManager.getActive(); | |
| if(processing && active && activeGenInterval){ | |
| ind.style.display='flex'; | |
| txt.textContent = `🔴 يستهلك من ${maskToken(active.value)} - متبقي ${formatTime(active.remaining)} - إجمالي متبقي ${formatTime(tokenManager.getStats().totalRemaining)}`; | |
| } else { | |
| ind.style.display='none'; | |
| } | |
| } | |
| function startTokenCountdown(tokenId){ | |
| stopTokenCountdown(); | |
| const start = Date.now(); | |
| activeGenInterval = setInterval(()=>{ | |
| const tok = tokenManager.tokens.find(t=>t.id===tokenId); | |
| if(!tok || tok.remaining<=0){ | |
| stopTokenCountdown(); | |
| renderTokens(); | |
| return; | |
| } | |
| // نقص ثانية كل ثانية | |
| tok.remaining = Math.max(0, tok.remaining - 1); | |
| tok.used = tok.total - tok.remaining; | |
| if(tok.remaining<=0){ | |
| tokenManager.exhaust(tokenId, 'انتهى الوقت المحلي - 5 دقائق'); | |
| $("queueStatus").textContent = `التوكن ${maskToken(tok.value)} انتهى (5 دقائق). سيتم التبديل تلقائيا...`; | |
| } | |
| tokenManager.save(); | |
| // تحديث سريع بدون إعادة رسم كامل كل ثانية لتقليل التقطيع؟ بس نعمل render خفيف | |
| const stats = tokenManager.getStats(); | |
| $("tokenStatsChip").textContent = `${stats.total} توكن | ${stats.ready} جاهز | ${formatTime(stats.totalRemaining)} متبقي`; | |
| // تحديث البطاقة النشطة فقط | |
| const activeCard = document.querySelector(`.tokenCard.active .tokenMeta`); | |
| if(activeCard){ | |
| activeCard.innerHTML = `<span>⏳ متبقي: ${formatTime(tok.remaining)} / ${formatTime(tok.total)}</span><span>🔥 مستهلك: ${formatTime(tok.used)}</span>`; | |
| const fill = document.querySelector(`.tokenCard.active .tokenProgressFill`); | |
| if(fill) fill.style.width = `${(tok.remaining/tok.total*100)}%`; | |
| } | |
| updateLiveIndicator(); | |
| // تحديث global remaining | |
| const remainEl = document.querySelector('#tokenGlobalStats .statBox:nth-child(2) .statVal'); | |
| if(remainEl) remainEl.textContent = formatTime(stats.totalRemaining); | |
| }, 1000); | |
| } | |
| function stopTokenCountdown(){ | |
| if(activeGenInterval){ | |
| clearInterval(activeGenInterval); | |
| activeGenInterval = null; | |
| } | |
| } | |
| // ===================== نهاية نظام التوكنز ===================== | |
| const STYLES = [ | |
| "No extra style","Photorealistic","Ultra realistic photography","Cinematic", | |
| "Hollywood blockbuster","IMAX film still","35mm analog film","70mm epic cinema", | |
| "Documentary photography","Fashion editorial","Luxury fashion campaign", | |
| "Studio portrait","Environmental portrait","Street photography","Sports photography", | |
| "Wildlife photography","Macro photography","Aerial photography","Drone photography", | |
| "Underwater photography","Astrophotography","Architectural photography", | |
| "Interior design photography","Product photography","Luxury product advertising", | |
| "Food photography","Automotive photography","Concert photography","Wedding photography", | |
| "Photojournalism","Vintage photography","Polaroid","Kodachrome","Technicolor", | |
| "Film noir","Neo-noir","Black and white fine art","High-key lighting","Low-key lighting", | |
| "Golden hour","Blue hour","Soft diffused light","Dramatic chiaroscuro", | |
| "Volumetric lighting","Neon lighting","Bioluminescent lighting","Cyberpunk", | |
| "Solarpunk","Steampunk","Dieselpunk","Atompunk","Cassette futurism","Retro futurism", | |
| "Y2K futurism","Sci-fi concept art","Space opera","Hard science fiction","Alien world", | |
| "Dystopian","Utopian","Post-apocalyptic","Fantasy concept art","Dark fantasy", | |
| "High fantasy","Epic fantasy","Mythological","Medieval","Renaissance","Baroque", | |
| "Rococo","Victorian","Art Nouveau","Art Deco","Bauhaus","Brutalism","Minimalism", | |
| "Maximalism","Surrealism","Dreamlike surrealism","Magical realism","Expressionism", | |
| "Impressionism","Post-impressionism","Romanticism","Symbolism","Cubism","Futurism", | |
| "Dada","Pop art","Op art","Abstract art","Geometric abstraction","Psychedelic art", | |
| "Visionary art","Graffiti","Street art","Ink illustration","Pen and ink", | |
| "Graphite drawing","Charcoal drawing","Colored pencil","Pastel drawing","Watercolor", | |
| "Gouache","Oil painting","Acrylic painting","Impasto painting","Digital painting", | |
| "Matte painting","Concept art","Key art","Book cover illustration", | |
| "Editorial illustration","Children's book illustration","Comic book art", | |
| "Graphic novel","Manga","Anime","90s anime","Retro anime","Cel animation", | |
| "Stop motion","Claymation","Paper cutout","Origami art","Low-poly 3D", | |
| "Stylized 3D","Photorealistic 3D render","Unreal Engine cinematic","Octane render", | |
| "Ray-traced CGI","Isometric 3D","Voxel art","Pixel art","16-bit game art", | |
| "8-bit game art","PS1 game aesthetic","Retro arcade","Game concept art", | |
| "Fantasy game splash art","Trading card art","Holographic art","Chrome metallic", | |
| "Liquid metal","Crystal sculpture","Porcelain sculpture","Marble sculpture", | |
| "Bronze sculpture","Wood carving","Embroidery","Tapestry","Mosaic","Stained glass", | |
| "Paper collage","Mixed media","Blueprint","Technical drawing","Scientific illustration", | |
| "Botanical illustration","Anatomical illustration","Logo design","Poster design", | |
| "Album cover","Magazine cover","Movie poster","Luxury branding","Minimalist advertising", | |
| "Japanese ukiyo-e","Chinese ink wash","Persian miniature","Islamic geometric art", | |
| "Ancient Egyptian mural","Greek pottery art","Roman mosaic","Celtic art", | |
| "Nordic folk art","Mexican folk art","Indian miniature painting","Arabesque ornament", | |
| "Gothic cathedral art","Dark academia","Light academia","Cottagecore","Fairycore", | |
| "Royalcore","Dreamcore","Weirdcore","Liminal space","Vaporwave","Synthwave", | |
| "Retrowave","Lo-fi aesthetic","Grunge","Heavy metal album art","Elegant luxury", | |
| "Clean commercial","Hyper-detailed","Ethereal","Whimsical","Moody","Haunting", | |
| "Cozy","Playful","Epic" | |
| ]; | |
| const STYLE_DETAIL = { | |
| "No extra style":"", | |
| "Photorealistic":"photorealistic, realistic materials, physically accurate lighting, natural texture", | |
| "Ultra realistic photography":"ultra realistic professional photography, true-to-life optics, natural micro-texture", | |
| "Cinematic":"cinematic composition, dramatic motivated lighting, filmic depth, refined color grading", | |
| "Hollywood blockbuster":"large-scale blockbuster frame, premium production design, dynamic cinematic staging", | |
| "IMAX film still":"IMAX film still, monumental scale, crisp cinematic detail, natural film contrast", | |
| "35mm analog film":"35mm analog film, organic grain, subtle halation, authentic lens character", | |
| "Fashion editorial":"high-fashion editorial, deliberate styling, premium magazine art direction", | |
| "Luxury fashion campaign":"luxury fashion campaign, immaculate art direction, expensive materials", | |
| "Studio portrait":"professional studio portrait, controlled key and fill lighting, clean separation", | |
| "Product photography":"premium product photography, precise reflections, clean geometry, commercial detail", | |
| "Luxury product advertising":"luxury advertising campaign, sculpted highlights, polished surfaces, elegant negative space", | |
| "Film noir":"film noir, hard directional light, deep shadows, smoky suspenseful atmosphere", | |
| "Neo-noir":"neo-noir, modern darkness, colored practical lights, tense cinematic mood", | |
| "Volumetric lighting":"volumetric light shafts, atmospheric depth, visible light diffusion", | |
| "Cyberpunk":"cyberpunk city aesthetic, neon infrastructure, rain-slick surfaces, dense futuristic detail", | |
| "Solarpunk":"solarpunk, optimistic ecological technology, lush greenery, sustainable architecture", | |
| "Steampunk":"steampunk, intricate brass machinery, Victorian engineering, warm industrial atmosphere", | |
| "Sci-fi concept art":"high-end science-fiction concept art, believable technology, cinematic scale", | |
| "Space opera":"epic space opera, colossal celestial scale, ornate futuristic design", | |
| "Fantasy concept art":"premium fantasy concept art, rich worldbuilding, dramatic atmosphere", | |
| "Dark fantasy":"dark fantasy, ominous mythology, textured shadows, ancient atmosphere", | |
| "High fantasy":"high fantasy, majestic worldbuilding, luminous magic, heroic composition", | |
| "Oil painting":"museum-quality oil painting, layered brushwork, rich pigments, painterly light", | |
| "Watercolor":"expressive watercolor, translucent washes, granulation, soft edge variation", | |
| "Digital painting":"high-end digital painting, confident brushwork, cinematic rendering", | |
| "Anime":"premium anime illustration, expressive character design, clean linework, cinematic cel shading", | |
| "Manga":"detailed manga artwork, confident ink lines, dynamic composition", | |
| "90s anime":"1990s anime aesthetic, hand-painted cel look, nostalgic color palette", | |
| "Photorealistic 3D render":"photorealistic 3D render, physically based materials, ray-traced lighting", | |
| "Unreal Engine cinematic":"Unreal Engine cinematic frame, detailed assets, global illumination", | |
| "Pixel art":"carefully designed pixel art, readable silhouettes, deliberate limited palette", | |
| "PS1 game aesthetic":"PlayStation 1 aesthetic, low-poly geometry, retro affine texture character", | |
| "Japanese ukiyo-e":"Japanese ukiyo-e woodblock print, elegant flat shapes, carved line rhythm", | |
| "Islamic geometric art":"intricate Islamic geometric design, precise tessellation, balanced ornament", | |
| "Surrealism":"surrealist visual logic, uncanny symbolism, dreamlike spatial relationships", | |
| "Magical realism":"magical realism, believable everyday world touched by subtle wonder", | |
| "Art Nouveau":"Art Nouveau, flowing organic ornament, elegant curves, botanical detail", | |
| "Art Deco":"Art Deco, geometric luxury, symmetrical glamour, polished metallic accents", | |
| "Brutalism":"brutalist design, monumental concrete geometry, severe structural rhythm", | |
| "Minimalism":"minimalist composition, disciplined negative space, simple forms", | |
| "Maximalism":"maximalist visual abundance, layered ornament, rich color and detail", | |
| "Movie poster":"premium theatrical movie poster, iconic focal point, dramatic visual hierarchy", | |
| "Album cover":"memorable album cover artwork, bold identity, strong central concept", | |
| "Dark academia":"dark academia, old libraries, scholarly atmosphere, warm shadowed interiors", | |
| "Dreamcore":"dreamcore, nostalgic uncanny spaces, soft surreal atmosphere", | |
| "Vaporwave":"vaporwave, retro digital nostalgia, surreal neon geometry, glossy gradients", | |
| "Synthwave":"synthwave, retro-futurist neon, dramatic horizon glow, 1980s electronic atmosphere", | |
| "Elegant luxury":"elegant luxury aesthetic, refined materials, tasteful restraint, premium finish", | |
| "Clean commercial":"clean commercial image, polished lighting, clear subject, advertising-ready composition", | |
| "Hyper-detailed":"hyper-detailed, intricate micro-texture, crisp controlled complexity", | |
| "Ethereal":"ethereal atmosphere, luminous haze, delicate light, graceful visual softness", | |
| "Whimsical":"whimsical visual storytelling, imaginative forms, charming unexpected details", | |
| "Moody":"moody atmosphere, controlled contrast, emotionally charged lighting", | |
| "Haunting":"haunting beauty, restrained unease, atmospheric depth, memorable silhouette", | |
| "Cozy":"cozy atmosphere, warm practical light, tactile materials, inviting detail", | |
| "Epic":"epic scale, heroic composition, monumental depth, dramatic visual impact" | |
| }; | |
| const PROMPT_BOOST = | |
| "masterful composition, coherent anatomy, accurate hands and facial structure, " + | |
| "clear subject separation, refined color harmony, realistic material response, " + | |
| "controlled detail, intentional lighting, professional visual storytelling"; | |
| for (const style of STYLES) { | |
| const option = document.createElement("option"); | |
| option.value = style; | |
| $("styles").appendChild(option); | |
| } | |
| function normalise(value) { | |
| return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, ""); | |
| } | |
| function updateQueueUI() { | |
| $("queuedChip").textContent = `Queued: ${localQueue.length}`; | |
| if (!processing) { | |
| const active = tokenManager.getActive(); | |
| if(active) $("activeChip").textContent = `Active Token: ${maskToken(active.value)} (${formatTime(active.remaining)})`; | |
| else $("activeChip").textContent = tokenManager.tokens.length? "Active: كل التوكنز منتهية" : "Active: none"; | |
| return; | |
| } | |
| const engine = activeBackend?.label || "connecting"; | |
| const activeTok = tokenManager.getActive(); | |
| if(activeTok) $("activeChip").textContent = `${engine} | ${maskToken(activeTok.value)}`; | |
| else $("activeChip").textContent = `Active: ${engine}`; | |
| } | |
| function setViewer(mode, message = "") { | |
| $("empty").style.display = mode === "empty" ? "block" : "none"; | |
| $("loading").style.display = mode === "loading" ? "block" : "none"; | |
| $("image").style.display = mode === "image" ? "block" : "none"; | |
| if (message) $("status").textContent = message; | |
| } | |
| function endpointInfo(names) { | |
| const named = api?.named_endpoints || {}; | |
| const entries = Object.entries(named); | |
| for (const name of names) { | |
| const exact = entries.find(([key]) => normalise(key) === normalise(name)); | |
| if (exact) return { endpoint: exact[0], spec: exact[1] }; | |
| } | |
| for (const name of names) { | |
| const partial = entries.find(([key]) => normalise(key).includes(normalise(name))); | |
| if (partial) return { endpoint: partial[0], spec: partial[1] }; | |
| } | |
| return { endpoint: names[0], spec: null }; | |
| } | |
| function buildPayload(spec, values, fallback) { | |
| const params = spec?.parameters; | |
| if (!Array.isArray(params) || !params.length) return fallback; | |
| return params.map(param => { | |
| const name = normalise(param.parameter_name || param.label || param.name || param.component); | |
| if (Object.prototype.hasOwnProperty.call(values, name)) return values[name]; | |
| for (const [alias, value] of Object.entries(values)) { | |
| if (alias && name && (name.includes(alias) || alias.includes(name))) return value; | |
| } | |
| if ("parameter_default" in param) return param.parameter_default; | |
| return null; | |
| }); | |
| } | |
| function withTimeout(promise, milliseconds, message) { | |
| let timer; | |
| const timeout = new Promise((_, reject) => { | |
| timer = setTimeout(() => reject(new Error(message)), milliseconds); | |
| }); | |
| return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); | |
| } | |
| function backendIsCoolingDown(spaceId) { | |
| const failedAt = backendFailures.get(spaceId); | |
| if (!failedAt) return false; | |
| if (Date.now() - failedAt >= BACKEND_FAILURE_COOLDOWN_MS) { | |
| backendFailures.delete(spaceId); | |
| return false; | |
| } | |
| return true; | |
| } | |
| function markBackendFailed(spaceId) { | |
| backendFailures.set(spaceId, Date.now()); | |
| } | |
| function backendSupportsAidmaApi(apiDescription) { | |
| const named = apiDescription?.named_endpoints || {}; | |
| const names = Object.keys(named).map(normalise); | |
| const hasAdd = names.some(name => name.includes(normalise("add_custom_lora"))); | |
| const hasRun = names.some(name => name.includes(normalise("run_lora"))); | |
| return hasAdd && hasRun; | |
| } | |
| function resetBackendConnection() { | |
| client = null; | |
| api = null; | |
| activeBackend = null; | |
| updateQueueUI(); | |
| } | |
| async function connectBackend(index, hfToken) { | |
| const backend = BACKEND_SPACES[index]; | |
| if (!backend) throw new Error("Unknown backend index."); | |
| // إذا لدينا نفس الباكند ونفس التوكن، أعد استخدام العميل | |
| if (client && activeBackend?.id === backend.id && activeBackend?.token === (hfToken||null)) return client; | |
| resetBackendConnection(); | |
| activeBackend = {...backend, token: hfToken||null}; | |
| updateQueueUI(); | |
| $("loadingTitle").textContent = `Connecting to ${backend.label}…`; | |
| const tokenInfo = hfToken ? `باستخدام ${maskToken(hfToken)}` : 'بدون توكن'; | |
| $("queueStatus").textContent = `${backend.id} | ${tokenInfo}`; | |
| const connectOptions = { | |
| events: ["data", "status"], | |
| space_status: status => { | |
| if (status?.status && status.status !== "running") { | |
| $("queueStatus").textContent = `${backend.label}: ${status.status} | ${tokenInfo}`; | |
| } | |
| } | |
| }; | |
| if(hfToken){ | |
| connectOptions.hf_token = hfToken; | |
| } | |
| const newClient = await withTimeout( | |
| Client.connect(backend.id, connectOptions), | |
| BACKEND_CONNECT_TIMEOUT_MS, | |
| `${backend.label} connection timed out` | |
| ); | |
| const newApi = await withTimeout( | |
| newClient.view_api(), | |
| BACKEND_CONNECT_TIMEOUT_MS, | |
| `${backend.label} API check timed out` | |
| ); | |
| if (!backendSupportsAidmaApi(newApi)) { | |
| throw new Error(`${backend.label} is not compatible with the required AIDMA API`); | |
| } | |
| client = newClient; | |
| api = newApi; | |
| return client; | |
| } | |
| function errorText(error) { | |
| return String(error?.message || error || "").replace(/\s+/g, " ").trim(); | |
| } | |
| function isCallerZeroGpuQuotaError(error) { | |
| const text = errorText(error).toLowerCase(); | |
| return ( | |
| text.includes("exceeded your free zerogpu quota") || | |
| text.includes("zerogpu quota") && text.includes("left") || | |
| text.includes("daily gpu quota") || | |
| text.includes("try again in") && text.includes("quota") || | |
| text.includes("quota exceeded") || | |
| text.includes("no gpu") && text.includes("quota") | |
| ); | |
| } | |
| async function generateWithBackendFailover(job, hfToken) { | |
| const errors = []; | |
| for (let offset = 0; offset < BACKEND_SPACES.length; offset++) { | |
| const index = (preferredBackendIndex + offset) % BACKEND_SPACES.length; | |
| const backend = BACKEND_SPACES[index]; | |
| if (backendIsCoolingDown(backend.id)) { | |
| continue; | |
| } | |
| try { | |
| await connectBackend(index, hfToken); | |
| $("queueStatus").textContent = `${backend.label}: loading AIDMA LoRA | Token ${hfToken?maskToken(hfToken):'anon'}`; | |
| const loaded = await loadSelectedLora(job); | |
| $("queueStatus").textContent = | |
| `${backend.label}: waiting for remote GPU and final image | ${hfToken?maskToken(hfToken):''}`; | |
| await runGeneration(job, loaded.selectedIndex, loaded.trigger); | |
| preferredBackendIndex = index; | |
| backendFailures.delete(backend.id); | |
| return; | |
| } catch (error) { | |
| const message = errorText(error); | |
| console.error(`${backend.label} failed:`, error); | |
| if (isCallerZeroGpuQuotaError(error)) { | |
| resetBackendConnection(); | |
| throw new Error(message); | |
| } | |
| errors.push(`${backend.label}: ${message || "unknown error"}`); | |
| markBackendFailed(backend.id); | |
| resetBackendConnection(); | |
| if (offset + 1 < BACKEND_SPACES.length) { | |
| let nextBackend = null; | |
| for (let ahead = offset + 1; ahead < BACKEND_SPACES.length; ahead++) { | |
| const nextIndex = (preferredBackendIndex + ahead) % BACKEND_SPACES.length; | |
| const candidate = BACKEND_SPACES[nextIndex]; | |
| if (!backendIsCoolingDown(candidate.id)) { | |
| nextBackend = candidate; | |
| break; | |
| } | |
| } | |
| const tokenInfo = hfToken? maskToken(hfToken): ''; | |
| $("queueStatus").textContent = nextBackend | |
| ? `${backend.label} failed. Switching to ${nextBackend.label}… ${tokenInfo}` | |
| : `${backend.label} failed. No healthy backend remains.`; | |
| } | |
| } | |
| } | |
| throw new Error( | |
| "All configured AIDMA Spaces failed. " + errors.join(" | ") | |
| ); | |
| } | |
| async function attemptJobWithTokenRotation(job){ | |
| while(true){ | |
| const activeToken = tokenManager.getActive(); | |
| const hasPool = tokenManager.tokens.length>0; | |
| if(hasPool && !activeToken){ | |
| throw new Error("انتهت جميع التوكنات! كل توكن استهلك 5 دقائق. أضف توكنات جديدة للمتابعة. (لو حطيت 30 توكن = 150 دقيقة كانت متاحة)"); | |
| } | |
| const tokenValue = activeToken ? activeToken.value : null; | |
| const tokenId = activeToken ? activeToken.id : null; | |
| if(tokenId){ | |
| tokenManager.setActive(tokenId); | |
| renderTokens(); | |
| $("activeChip").textContent = `Token: ${maskToken(activeToken.value)} | ${formatTime(activeToken.remaining)} left`; | |
| } | |
| if(tokenId) startTokenCountdown(tokenId); | |
| try{ | |
| const jobStart = Date.now(); | |
| await generateWithBackendFailover(job, tokenValue); | |
| stopTokenCountdown(); | |
| // إذا كان هناك عداد زمني شغال، فاستهلاكه تم بالفعل عبر الانترفال | |
| // لكن لتكون دقيقه نحسب ما تبقى من الانترفال ونزامن | |
| renderTokens(); | |
| const t = tokenId ? tokenManager.tokens.find(x=>x.id===tokenId) : null; | |
| if(t){ | |
| if(t.remaining <= 15 && tokenManager.tokens.filter(x=>x.remaining>0).length>1){ | |
| const next = tokenManager.tokens.find(x=>x.remaining>0 && x.id!==tokenId); | |
| if(next){ | |
| $("status").textContent = `التوكن ${maskToken(t.value)} أوشك على الانتهاء (${formatTime(t.remaining)} متبقي). التالي سيكون ${maskToken(next.value)} تلقائياً.`; | |
| } | |
| } | |
| if(t.remaining<=0){ | |
| const next = tokenManager.tokens.find(x=>x.remaining>0); | |
| if(next){ | |
| $("queueStatus").textContent = `انتهى ${maskToken(t.value)} (5 دقائق). الانتقال تلقائياً إلى ${maskToken(next.value)}`; | |
| } | |
| } | |
| } | |
| return; | |
| }catch(err){ | |
| stopTokenCountdown(); | |
| const isQuota = isCallerZeroGpuQuotaError(err); | |
| if(isQuota && tokenId){ | |
| console.warn(`Token exhausted: ${maskToken(tokenManager.tokens.find(x=>x.id===tokenId)?.value)}`); | |
| tokenManager.exhaust(tokenId, err.message); | |
| renderTokens(); | |
| const stats = tokenManager.getStats(); | |
| $("queueStatus").textContent = `🚨 التوكن ${maskToken(activeToken.value)} انتهت حصته (5 دقائق). متبقي ${stats.ready} توكن و ${formatTime(stats.totalRemaining)} - جاري التبديل...`; | |
| // انتظر قليلا ثم حاول بالتوكن التالي لنفس الوظيفة | |
| await new Promise(r=>setTimeout(r,1200)); | |
| continue; | |
| }else{ | |
| throw err; | |
| } | |
| } | |
| } | |
| } | |
| function snapshotJob() { | |
| const prompt = $("prompt").value.trim(); | |
| if (!prompt) throw new Error("Write a prompt first."); | |
| const modelOption = $("model").selectedOptions[0]; | |
| const [width, height] = $("ratio").value.split("x").map(Number); | |
| const styleName = $("style").value.trim(); | |
| const styleText = STYLE_DETAIL[styleName] ?? styleName; | |
| const parts = [prompt]; | |
| if (styleText && normalise(styleName) !== normalise("No extra style")) parts.push(styleText); | |
| if ($("boost").checked) parts.push(PROMPT_BOOST); | |
| return { | |
| id: ++jobCounter, | |
| repo: modelOption.value, | |
| trigger: modelOption.dataset.trigger || "aidmaMJ6.1", | |
| modelLabel: modelOption.textContent.trim(), | |
| prompt: parts.join(", "), | |
| width, | |
| height, | |
| steps: Number($("quality").value), | |
| seed: crypto.getRandomValues(new Uint32Array(1))[0], | |
| cfg: 3.5, | |
| loraScale: 0.95 | |
| }; | |
| } | |
| function remoteFileUrl(value) { | |
| if (typeof value !== "string") return null; | |
| const text = value.trim(); | |
| if (!text) return null; | |
| if (/^(https?:\/\/|blob:|data:image\/)/i.test(text)) return text; | |
| if (text.startsWith("/gradio_api/")) return `${activeBackend?.origin || ""}${text}`; | |
| if (text.startsWith("gradio_api/")) return `${activeBackend?.origin || ""}/${text}`; | |
| if ( | |
| text.startsWith("/tmp/") || | |
| text.includes("/gradio/") || | |
| /\.(png|jpe?g|webp|gif|bmp)(\?|$)/i.test(text) | |
| ) { | |
| return `${activeBackend?.origin || ""}/gradio_api/file=${encodeURI(text)}`; | |
| } | |
| return null; | |
| } | |
| function extractImageUrl(value, seen = new Set()) { | |
| if (value == null) return null; | |
| if (typeof Blob !== "undefined" && value instanceof Blob) { | |
| return URL.createObjectURL(value); | |
| } | |
| if (typeof File !== "undefined" && value instanceof File) { | |
| return URL.createObjectURL(value); | |
| } | |
| if (typeof value === "string") { | |
| const trimmed = value.trim(); | |
| if (/^[A-Za-z0-9+/=\s]{1000,}$/.test(trimmed)) { | |
| return `data:image/png;base64,${trimmed.replace(/\s+/g, "")}`; | |
| } | |
| return remoteFileUrl(trimmed); | |
| } | |
| if (typeof value !== "object") return null; | |
| if (seen.has(value)) return null; | |
| seen.add(value); | |
| if (Array.isArray(value)) { | |
| for (const item of value) { | |
| const found = extractImageUrl(item, seen); | |
| if (found) return found; | |
| } | |
| return null; | |
| } | |
| for (const key of ["url", "path", "orig_name", "name", "image", "data"]) { | |
| if (Object.prototype.hasOwnProperty.call(value, key)) { | |
| const found = extractImageUrl(value[key], seen); | |
| if (found) return found; | |
| } | |
| } | |
| for (const item of Object.values(value)) { | |
| const found = extractImageUrl(item, seen); | |
| if (found) return found; | |
| } | |
| return null; | |
| } | |
| function showImage(url, job) { | |
| $("image").src = url; | |
| $("download").href = url; | |
| $("download").download = `aidma-${job.seed}.png`; | |
| $("download").style.display = "inline-flex"; | |
| setViewer("image", `${job.modelLabel} · seed ${job.seed}`); | |
| } | |
| async function loadSelectedLora(job) { | |
| const { endpoint, spec } = endpointInfo(["/add_custom_lora", "add_custom_lora"]); | |
| const payload = buildPayload( | |
| spec, | |
| { | |
| customlora: job.repo, | |
| lorarepository: job.repo, | |
| huggingfacepath: job.repo | |
| }, | |
| [job.repo] | |
| ); | |
| const result = await client.predict(endpoint, payload); | |
| const data = result?.data || []; | |
| const selectedIndex = Number.isFinite(Number(data[4])) ? Number(data[4]) : null; | |
| const trigger = typeof data[5] === "string" && data[5].trim() ? data[5].trim() : job.trigger; | |
| return { selectedIndex, trigger }; | |
| } | |
| async function runGeneration(job, selectedIndex, trigger) { | |
| const { endpoint, spec } = endpointInfo(["/run_lora", "run_lora"]); | |
| const finalPrompt = normalise(job.prompt).includes(normalise(trigger)) | |
| ? job.prompt | |
| : `${trigger}, ${job.prompt}`; | |
| const values = { | |
| prompt: finalPrompt, | |
| prompttext: finalPrompt, | |
| imageinput: null, | |
| inputimage: null, | |
| image: null, | |
| imagestrength: 0.75, | |
| denoisestrength: 0.75, | |
| cfgscale: job.cfg, | |
| guidancescale: job.cfg, | |
| steps: job.steps, | |
| numinferencesteps: job.steps, | |
| selectedindex: selectedIndex, | |
| randomizeseed: false, | |
| seed: job.seed, | |
| width: job.width, | |
| height: job.height, | |
| lorascale: job.loraScale, | |
| adapterstrength: job.loraScale | |
| }; | |
| const fallbackWithoutState = [ | |
| finalPrompt, null, 0.75, job.cfg, job.steps, false, | |
| job.seed, job.width, job.height, job.loraScale | |
| ]; | |
| const payload = buildPayload(spec, values, fallbackWithoutState); | |
| $("queueStatus").textContent = "Waiting for the remote GPU and final image"; | |
| const response = await client.predict(endpoint, payload); | |
| const outputs = response?.data ?? response; | |
| console.log("AIDMA final Gradio outputs:", outputs); | |
| const imageOutput = Array.isArray(outputs) ? outputs[0] : outputs; | |
| const returnedSeed = Array.isArray(outputs) ? Number(outputs[1]) : NaN; | |
| const finalUrl = extractImageUrl(imageOutput) || extractImageUrl(outputs); | |
| if (!finalUrl) { | |
| console.error("Unrecognized final Gradio image payload:", outputs); | |
| throw new Error( | |
| "The remote Space did not provide a usable final image. " + | |
| "It may have rejected this LoRA or changed its API." | |
| ); | |
| } | |
| if (Number.isFinite(returnedSeed)) job.seed = returnedSeed; | |
| showImage(finalUrl, job); | |
| return finalUrl; | |
| } | |
| async function processQueue() { | |
| if (processing || !localQueue.length) return; | |
| processing = true; | |
| updateQueueUI(); | |
| while (localQueue.length) { | |
| const job = localQueue.shift(); | |
| updateQueueUI(); | |
| $("download").style.display = "none"; | |
| $("loadingTitle").textContent = `Generating job #${job.id}…`; | |
| const activeTok = tokenManager.getActive(); | |
| if(activeTok){ | |
| $("queueStatus").textContent = `Preparing AIDMA LoRA | Token ${maskToken(activeTok.value)} - ${formatTimeArabic(activeTok.remaining)} متبقي`; | |
| } else { | |
| $("queueStatus").textContent = "Preparing AIDMA LoRA"; | |
| } | |
| $("status").className = ""; | |
| setViewer("loading", `Job #${job.id} is running.`); | |
| try { | |
| await attemptJobWithTokenRotation(job); | |
| } catch (error) { | |
| console.error(error); | |
| $("status").className = "error"; | |
| $("status").textContent = error?.message || String(error); | |
| $("empty").innerHTML = | |
| '<div class="orb">!</div><strong>Generation failed.</strong>' + | |
| '<div style="margin-top:8px">' + escapeHtml(error?.message || String(error)) + '</div>'; | |
| setViewer("empty"); | |
| resetBackendConnection(); | |
| stopTokenCountdown(); | |
| } finally { | |
| renderTokens(); | |
| } | |
| } | |
| processing = false; | |
| updateQueueUI(); | |
| updateLiveIndicator(); | |
| } | |
| function escapeHtml(value) { | |
| return String(value).replace(/[&<>"']/g, char => ({ | |
| "&":"&","<":"<",">":">",'"':""","'":"'" | |
| })[char]); | |
| } | |
| // ===== Events ===== | |
| $("generate").addEventListener("click", () => { | |
| try { | |
| if(tokenManager.tokens.length>0 && !tokenManager.getActive()){ | |
| throw new Error("انتهت جميع التوكنات (5 دقائق لكل توكن). أضف توكنات جديدة."); | |
| } | |
| const job = snapshotJob(); | |
| localQueue.push(job); | |
| $("status").className = ""; | |
| const activeTok = tokenManager.getActive(); | |
| if(activeTok){ | |
| const stats = tokenManager.getStats(); | |
| $("status").textContent = `Job #${job.id} أضيف للطابور. سيتم استخدام ${maskToken(activeTok.value)} - متبقي كلي ${formatTime(stats.totalRemaining)}`; | |
| } else { | |
| $("status").textContent = `Job #${job.id} added to your browser queue.`; | |
| } | |
| updateQueueUI(); | |
| processQueue(); | |
| } catch (error) { | |
| $("status").className = "error"; | |
| $("status").textContent = error.message; | |
| $("prompt").focus(); | |
| } | |
| }); | |
| $("prompt").addEventListener("keydown", event => { | |
| if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { | |
| event.preventDefault(); | |
| $("generate").click(); | |
| } | |
| }); | |
| // Token Events | |
| $("addTokensBtn").addEventListener("click", ()=>{ | |
| const raw = $("tokenBulkInput").value; | |
| if(!raw.trim()){ | |
| $("status").textContent = "الصق توكن واحد على الأقل"; | |
| $("status").className = "error"; | |
| return; | |
| } | |
| const result = tokenManager.addBulk(raw); | |
| $("tokenBulkInput").value = ""; | |
| renderTokens(); | |
| $("status").className = ""; | |
| if(result.added>0){ | |
| $("status").textContent = `✅ تمت إضافة ${result.added} توكن جديد! ${result.skipped>0?`(${result.skipped} مكرر تم تخطيه)` : ''} - إجمالي الوقت: ${formatTimeArabic(result.added*TOKEN_QUOTA_SECONDS)}`; | |
| if(result.added>=30){ | |
| $("status").textContent += ` | 30 توكن = ${30*5}=150 دقيقة كما طلبت!`; | |
| } | |
| } else { | |
| $("status").textContent = result.skipped>0 ? `جميع التوكنات مكررة (${result.skipped})` : "لم يتم العثور على توكنات صالحة"; | |
| $("status").className = "error"; | |
| } | |
| }); | |
| $("clearTokensBtn").addEventListener("click", ()=>{ | |
| if(tokenManager.tokens.length===0){ $("status").textContent = "لا يوجد توكنات لمسحها"; return; } | |
| if(confirm(`هل أنت متأكد من مسح جميع التوكنات (${tokenManager.tokens.length})؟`)){ | |
| tokenManager.clear(); | |
| renderTokens(); | |
| $("status").textContent = "تم مسح جميع التوكنات"; | |
| } | |
| }); | |
| // Init | |
| tokenManager.load(); | |
| renderTokens(); | |
| updateQueueUI(); | |
| console.log(`Token system ready: each token = ${TOKEN_QUOTA_SECONDS}s`); | |
| </script> | |
| </body> | |
| </html> | |