Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>FFD-XL-3.0</title> | |
| <style> | |
| :root{--bg:#0a0a14;--surface:#141425;--surface2:#1e1e3a;--accent:#7c3aed;--accent2:#e94560;--text:#e8e8f0;--dim:#6b6b8a;--border:#2a2a45;--ok:#10b981;--warn:#f59e0b;--err:#ef4444;--r:8px} | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);min-height:100vh} | |
| .header{background:var(--surface);border-bottom:1px solid var(--border);padding:12px 20px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px} | |
| .header h1{font-size:20px;background:linear-gradient(135deg,var(--accent),var(--accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent} | |
| .header-right{display:flex;align-items:center;gap:16px;font-size:13px;color:var(--dim)} | |
| .conn-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:4px} | |
| .conn-dot.online{background:var(--ok);box-shadow:0 0 6px var(--ok)} | |
| .conn-dot.offline{background:var(--err);box-shadow:0 0 6px var(--err)} | |
| .main{display:grid;grid-template-columns:380px 1fr;gap:0;min-height:calc(100vh - 52px)} | |
| @media(max-width:800px){.main{grid-template-columns:1fr}} | |
| .panel-left{background:var(--surface);border-right:1px solid var(--border);padding:16px;overflow-y:auto;max-height:calc(100vh - 52px)} | |
| .panel-right{padding:20px;overflow-y:auto;max-height:calc(100vh - 52px)} | |
| label{display:block;font-size:12px;color:var(--dim);margin-bottom:4px;font-weight:600;text-transform:uppercase;letter-spacing:.5px} | |
| textarea,input[type="number"],select{width:100%;background:var(--surface2);border:1px solid var(--border);color:var(--text);padding:10px;border-radius:var(--r);font-size:14px;outline:none;transition:border-color .2s} | |
| textarea:focus,input:focus,select:focus{border-color:var(--accent)} | |
| textarea{resize:vertical;font-family:inherit} | |
| .slider-group{margin-bottom:12px} | |
| .slider-label{display:flex;justify-content:space-between;font-size:12px;color:var(--dim);margin-bottom:4px} | |
| .slider-label span{color:var(--accent);font-weight:700} | |
| input[type="range"]{width:100%;-webkit-appearance:none;height:6px;background:var(--border);border-radius:3px;outline:none} | |
| input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:var(--accent);cursor:pointer} | |
| .checkbox-group{display:flex;align-items:center;gap:8px;margin-bottom:8px} | |
| .checkbox-group input[type="checkbox"]{accent-color:var(--accent);width:16px;height:16px} | |
| .checkbox-group label{margin:0;text-transform:none;font-size:14px;color:var(--text);font-weight:400} | |
| details{margin-bottom:12px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);overflow:hidden} | |
| summary{padding:10px 14px;cursor:pointer;font-size:14px;font-weight:600;user-select:none;list-style:none} | |
| summary::before{content:'▸ '}details[open]>summary::before{content:'▾ '} | |
| summary:hover{background:rgba(124,58,237,.1)} | |
| .details-body{padding:12px 14px;border-top:1px solid var(--border)} | |
| .row{display:grid;grid-template-columns:1fr 1fr;gap:10px} | |
| #generate-btn{width:100%;padding:14px;font-size:18px;font-weight:700;background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;border:none;border-radius:var(--r);cursor:pointer;transition:transform .1s,box-shadow .2s,opacity .2s;margin-top:12px} | |
| #generate-btn:hover{transform:translateY(-1px);box-shadow:0 4px 20px rgba(124,58,237,.4)} | |
| #generate-btn:active{transform:translateY(0)} | |
| #generate-btn:disabled{opacity:.5;cursor:not-allowed;transform:none;box-shadow:none} | |
| .gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-bottom:16px} | |
| .gallery-item{position:relative;border-radius:var(--r);overflow:hidden;cursor:pointer;background:var(--surface);border:1px solid var(--border);transition:transform .2s,border-color .2s} | |
| .gallery-item:hover{transform:translateY(-2px);border-color:var(--accent)} | |
| .gallery-item img{width:100%;display:block} | |
| .gallery-item .dl-btn{position:absolute;bottom:8px;right:8px;background:rgba(0,0,0,.7);color:#fff;border:none;border-radius:6px;padding:6px 10px;cursor:pointer;font-size:14px;opacity:0;transition:opacity .2s} | |
| .gallery-item:hover .dl-btn{opacity:1} | |
| .info-panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:14px;font-size:13px;line-height:1.6;margin-bottom:12px} | |
| .info-panel strong{color:var(--accent)} | |
| .status-bar{padding:10px 14px;border-radius:var(--r);font-size:13px;font-weight:600} | |
| .status-bar.idle{background:var(--surface2);color:var(--dim)} | |
| .status-bar.working{background:rgba(124,58,237,.15);color:var(--accent);border:1px solid rgba(124,58,237,.3)} | |
| .status-bar.success{background:rgba(16,185,129,.15);color:var(--ok);border:1px solid rgba(16,185,129,.3)} | |
| .status-bar.error{background:rgba(239,68,68,.15);color:var(--err);border:1px solid rgba(239,68,68,.3)} | |
| .token-stats{display:flex;gap:16px;font-size:12px} | |
| .stat-item{display:flex;align-items:center;gap:4px} | |
| .stat-val{font-weight:700;color:var(--text)} | |
| .lightbox{display:none;position:fixed;inset:0;background:rgba(0,0,0,.9);z-index:1000;justify-content:center;align-items:center;cursor:pointer} | |
| .lightbox.active{display:flex} | |
| .lightbox img{max-width:95vw;max-height:95vh;object-fit:contain;border-radius:4px} | |
| .modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:999;justify-content:center;align-items:center} | |
| .modal-overlay.active{display:flex} | |
| .modal{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:24px;width:440px;max-width:90vw} | |
| .modal h2{margin-bottom:16px;font-size:18px} | |
| .modal input{margin-bottom:12px} | |
| .modal .btn-row{display:flex;gap:8px;justify-content:flex-end} | |
| .modal button{padding:8px 16px;border-radius:var(--r);border:1px solid var(--border);cursor:pointer;font-size:14px} | |
| .modal .btn-save{background:var(--accent);color:#fff;border:none} | |
| .modal .btn-cancel{background:transparent;color:var(--dim)} | |
| .empty-state{text-align:center;padding:60px 20px;color:var(--dim)} | |
| .empty-state .icon{font-size:48px;margin-bottom:12px} | |
| .spinner{display:inline-block;width:16px;height:16px;border:2px solid var(--dim);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;margin-right:6px;vertical-align:middle} | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| .log-box{background:#0d0d1a;border:1px solid var(--border);border-radius:var(--r);padding:10px;font-family:'Courier New',monospace;font-size:11px;color:var(--dim);max-height:300px;overflow-y:auto;margin-top:8px;white-space:pre-wrap;word-break:break-all} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <h1>🚀 FFD-XL-3.0</h1> | |
| <div class="header-right"> | |
| <div class="token-stats"> | |
| <div class="stat-item">🕵️ <span class="stat-val" id="statActive">85</span>/<span id="statTotal">85</span></div> | |
| <div class="stat-item">✅ <span class="stat-val" id="statRate">-</span></div> | |
| <div class="stat-item">📡 <span class="conn-dot offline" id="connDot"></span><span id="connText">Disconnected</span></div> | |
| </div> | |
| <button onclick="openSettings()" style="background:none;border:1px solid var(--border);color:var(--dim);border-radius:6px;padding:4px 10px;cursor:pointer;font-size:16px">⚙️</button> | |
| </div> | |
| </div> | |
| <div class="main"> | |
| <div class="panel-left"> | |
| <div style="margin-bottom:12px"> | |
| <label>Prompt</label> | |
| <textarea id="prompt" rows="4">masterpiece, best quality, 1girl, beautiful detailed eyes, BREAK high resolution, 8k uhd</textarea> | |
| </div> | |
| <div style="margin-bottom:12px"> | |
| <label>Negative Prompt</label> | |
| <textarea id="negPrompt" rows="2">lowres, bad anatomy, bad hands, text, error, worst quality, low quality</textarea> | |
| </div> | |
| <details open> | |
| <summary>⏱️ Token Budget</summary> | |
| <div class="details-body"> | |
| <select id="tokenBudget"> | |
| <option value="Auto" selected>Auto (estimate)</option> | |
| <option value="10">10s</option><option value="15">15s</option> | |
| <option value="30">30s</option><option value="60">60s</option> | |
| <option value="120">120s</option> | |
| </select> | |
| </div> | |
| </details> | |
| <details open> | |
| <summary>🎯 Sampler & Schedule</summary> | |
| <div class="details-body"> | |
| <div class="row"> | |
| <div><label>Sampler</label><select id="sampler"></select></div> | |
| <div><label>Schedule</label><select id="schedule"></select></div> | |
| </div> | |
| </div> | |
| </details> | |
| <details open> | |
| <summary>⚙️ Settings</summary> | |
| <div class="details-body"> | |
| <div class="slider-group"> | |
| <div class="slider-label"><label>Steps</label><span id="stepsVal">28</span></div> | |
| <input type="range" id="steps" min="1" max="100" value="28" oninput="document.getElementById('stepsVal').textContent=this.value"> | |
| </div> | |
| <div class="slider-group"> | |
| <div class="slider-label"><label>CFG Scale</label><span id="cfgVal">7.0</span></div> | |
| <input type="range" id="cfg" min="1" max="30" value="7" step="0.1" oninput="document.getElementById('cfgVal').textContent=parseFloat(this.value).toFixed(1)"> | |
| </div> | |
| <div class="row" style="margin-bottom:12px"> | |
| <div><label>Seed (-1=random)</label><input type="number" id="seed" value="-1"></div> | |
| <div><label>Batch Size</label><input type="number" id="batch" value="1" min="1" max="8"></div> | |
| </div> | |
| <div class="row"> | |
| <div><label>Width</label><select id="width"></select></div> | |
| <div><label>Height</label><select id="height"></select></div> | |
| </div> | |
| </div> | |
| </details> | |
| <details> | |
| <summary>🌸 LoRA</summary> | |
| <div class="details-body"> | |
| <div style="margin-bottom:10px"><label>LoRA</label><select id="lora"><option value="None">None</option></select></div> | |
| <div class="slider-group"> | |
| <div class="slider-label"><label>LoRA Scale</label><span id="loraScaleVal">1.00</span></div> | |
| <input type="range" id="loraScale" min="0" max="2" value="1" step="0.05" oninput="document.getElementById('loraScaleVal').textContent=parseFloat(this.value).toFixed(2)"> | |
| </div> | |
| </div> | |
| </details> | |
| <details> | |
| <summary>🔮 Advanced Guidance</summary> | |
| <div class="details-body"> | |
| <div class="checkbox-group"><input type="checkbox" id="cfgRescale"><label for="cfgRescale">CFG Rescale</label></div> | |
| <div class="slider-group"> | |
| <div class="slider-label"><label>Rescale</label><span id="rescaleVal">0.70</span></div> | |
| <input type="range" id="rescale" min="0" max="1" value="0.7" step="0.05" oninput="document.getElementById('rescaleVal').textContent=parseFloat(this.value).toFixed(2)"> | |
| </div> | |
| <div class="checkbox-group" style="margin-top:12px"><input type="checkbox" id="cfgDecay"><label for="cfgDecay">CFG Decay</label></div> | |
| <div class="row"> | |
| <div class="slider-group"> | |
| <div class="slider-label"><label>Start</label><span id="decayStartVal">7.0</span></div> | |
| <input type="range" id="decayStart" min="1" max="30" value="7" step="0.5" oninput="document.getElementById('decayStartVal').textContent=parseFloat(this.value).toFixed(1)"> | |
| </div> | |
| <div class="slider-group"> | |
| <div class="slider-label"><label>End</label><span id="decayEndVal">2.0</span></div> | |
| <input type="range" id="decayEnd" min="1" max="30" value="2" step="0.5" oninput="document.getElementById('decayEndVal').textContent=parseFloat(this.value).toFixed(1)"> | |
| </div> | |
| </div> | |
| <div style="margin-bottom:12px"> | |
| <label>Decay Curve</label> | |
| <select id="decayCurve"> | |
| <option value="linear">Linear</option><option value="cosine" selected>Cosine</option> | |
| <option value="exponential">Exponential</option><option value="step">Step</option> | |
| </select> | |
| </div> | |
| <div class="checkbox-group" style="margin-top:12px"><input type="checkbox" id="pag"><label for="pag">PAG</label></div> | |
| <div class="slider-group"> | |
| <div class="slider-label"><label>PAG Scale</label><span id="pagVal">3.0</span></div> | |
| <input type="range" id="pagScale" min="0" max="15" value="3" step="0.5" oninput="document.getElementById('pagVal').textContent=parseFloat(this.value).toFixed(1)"> | |
| </div> | |
| </div> | |
| </details> | |
| <button id="generate-btn" onclick="handleGenerate()">🎨 Generate (Ctrl+Enter)</button> | |
| </div> | |
| <div class="panel-right"> | |
| <div class="gallery" id="gallery"> | |
| <div class="empty-state"><div class="icon">🖼️</div><p>No images yet. Set backend URL (⚙️) then Generate!</p></div> | |
| </div> | |
| <div class="info-panel" id="infoPanel" style="display:none"></div> | |
| <div class="status-bar idle" id="statusBar">Ready</div> | |
| <div class="log-box" id="logBox"></div> | |
| </div> | |
| </div> | |
| <div class="lightbox" id="lightbox" onclick="window._closeLB()"><img id="lightboxImg" src="" alt=""></div> | |
| <div class="modal-overlay" id="settingsModal"> | |
| <div class="modal"> | |
| <h2>⚙️ Backend Settings</h2> | |
| <label>Backend Space URL</label> | |
| <input type="text" id="backendUrlInput" placeholder="https://username-space.hf.space"> | |
| <p style="font-size:12px;color:var(--dim);margin-bottom:16px"> | |
| ZeroGPU backend URL. Endpoint: <code>/call/generate</code><br> | |
| ✅ CORS_ALLOW_ALL=true + Public di backend.<br> | |
| ✅ Backend returns 4 outputs (Gallery, Info, Status, Base64 JSON) | |
| </p> | |
| <div class="btn-row"> | |
| <button class="btn-cancel" onclick="window._closeSettings()">Cancel</button> | |
| <button class="btn-save" onclick="window._saveSettings()">Save & Connect</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| // ===================================================================== | |
| // 📦 IMPORT @gradio/client | |
| // ===================================================================== | |
| let Client = null; | |
| let GRADIO_CLIENT_AVAILABLE = false; | |
| try { | |
| const mod = await import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"); | |
| Client = mod.Client; | |
| GRADIO_CLIENT_AVAILABLE = !!Client; | |
| } catch(e) { | |
| console.warn("@gradio/client CDN failed, trying esm.sh...", e); | |
| try { | |
| const mod2 = await import("https://esm.sh/@gradio/client"); | |
| Client = mod2.Client; | |
| GRADIO_CLIENT_AVAILABLE = !!Client; | |
| } catch(e2) { | |
| console.warn("All @gradio/client imports failed", e2); | |
| } | |
| } | |
| // ===================================================================== | |
| // 🌐 CONFIG | |
| // ===================================================================== | |
| let BACKEND_URL = localStorage.getItem('ffd_backend_url') || ''; | |
| // ===================================================================== | |
| // 🕵️ STEALTH TOKEN POOL — 85 TOKENS | |
| // ===================================================================== | |
| const RAW_PAYLOAD = "PiRCDDtPcPFMLWkTkVaZmzoleHOunXnLIA-BHvZXGICstaktSwycmwNmzHGrTNmKxnlRZ-ZdgawyTPzXIpwhnRYIteUKSMsWnEDtGKtM-nMiFYAFsINxAJWPwiCQlaunmdgmrcxKoaT-PccpUIbTckCiafwErDLkRlsvqhgtfZaBHL-faGyXBPfBkaHXDMUSJtxEggonhhZbomFIz-SndsPaRWsevDXCgZcSjTUlBYUJqOkSfFmn-CqobFdUpeVCeuhUaiuXwvdczBUmoUHXRGa-JKCQYUhhHPPkpucegqkNSyureLdXpmeXRF-tBYfslUwHNiNMufzwAYIlrDVovEWmOQulC-LKLdrdUxyUyKODSUthmqHXqDMfHrQueera-ivSBboJYQVcifWkCNcOTOnxUQrZOtOglnU-jiSbBMUmAniRpJOmVIlczuqpRjwSeuizLk-VcXaKQLEawBWZbNrBOSLTjrVtTuSvobhLL-ZrlTPvhDmYqZGGFuIqDDCrQRcWRhYcuyOI-FCambosUqUQJrThbIveHglnvjoNpOGWBsW-kUyoiWTbZlNfSrdTNaVINuwlNTQseFCfZB-WGarKlgPBzpJeKxpqirFgnKKAtOFBFomSe-IZwzmRBCALYfvYtmtvTWsIQYvHuRGUiGyr-NtijfwwAPQRknELkhIWjMQQUUqzgwhIjeu-obVKYRMqECBoLsBWOKyfVWtHlugAhhuaIH-EsDAvVqRZCbigQrpDFNinlVeijagnAjETW-yuMifxRJoXWKPRGgYFrXHXTGdoKBuCZCUU-YthKrdEtrmyDbBteZcGzNeoDqGAxzeEinv-JgNjfcunLsOBcZIaOYcFqgcZIZWjbnocJn-cINBgwvihyKiTpxwDTXjnHTnlQivLCluGJ-jnciPeeWUwQbHNITBRtOgPjnqWkgAqZDhq-uwTpdbUKmiKUZkpWgsOJyaywRBSPeSHcLY-ddTsgQvyVXUSRsYrcAioNbGnTsOVugcSpK-BoDGLtNQdSSiIvuveGBiCFJKzEEqQmZTbz-aKBQewTCodMpeijniAvkcsqoSEhMrbhiKE-hOrZAwkLlabOCjjZVStFoufImunhcjlEhz-NkfolaYSzHbBLkqkalzvbLJWHxDQfUEwsr-OoFVTidQQHCpKkDBnTsTvPeOogJkVfEWAZ-plFpieuUJmDJBhyPIdjBmDKKTVNcNpulXJ-AyHpfXRVxcrmiOFUYMLiNAaVtyLwwkdvWJ-pHNLBEtYhQwfUQJFAxtLcklIaIXyQQkIAt-CpXxqjXPCvnLEyfdlsbgdqXdotkMkzvmBU-PerSDYxXPlPviQNmACCXyzyIfNBnJgpXst-PKxgUunqjxFjeMUrhgPuHAfUYhrGsFPlBh-QaVcpabAfUrqncLNBwgXCkpksoGWWxgQxK-PdxIUKbxDsVyMeDtHozMnbQrQXwFxZjKdy-aMejPqEuMzjwYCKuUVQWOrcvfoWKtKbbgv-CVBXSUlqCHUOPgjbLtmkxXQieFECclgZkT-pMRYEHGgfGjuxgmKVFobnNhPpxmEoWgsZb-HGSvrRpieiWTbSqRVtdKVgeMHhxVQmakAE-yrUmRlsTkRjREynarJfHTAOGjcbCQAJDQd-PtPUYmOAhSihYOJpQClGZUsWXBBVzQLOrU-PLNxMJmXrCgzMNZtIJahbKQfmGFeNceLTE-zLAfTPijfcCqWHOLBuTjEeNXIcWVxsPbbq-yjgUeEDychEqlzSMSsRZnUFVvOtpAdWtAx-fKcKRTqWTLhgIqiCquYvYyqksBBwvXTlSm-uUdhNJtMIsIWxZZQmjOAoRwkbuhVbDRkUn-MunRYwrfqwMdLZEbZOGHpPPYnDmuDimopu-UWAwKSXCNzWvGmsdOhnmqeUIyTJfiNXIZt-iheoERRZTkhdAdnDHJatYgOiLapIgVTFJw-LLnfrrHopubyFJllMntnFliVrXFFXaAwoj-PGjVbqVHxzglCMkvteIIbdVtPJlCPxefLA-hkrmPSIlSUTnqsmQLsGECXCwxSmQLnJmEJ-gqxPdLdhmngjpzBznOrzIQqtOrsUGNYbBJ-QwsXWJiLNOCRMYuZGNsYxYTLpXygHmQjvW-yQBGhxEAztptTIQXgOYOoVqcAEfiCLdsFd-pFGVSiujNvgifJoBOHLLPogiOgOZyiQlqu-HwAhtTKtaGUOEjDcjfXVNOMFHKHyZlsQpI-wbnWdXOgZrkfktwYDlbiqCuhQRZLCsBvak-LDuJYfWVljMjsFFcbKahhsglPTjsvTJfqy-DAmhXglyPbLZAlbiekBRHnjWZrzdtJSbAL-SByeDprwukcociMPOhJKEtAodUOWSiETeq-oqfZPYkBcrgJiuWOPLINQImroLKjQDmbRZ-aJLwnooVCfTUssMXtAWIYfgsKhZPthlkYK-UqUQinbBqBVPuewmsxNImHMQCTIUWOpCdA-EiEjqpvtdAtxpUpnxIgRrKPBkUJuWaQJnA-cvXjppPnFhwdCbBFukSwNLVOkDOQEPUSqd-PoSgaPVVsdesVCucFerlntYWiJpgreWdGJ-UELvMUNsxlMemLmYiBMLDXIUHxTiWqIYqd-FSVAPeAzGEfLJIYRbSNxQxfTvseIkWVCZw-StCLoInFnlseUbPzqIFpFCCUCBsQjxzuIc-oHKZuJHonaPBaiGvYPMSENeXOcYIzcJTJy-cjmPeQZixhFdRdImgagBonMbwQhuTitAqM-dPztnFlmVxzpdbFbCWWfjWAZGTcahucGIi-NMEbWuaiULTGpoxulWSHwubeGeopaxiPIH-sGJhTjdDVTBsPPZRlXPYHlmBbPpGkymfVH-HwkuSfVGIzfUrnGXOYonaLgBqKMksniSmG-vnJYppejvDHxpxDIHgUoNBshieASIRvtXD-JybhNvxSGmhEMJTxnLktzLdempTyAEXYuu"; | |
| const GHOST = String.fromCharCode(104,102,95); | |
| const TOKENS = RAW_PAYLOAD.split('-').map(t => GHOST + t); | |
| // ===================================================================== | |
| // 🧠 TOKEN STATE MANAGER | |
| // | |
| // Per-token tracking: | |
| // cd = cooldown_until timestamp (0 = no cooldown) | |
| // lu = last_used timestamp | |
| // req = request count (how many times this token was picked) | |
| // err = error count (quota failures) | |
| // mc = max_cost (max budget seconds allowed for this token) | |
| // ===================================================================== | |
| const TS = {}; | |
| TOKENS.forEach(t => { TS[t] = { cd:0, lu:0, req:0, err:0, mc:Infinity }; }); | |
| function activeTokens() { | |
| const n = Date.now() / 1000; | |
| return TOKENS.filter(t => n > TS[t].cd); | |
| } | |
| function tokenStats() { | |
| const n = Date.now() / 1000; | |
| let a = 0, c = 0, r = 0, e = 0; | |
| TOKENS.forEach(t => { | |
| const s = TS[t]; | |
| n > s.cd ? a++ : c++; | |
| r += s.req; | |
| e += s.err; | |
| }); | |
| const usageCounts = TOKENS.map(t => TS[t].req).sort((a,b) => a-b); | |
| return { | |
| total: TOKENS.length, | |
| active: a, | |
| cooldown: c, | |
| requests: r, | |
| errors: e, | |
| rate: r > 0 ? ((r / (r + e)) * 100).toFixed(1) + '%' : 'N/A', | |
| usageMin: usageCounts[0], | |
| usageMax: usageCounts[usageCounts.length - 1], | |
| usageMedian: usageCounts[Math.floor(usageCounts.length / 2)] | |
| }; | |
| } | |
| // ===================================================================== | |
| // 🔍 Quota error detection + parsing | |
| // ===================================================================== | |
| function isQuotaError(m) { | |
| if (!m) return false; | |
| const l = m.toLowerCase(); | |
| return l.includes('exceeded') || l.includes('quota') || | |
| l.includes('rate limit') || l.includes('too many'); | |
| } | |
| function parseQuotaError(m) { | |
| const r = { rem: null, req: null, retry: null }; | |
| let x = m.match(/(-?\d+)s\s+left\s+vs\.?\s*(-?\d+)s\s+requested/); | |
| if (x) { r.rem = +x[1]; r.req = +x[2]; } | |
| if (r.rem === null) { | |
| x = m.match(/(-?\d+)s\s+requested\s+vs\.?\s*(-?\d+)s\s+left/); | |
| if (x) { r.req = +x[1]; r.rem = +x[2]; } | |
| } | |
| x = m.match(/(?:try again in|retry in)\s+(.+?)(?:\.|$)/i); | |
| if (x) r.retry = x[1].trim(); | |
| return r; | |
| } | |
| function parseRetrySecs(s) { | |
| if (!s) return null; | |
| let d = 0; | |
| const dm = s.match(/(\d+)\s+day/); | |
| if (dm) d = +dm[1]; | |
| const tm = s.match(/(\d+):(\d+):(\d+)/); | |
| if (tm) return d * 86400 + (+tm[1]) * 3600 + (+tm[2]) * 60 + (+tm[3]) + 60; | |
| return null; | |
| } | |
| // ===================================================================== | |
| // ⏱️ Duration estimation | |
| // ===================================================================== | |
| const TIERS = [10, 15, 30, 60, 120]; | |
| function estimateDur(steps, batch, w, h, pag, decay) { | |
| let base = ((w * h) / (1024 * 1024)) * steps * 0.12 * batch; | |
| if (pag) base *= 1.4; | |
| if (decay) base *= 1.05; | |
| const est = base * 1.3; | |
| for (const d of TIERS) if (est <= d) return d; | |
| return TIERS[TIERS.length - 1]; | |
| } | |
| // ===================================================================== | |
| // 🎯 Sampler registry | |
| // ===================================================================== | |
| const SAMPLERS = { | |
| "Euler": ["Normal", "Karras", "Exponential", "Polyexponential", "Beta", "SGM Uniform"], | |
| "Euler a": ["Normal", "Karras", "Exponential", "Polyexponential", "Beta", "SGM Uniform"], | |
| "Heun": ["Normal", "Karras", "Exponential", "Polyexponential", "Beta", "SGM Uniform"], | |
| "LMS": ["Normal", "Karras", "Exponential", "Polyexponential", "Beta"], | |
| "DPM2": ["Normal", "Karras", "Exponential", "Polyexponential", "Beta"], | |
| "DPM2 a": ["Normal", "Karras", "Exponential", "Polyexponential", "Beta"], | |
| "DPM++ 2M": ["Normal", "Karras", "Exponential", "Polyexponential", "Beta", "SGM Uniform"], | |
| "DPM++ 2M SDE": ["Normal", "Karras", "Exponential", "Polyexponential", "Beta", "SGM Uniform"], | |
| "DPM++ 3M": ["Normal", "Karras", "Exponential", "Polyexponential", "Beta", "SGM Uniform"], | |
| "DPM++ 3M SDE": ["Normal", "Karras", "Exponential", "Polyexponential", "Beta", "SGM Uniform"], | |
| "DPM++ SDE": ["Normal", "Karras", "Exponential", "Polyexponential", "Beta"], | |
| "EDM DPM++ 2M": ["Karras"], | |
| "DDIM": ["Normal", "Beta"], "PNDM": ["Normal", "Beta"], "DDPM": ["Normal", "Beta"], | |
| "UniPC": ["Normal", "Karras", "Exponential", "Polyexponential", "Beta", "SGM Uniform"], | |
| "DEIS": ["Normal", "Karras", "Exponential", "Polyexponential", "Beta"], | |
| "SA Solver": ["Normal", "Karras", "Exponential", "Polyexponential", "Beta"], | |
| "LCM": ["Fixed"], "TCD": ["Fixed"], | |
| }; | |
| const RES = [512, 576, 640, 704, 768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344, 1408, 1472, 1536]; | |
| // ===================================================================== | |
| // 📝 LOG | |
| // ===================================================================== | |
| function log(msg) { | |
| const box = document.getElementById('logBox'); | |
| const time = new Date().toLocaleTimeString(); | |
| box.textContent += `[${time}] ${msg}\n`; | |
| box.scrollTop = box.scrollHeight; | |
| console.log(`[${time}] ${msg}`); | |
| } | |
| // ===================================================================== | |
| // 🎨 UI INIT | |
| // ===================================================================== | |
| function initUI() { | |
| const sel = document.getElementById('sampler'); | |
| Object.keys(SAMPLERS).forEach(s => { | |
| const o = document.createElement('option'); | |
| o.value = s; o.textContent = s; | |
| if (s === 'Euler a') o.selected = true; | |
| sel.appendChild(o); | |
| }); | |
| sel.addEventListener('change', updateSchedules); | |
| updateSchedules(); | |
| ['width', 'height'].forEach(id => { | |
| const s = document.getElementById(id); | |
| RES.forEach(v => { | |
| const o = document.createElement('option'); | |
| o.value = v; o.textContent = v; | |
| if (id === 'width' && v === 832) o.selected = true; | |
| if (id === 'height' && v === 1216) o.selected = true; | |
| s.appendChild(o); | |
| }); | |
| }); | |
| } | |
| function updateSchedules() { | |
| const s = document.getElementById('sampler').value; | |
| const sc = SAMPLERS[s] || ['Normal']; | |
| const sel = document.getElementById('schedule'); | |
| sel.innerHTML = ''; | |
| sc.forEach(x => { | |
| const o = document.createElement('option'); | |
| o.value = x; o.textContent = x; | |
| sel.appendChild(o); | |
| }); | |
| } | |
| function updateTokenStatsUI() { | |
| const s = tokenStats(); | |
| document.getElementById('statActive').textContent = s.active; | |
| document.getElementById('statTotal').textContent = s.total; | |
| document.getElementById('statRate').textContent = s.rate; | |
| } | |
| function setStatus(text, type = 'idle') { | |
| const bar = document.getElementById('statusBar'); | |
| bar.className = 'status-bar ' + type; | |
| if (type === 'working') { | |
| bar.innerHTML = '<span class="spinner"></span>' + text; | |
| } else { | |
| bar.textContent = text; | |
| } | |
| } | |
| // ===================================================================== | |
| // 🔑 CUSTOM FETCH — Block Cookies (PERSIS seperti SODAGEN headers) | |
| // ===================================================================== | |
| function noCookieFetch(url, options = {}) { | |
| return fetch(url, { | |
| ...options, | |
| credentials: "omit", | |
| redirect: "follow", | |
| }); | |
| } | |
| // ===================================================================== | |
| // 🌐 CONNECTION CHECK | |
| // ===================================================================== | |
| async function checkConnection() { | |
| if (!BACKEND_URL) { setConn(false); return; } | |
| try { | |
| const r = await fetch(`${BACKEND_URL}/config`, { | |
| credentials: "omit", | |
| signal: AbortSignal.timeout(10000) | |
| }); | |
| if (r.ok) { | |
| const text = await r.text(); | |
| if (text.trim().startsWith('{')) { | |
| setConn(true); | |
| try { | |
| const config = JSON.parse(text); | |
| const ver = config.version || 'unknown'; | |
| const prefix = config.api_prefix || config.root_url || ''; | |
| log(`✅ Connected! Gradio v${ver} | prefix: "${prefix || '(none)'}"`); | |
| } catch(e) { | |
| log(`✅ Connected via /config`); | |
| } | |
| return; | |
| } | |
| } | |
| } catch(e) {} | |
| setConn(false); | |
| log(`❌ Backend unreachable`); | |
| } | |
| function setConn(online) { | |
| document.getElementById('connDot').className = 'conn-dot ' + (online ? 'online' : 'offline'); | |
| document.getElementById('connText').textContent = online ? 'Connected' : 'Disconnected'; | |
| } | |
| // ===================================================================== | |
| // 🎲 FISHER-YATES SHUFFLE (proper, unbiased) | |
| // ===================================================================== | |
| function shuffle(array) { | |
| const arr = [...array]; | |
| for (let i = arr.length - 1; i > 0; i--) { | |
| const j = Math.floor(Math.random() * (i + 1)); | |
| [arr[i], arr[j]] = [arr[j], arr[i]]; | |
| } | |
| return arr; | |
| } | |
| // ===================================================================== | |
| // 📋 GET ALL ELIGIBLE TOKENS FOR SESSION (PERSIS seperti SODAGEN) | |
| // | |
| // Filter tokens yang: | |
| // 1. Tidak dalam cooldown (now > cd) | |
| // 2. Budget fits (budgetSecs <= mc) | |
| // | |
| // Kalau TIDAK ada yang eligible → fallback ke SEMUA tokens (LRU first) | |
| // ===================================================================== | |
| function getSessionTokens(budgetSecs) { | |
| const now = Date.now() / 1000; | |
| const eligible = TOKENS.filter(t => | |
| now >= TS[t].cd && budgetSecs <= TS[t].mc | |
| ); | |
| if (eligible.length === 0) { | |
| log('⚠️ No eligible tokens — fallback ke ALL tokens (LRU first)'); | |
| return [...TOKENS].sort((a, b) => TS[a].lu - TS[b].lu); | |
| } | |
| return eligible; | |
| } | |
| // ===================================================================== | |
| // 🎯 SINGLE TOKEN GENERATION ATTEMPT | |
| // | |
| // Returns: outputData array (success) | |
| // Throws: Error (failure) | |
| // | |
| // - Quota error → re-thrown (caller will skip to next token) | |
| // - Network error → re-thrown (caller will skip to next token) | |
| // - Other errors → re-thrown (caller decides) | |
| // ===================================================================== | |
| async function generateWithToken(token, data, safeToken) { | |
| // ============================================================== | |
| // PATH A: @gradio/client (PRIMARY) | |
| // ============================================================== | |
| if (GRADIO_CLIENT_AVAILABLE && Client) { | |
| try { | |
| log(`📡 [A] ${safeToken}: @gradio/client connect+predict`); | |
| const client = await Client.connect(BACKEND_URL, { | |
| hf_token: token, | |
| fetch: noCookieFetch, | |
| }); | |
| setStatus(`${safeToken} | Waiting GPU...`, 'working'); | |
| const result = await client.predict("/generate", data); | |
| try { client.close(); } catch(e) {} | |
| log(`📦 [A] ${safeToken}: Response received!`); | |
| return result.data; | |
| } catch(e) { | |
| const msg = e.message || String(e); | |
| if (isQuotaError(msg)) throw e; | |
| log(`⚠️ [A] ${safeToken}: non-quota error, try fallback...`); | |
| } | |
| } | |
| // ============================================================== | |
| // PATH B: Raw Fetch FALLBACK | |
| // ============================================================== | |
| const CALL_PATHS = [ | |
| '/call/generate', | |
| '/gradio_api/call/generate', | |
| '/api/generate', | |
| ]; | |
| for (const basePath of CALL_PATHS) { | |
| try { | |
| log(`📡 [B] ${safeToken}: POST ${basePath}`); | |
| const postRes = await noCookieFetch(`${BACKEND_URL}${basePath}`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${token}`, | |
| }, | |
| body: JSON.stringify({ data }), | |
| }); | |
| if (postRes.status === 404) continue; | |
| if (!postRes.ok) { | |
| const errText = await postRes.text(); | |
| if (isQuotaError(errText)) throw new Error(errText); | |
| continue; | |
| } | |
| const { event_id } = await postRes.json(); | |
| if (!event_id) continue; | |
| log(`📬 ${safeToken}: event_id=${event_id.substring(0,8)}, streaming...`); | |
| const streamRes = await noCookieFetch( | |
| `${BACKEND_URL}${basePath}/${event_id}`, | |
| { headers: { 'Authorization': `Bearer ${token}` } } | |
| ); | |
| const reader = streamRes.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split('\n'); | |
| buffer = lines.pop() || ''; | |
| for (const line of lines) { | |
| if (line.startsWith('data: ')) { | |
| const jsonStr = line.slice(6).trim(); | |
| if (!jsonStr || jsonStr === 'null') continue; | |
| try { | |
| const parsed = JSON.parse(jsonStr); | |
| if (Array.isArray(parsed)) { | |
| log(`✅ [B] ${safeToken}: success!`); | |
| return parsed; | |
| } | |
| } catch(e) {} | |
| } | |
| } | |
| } | |
| } catch(e) { | |
| const msg = e.message || String(e); | |
| if (isQuotaError(msg)) throw e; | |
| continue; | |
| } | |
| } | |
| throw new Error(`${safeToken}: All paths failed`); | |
| } | |
| // ===================================================================== | |
| // 🎨 MAIN GENERATE — PERSIS seperti SODAGEN: | |
| // | |
| // 1. Ambil SEMUA eligible tokens (getSessionTokens) | |
| // 2. SHUFFLE (Fisher-Yates) — tidak ada token yang di-prioritize | |
| // 3. Loop through setiap token: | |
| // - Coba max RETRY_PER_TOKEN kali (untuk non-quota errors) | |
| // - Kalau SUCCESS → keluar loop, display hasil | |
| // - Kalau QUOTA error → cooldown token, lanjut ke token berikutnya | |
| // - Kalau NON-QUOTA error → retry dengan token yang SAMA (max 3x) | |
| // 4. Kalau SEMUA token gagal → fail (quota habis semua) | |
| // | |
| // Garansi: 85 token PASTI di-coba (atau yang tidak cooldown) | |
| // ===================================================================== | |
| const RETRY_PER_TOKEN = 3; // Max retry per token untuk non-quota errors | |
| const seenRems = new Set(); // Track quota remainders untuk verifikasi | |
| async function handleGenerate() { | |
| if (!BACKEND_URL) { openSettings(); return; } | |
| const btn = document.getElementById('generate-btn'); | |
| btn.disabled = true; | |
| // ================================================================ | |
| // Parse input parameters | |
| // ================================================================ | |
| const p = { | |
| prompt: document.getElementById('prompt').value, | |
| neg: document.getElementById('negPrompt').value, | |
| sampler: document.getElementById('sampler').value, | |
| schedule: document.getElementById('schedule').value, | |
| steps: parseInt(document.getElementById('steps').value), | |
| cfg: parseFloat(document.getElementById('cfg').value), | |
| seed: parseInt(document.getElementById('seed').value), | |
| batch: parseInt(document.getElementById('batch').value), | |
| width: parseInt(document.getElementById('width').value), | |
| height: parseInt(document.getElementById('height').value), | |
| lora: document.getElementById('lora').value, | |
| loraScale: parseFloat(document.getElementById('loraScale').value), | |
| cfgRescale: document.getElementById('cfgRescale').checked, | |
| rescale: parseFloat(document.getElementById('rescale').value), | |
| cfgDecay: document.getElementById('cfgDecay').checked, | |
| decayStart: parseFloat(document.getElementById('decayStart').value), | |
| decayEnd: parseFloat(document.getElementById('decayEnd').value), | |
| decayCurve: document.getElementById('decayCurve').value, | |
| pag: document.getElementById('pag').checked, | |
| pagScale: parseFloat(document.getElementById('pagScale').value), | |
| budget: document.getElementById('tokenBudget').value, | |
| }; | |
| const estDur = estimateDur(p.steps, p.batch, p.width, p.height, p.pag, p.cfgDecay); | |
| const actualBudget = (p.budget === 'Auto') ? estDur : parseInt(p.budget); | |
| // ================================================================ | |
| // ✅ STEP 1: Ambil SEMUA eligible tokens | |
| // ================================================================ | |
| let sessionTokens = getSessionTokens(actualBudget); | |
| log(`🎯 Session: ${sessionTokens.length} tokens eligible | Budget: ${actualBudget}s | Steps: ${p.steps}`); | |
| // ================================================================ | |
| // ✅ STEP 2: SHUFFLE (Fisher-Yates) — tidak ada token yang di-prioritize | |
| // ================================================================ | |
| sessionTokens = shuffle(sessionTokens); | |
| // ================================================================ | |
| // Data array untuk backend (22 parameters) | |
| // hf_token = data[21] → diisi di loop | |
| // ================================================================ | |
| const data = [ | |
| p.prompt, p.neg, p.sampler, p.schedule, | |
| p.steps, p.cfg, p.seed, p.batch, | |
| p.width, p.height, p.lora, p.loraScale, | |
| p.cfgRescale, p.rescale, | |
| p.cfgDecay, p.decayStart, p.decayEnd, p.decayCurve, | |
| p.pag, p.pagScale, | |
| p.budget, | |
| '' // ← hf_token placeholder (parameter ke-22) | |
| ]; | |
| // ================================================================ | |
| // ✅ STEP 3: LOOP through setiap token | |
| // | |
| // Untuk setiap token: | |
| // - Retry max 3x (untuk non-quota errors) | |
| // - Kalau QUOTA error → cooldown + skip ke token berikutnya (TIDAK retry) | |
| // - Kalau SUCCESS → keluar loop + display result | |
| // ================================================================ | |
| const totalTries = sessionTokens.length; | |
| for (let i = 0; i < totalTries; i++) { | |
| const token = sessionTokens[i]; | |
| const safeToken = token.substring(0, 8) + '...'; | |
| const tryLabel = `[${i + 1}/${totalTries}]`; | |
| // Track token usage | |
| const now = Date.now() / 1000; | |
| TS[token].lu = now; | |
| TS[token].req++; | |
| // Set token ke data array (parameter ke-22) | |
| data[21] = token; | |
| log(`${tryLabel} 🎰 Trying: ${safeToken} | ${p.steps}st ${p.width}×${p.height} ${p.sampler}+${p.schedule}`); | |
| setStatus(`${tryLabel} Token: ${safeToken}`, 'working'); | |
| updateTokenStatsUI(); | |
| // ============================================================ | |
| // Inner retry loop — max RETRY_PER_TOKEN kali per token | |
| // ============================================================ | |
| let tokenSuccess = false; | |
| let lastError = null; | |
| for (let retry = 0; retry < RETRY_PER_TOKEN; retry++) { | |
| if (retry > 0) { | |
| log(`${tryLabel} 🔄 Retry ${retry}/${RETRY_PER_TOKEN - 1} for ${safeToken}...`); | |
| await new Promise(r => setTimeout(r, 1000)); // Wait 1s between retries | |
| } | |
| try { | |
| const outputData = await generateWithToken(token, data, safeToken); | |
| // ==================================================== | |
| // ✅ SUCCESS — parse + display + keluar semua loop | |
| // ==================================================== | |
| if (!Array.isArray(outputData) || outputData.length < 4) { | |
| throw new Error(`Unexpected output format: ${JSON.stringify(outputData).substring(0,150)}`); | |
| } | |
| const [galleryPIL, info, status, base64JsonStr] = outputData; | |
| if (status && (status.includes('Error') || status.includes('❌'))) { | |
| throw new Error(info || status || 'Generation failed (backend error)'); | |
| } | |
| let base64Images = []; | |
| try { | |
| base64Images = JSON.parse(base64JsonStr || "[]"); | |
| log(`✅ ${safeToken}: parsed ${base64Images.length} images`); | |
| } catch(e) { | |
| log(`⚠️ ${safeToken}: base64 parse failed: ${e.message}`); | |
| } | |
| displayResults(base64Images, info, status); | |
| setStatus(`✅ ${safeToken} — Success on try ${tryLabel}!`, 'success'); | |
| log(`🎉 ${tryLabel} ✅ ${safeToken}: DONE! (usage: ${TS[token].req} total)`); | |
| // Log token distribution untuk monitoring | |
| const stats = tokenStats(); | |
| log(`📊 Token stats: min=${stats.usageMin}, max=${stats.usageMax}, median=${stats.usageMedian}`); | |
| btn.disabled = false; | |
| updateTokenStatsUI(); | |
| return; // ← Keluar semua loop | |
| } catch(e) { | |
| const errMsg = e.message || String(e); | |
| lastError = errMsg; | |
| // ==================================================== | |
| // ❌ QUOTA ERROR — cooldown token, skip ke token berikutnya | |
| // | |
| // Ini KUNCI yang bikin sistem roulette bekerja: | |
| // - Cooldown token yang quota-nya habis | |
| // - Lanjut ke token berikutnya (TIDAK retry) | |
| // ==================================================== | |
| if (isQuotaError(errMsg)) { | |
| const qi = parseQuotaError(errMsg); | |
| log(`${tryLabel} ❌ ${safeToken}: QUOTA exceeded`); | |
| log(`${tryLabel} 📊 Detail: ${errMsg.substring(0, 120)}`); | |
| // Track quota remainders untuk verifikasi token pool | |
| if (qi.rem !== null) { | |
| seenRems.add(qi.rem); | |
| if (seenRems.size > 1 && seenRems.size <= 15) { | |
| log(`${tryLabel} 🎯 Quota berbeda: [${[...seenRems].join(', ')}s] → ${seenRems.size} unique accounts terdeteksi`); | |
| } | |
| } | |
| if (qi.rem !== null && qi.req !== null) { | |
| log(`${tryLabel} 💾 ${qi.req}s requested vs ${qi.rem}s left`); | |
| } | |
| // Parse cooldown duration | |
| let cooldownSecs; | |
| if (qi.retry) { | |
| const parsed = parseRetrySecs(qi.retry); | |
| cooldownSecs = parsed ? (parsed + 60) : 300; // +1 menit safety | |
| log(`${tryLabel} ⏰ Retry time: ${qi.retry} → cooldown ${Math.round(cooldownSecs/60)}min`); | |
| } else { | |
| cooldownSecs = 300; // default 5 menit | |
| } | |
| // Set cooldown + max_cost constraint | |
| TS[token].cd = Date.now() / 1000 + cooldownSecs; | |
| TS[token].mc = actualBudget; | |
| TS[token].err++; | |
| log(`${tryLabel} 🔒 ${safeToken}: cooldown until quota resets`); | |
| // Skip ke token berikutnya (TIDAK retry) | |
| break; // ← Break dari inner retry loop, lanjut ke token berikutnya | |
| } | |
| // ==================================================== | |
| // 🌐 NETWORK ERROR — retry dengan token yang SAMA | |
| // ==================================================== | |
| else if (errMsg.includes('Failed to fetch') || errMsg.includes('CORS') || errMsg.includes('NetworkError')) { | |
| log(`${tryLabel} 🌐 Network/CORS: ${errMsg.substring(0, 80)} (will retry)`); | |
| continue; // ← Lanjut ke retry berikutnya dengan token yang SAMA | |
| } | |
| // ==================================================== | |
| // 💀 FATAL ERROR — retry dengan token yang SAMA (mungkin transient) | |
| // ==================================================== | |
| else { | |
| log(`${tryLabel} ⚠️ ${safeToken}: ${errMsg.substring(0, 100)}`); | |
| continue; // ← Lanjut ke retry berikutnya | |
| } | |
| } | |
| } | |
| // ============================================================ | |
| // Kalau keluar inner loop tanpa success → lanjut ke token berikutnya | |
| // ============================================================ | |
| if (!tokenSuccess) { | |
| // Tunggu 500ms sebelum try token berikutnya | |
| await new Promise(r => setTimeout(r, 500)); | |
| continue; | |
| } | |
| } | |
| // ================================================================ | |
| // ❌ SEMUA TOKEN GAGAL — quota habis semua | |
| // ================================================================ | |
| log('❌ All session tokens exhausted!'); | |
| log('💡 Solusi: tunggu quota reset, atau reduce steps/size'); | |
| setStatus('❌ All tokens exhausted — try again later', 'error'); | |
| btn.disabled = false; | |
| updateTokenStatsUI(); | |
| } | |
| // ===================================================================== | |
| // 🖼️ DISPLAY RESULTS — Render dari Base64 Data URIs | |
| // ===================================================================== | |
| function displayResults(base64Images, info, status) { | |
| const gallery = document.getElementById('gallery'); | |
| gallery.innerHTML = ''; | |
| if (!base64Images || base64Images.length === 0) { | |
| gallery.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div><p>No images (base64 empty)</p></div>'; | |
| log(`⚠️ Base64 images array is empty`); | |
| return; | |
| } | |
| base64Images.forEach(img => { | |
| const dataUri = img.data; | |
| const name = img.name || `ffd_${img.seed}_${img.index}.jpg`; | |
| const div = document.createElement('div'); | |
| div.className = 'gallery-item'; | |
| div.innerHTML = ` | |
| <img src="${dataUri}" alt="${name}" loading="lazy" | |
| onclick="window._openLB('${dataUri}')"> | |
| <button class="dl-btn" onclick="window._downloadBase64('${dataUri}', '${name}')">⬇️</button> | |
| `; | |
| gallery.appendChild(div); | |
| }); | |
| const ip = document.getElementById('infoPanel'); | |
| if (info) { | |
| ip.style.display = 'block'; | |
| ip.innerHTML = info.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') | |
| .replace(/\n\n/g, '<br><br>') | |
| .replace(/\n/g, '<br>'); | |
| } | |
| } | |
| // ===================================================================== | |
| // 📥 DOWNLOAD BASE64 IMAGE | |
| // ===================================================================== | |
| window._downloadBase64 = function(dataUri, filename) { | |
| const link = document.createElement('a'); | |
| link.href = dataUri; | |
| link.download = filename; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| }; | |
| // ===================================================================== | |
| // 🔍 LIGHTBOX + SETTINGS | |
| // ===================================================================== | |
| window._openLB = function(url) { | |
| document.getElementById('lightboxImg').src = url; | |
| document.getElementById('lightbox').classList.add('active'); | |
| }; | |
| window._closeLB = function() { | |
| document.getElementById('lightbox').classList.remove('active'); | |
| }; | |
| function openSettings() { | |
| document.getElementById('backendUrlInput').value = BACKEND_URL; | |
| document.getElementById('settingsModal').classList.add('active'); | |
| } | |
| window._closeSettings = function() { | |
| document.getElementById('settingsModal').classList.remove('active'); | |
| }; | |
| window._saveSettings = function() { | |
| const url = document.getElementById('backendUrlInput').value.trim().replace(/\/$/, ''); | |
| BACKEND_URL = url; | |
| localStorage.setItem('ffd_backend_url', url); | |
| window._closeSettings(); | |
| checkConnection(); | |
| }; | |
| document.addEventListener('keydown', e => { | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { | |
| e.preventDefault(); | |
| if (!document.getElementById('generate-btn').disabled) handleGenerate(); | |
| } | |
| }); | |
| window.handleGenerate = handleGenerate; | |
| window.openSettings = openSettings; | |
| // ===================================================================== | |
| // 🚀 INIT | |
| // ===================================================================== | |
| initUI(); | |
| updateTokenStatsUI(); | |
| log(`🕵️ ${TOKENS.length} Stealth Tokens loaded`); | |
| log(`📦 @gradio/client: ${GRADIO_CLIENT_AVAILABLE ? '✅ Available' : '❌ Not available (fallback only)'}`); | |
| log(`🎯 Mode: credentials:"omit" + Authorization:Bearer per token`); | |
| log(`🎲 Token pool: Fisher-Yates shuffle (unbiased)`); | |
| log(`🔄 Retry per token: ${RETRY_PER_TOKEN}x (untuk non-quota errors)`); | |
| log(`⏭️ Quota error: cooldown + skip ke token berikutnya`); | |
| log(`📖 Backend returns 4 outputs (Gallery, Info, Status, Base64 JSON)`); | |
| log(`🎨 FFD-XL-3.0 Ready — klik Generate untuk test roulette!`); | |
| if (BACKEND_URL) { | |
| checkConnection(); | |
| } else { | |
| log('⚙️ Click ⚙️ to set Backend URL'); | |
| openSettings(); | |
| } | |
| setInterval(updateTokenStatsUI, 5000); | |
| setInterval(checkConnection, 30000); | |
| </script> | |
| </body> | |
| </html> | |