ffd3.0Frontend / index.html
Bl4ckSpaces's picture
Update index.html
dedb5bb verified
Raw
History Blame Contribute Delete
48.7 kB
<!DOCTYPE html>
<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>