// ========================================================================== // 📝 TODO (Architecture / Enhancements) // ========================================================================== // 1. [Prompt Presets] 实现基于 ID 的 Prompt 远程动态加载 // - 方案:将超长 Prompt 抽离为集中的 JSON 文件(如: prompt-assets/presets.json)。 // - 逻辑:图库 (Gallery) 跳转时仅传递 ?id=xxx,前端 (Studio) 接收到 ID 后,跨域 fetch 该 JSON 并自动填充 Prompt 输入框。 // - 优点:彻底解决 URL 长度限制,实现前后端数据彻底解耦,方便统一维护。 // commit@70f742a386b9cbbfd6df774b19d727485ef5446f // ========================================================================== // 🛡️ Dynamic API Base URL // ========================================================================== const hostname = window.location.hostname; let API_BASE_URL = ""; // 1. 如果在 GUI 预览环境(哪怕带 .static 后缀) if (hostname.includes("universal-lora-studio-gui")) { API_BASE_URL = "https://waveshaker-universal-lora-studio-test.hf.space"; } // 2. 如果在本地开发环境 else if (hostname === "localhost" || hostname === "127.0.0.1") { API_BASE_URL = "https://waveshaker-universal-lora-studio-test.hf.space"; } // 3. 如果在测试环境(test Space) else if (hostname.includes("universal-lora-studio-test")) { API_BASE_URL = ""; // 同源,走相对路径 } // 4. 如果在线上环境(prod Space) else if (hostname.includes("universal-lora-studio.hf.space") || hostname.includes("universal-lora-studio.static")) { API_BASE_URL = ""; // 同源,走相对路径 } // ========================================================================== // 🔐 ZeroGPU Token Handshake // ========================================================================== const ZEROGPU_HEADERS_MSG = "supports-zerogpu-headers"; // 监听 HF 父窗口发来的握手信号 window.addEventListener("message", (event) => { if (event.data === ZEROGPU_HEADERS_MSG) { window.supports_zerogpu_headers = true; } }); // 向父窗口声明支持,触发后续的 token 索要流程 (function announceZeroGpuSupport() { const hostname = window.location.hostname; if (!hostname.endsWith(".hf.space") && !hostname.includes(".dev.")) { return; // 非 HF 环境,不执行 } const origin = hostname.includes(".dev.") ? `https://moon-${hostname.split(".")[1]}.dev.spaces.huggingface.tech` : "https://huggingface.co"; try { window.parent.postMessage(ZEROGPU_HEADERS_MSG, origin); } catch (e) { console.warn("ZeroGPU handshake failed:", e); } })(); // 向父窗口索要 x-ip-token(带 2 秒超时兜底) async function requestZeroGpuToken() { if (!window.supports_zerogpu_headers) return null; const hostname = window.location.hostname; if (!hostname.endsWith(".hf.space") && !hostname.includes(".dev.")) return null; const origin = hostname.includes(".dev.") ? `https://moon-${hostname.split(".")[1]}.dev.spaces.huggingface.tech` : "https://huggingface.co"; try { return await new Promise((resolve) => { const channel = new MessageChannel(); const timeout = setTimeout(() => { channel.port1.close(); resolve(null); }, 2000); channel.port1.onmessage = ({ data }) => { clearTimeout(timeout); resolve(data || null); }; window.parent.postMessage("zerogpu-headers", origin, [channel.port2]); }); } catch { return null; } } // ========================================================================== // 🛡️ API Layer // ========================================================================== const BackendAPI = { // 1. 心跳检测 checkHealth: async function() { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); const res = await fetch(`${API_BASE_URL}/api/health`, { signal: controller.signal, cache: 'no-store' }); clearTimeout(timeoutId); if (res.ok) { const data = await res.json(); return data.status === "ok"; } return false; } catch (error) { return false; } }, // 2. 获取全局配置 (Sampler/Scheduler & Root Mapping) getConfig: async function() { try { const res = await fetch(`${API_BASE_URL}/api/config`, { method: 'GET', cache: 'no-store' }); if (!res.ok) throw new Error("HTTP error " + res.status); return await res.json(); } catch (error) { console.warn("⚠️ Failed to fetch config", error); return null; } }, // 3. 获取 Base Model 列表 getModels: async function() { const res = await fetch(`${API_BASE_URL}/api/models`, { method: 'GET', cache: 'no-store' }); if (!res.ok) throw new Error("HTTP error " + res.status); return await res.json(); }, // 4. 获取角色 LoRA 图库数据 getLoras: async function() { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时 try { const res = await fetch(`${API_BASE_URL}/api/loras`, { method: 'GET', cache: 'no-store', signal: controller.signal }); clearTimeout(timeoutId); if (!res.ok) throw new Error("HTTP error " + res.status); return await res.json(); } catch (error) { clearTimeout(timeoutId); throw error; } }, // 5. 提交生成任务 submitGenerate: async function(payload) { const headers = { 'Content-Type': 'application/json' }; const tokenInfo = await requestZeroGpuToken(); if (tokenInfo) { if (typeof tokenInfo === 'object') { Object.assign(headers, tokenInfo); } else if (typeof tokenInfo === 'string') { headers['x-ip-token'] = tokenInfo; } } const res = await fetch(`${API_BASE_URL}/api/generate`, { method: 'POST', cache: 'no-store', headers: headers, body: JSON.stringify(payload) }); if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`); const data = await res.json(); if (data.status === "error") throw new Error(data.message); return data.task_id; }, // 6. 轮询任务状态 pollStatus: async function(taskId, callbacks, retries = 0) { const MAX_RETRIES = 150; try { const res = await fetch(`${API_BASE_URL}/api/status/${taskId}`, { cache: 'no-store' }); const data = await res.json(); if (data.status === "processing" || data.status === "queued") { if (callbacks.onProcessing) { callbacks.onProcessing(data.message || "Processing..."); } if (retries >= MAX_RETRIES) { const timeoutMsg = `Timeout: Backend exceeded ${MAX_RETRIES * 500 / 1000}s. Please try again.`; if (callbacks.onError) callbacks.onError(timeoutMsg); return; } setTimeout(() => this.pollStatus(taskId, callbacks, retries + 1), 300); } else if (data.status === "success") { if (callbacks.onSuccess) callbacks.onSuccess(data.image, data.seed, data.metrics); } else if (data.status === "error") { if (callbacks.onError) callbacks.onError(data.message); } else if (data.status === "not_found") { if (callbacks.onError) callbacks.onError("Task not found (backend may have restarted)."); } } catch (error) { if (callbacks.onError) callbacks.onError(error.message); } }, }; // ========================================================================== // 🧠 Global State & Utilities // ========================================================================== let generateStartTime = 0; let currentStageStartTime = 0; let currentStageName = ""; let isInitializing = false; let shouldAnimateNextRender = false; let globalModelData = {}; let currentGalleryData = []; let renderedCount = 0; const BATCH_SIZE = 20; let selectedLora = ""; let backendState = { isReady: false, isChecking: false, healthInterval: null }; const ANIM_CONFIG = { startY: 24, stiffness: 280, damping: 0.7, staggerDelay: 30, }; function toUnicodeBold(str) { return str.split('').map(char => { const code = char.charCodeAt(0); if (code >= 65 && code <= 90) return String.fromCodePoint(0x1D5D4 + (code - 65)); if (code >= 97 && code <= 122) return String.fromCodePoint(0x1D5EE + (code - 97)); if (code >= 48 && code <= 57) return String.fromCodePoint(0x1D7EC + (code - 48)); return char; }).join(''); } function bindSlider(sliderId, inputId) { const slider = document.getElementById(sliderId); const input = document.getElementById(inputId); slider.addEventListener('input', (e) => { input.value = e.target.value; }); input.addEventListener('input', (e) => { let val = parseFloat(e.target.value); if(val < parseFloat(slider.min)) val = slider.min; if(val > parseFloat(slider.max)) val = slider.max; slider.value = val; }); } bindSlider('width-slider', 'width-input'); bindSlider('height-slider', 'height-input'); bindSlider('steps-slider', 'steps-input'); bindSlider('cfg-slider', 'cfg-input'); const RATIO_PRESETS = { "1:1": { w: 1024, h: 1024 }, "3:4": { w: 888, h: 1184 }, "9:16": { w: 768, h: 1368 }, "4:3": { w: 1184, h: 888 }, "16:9": { w: 1368, h: 768 } }; document.querySelectorAll('.ratio-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.ratio-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const ratio = btn.dataset.ratio; const preset = RATIO_PRESETS[ratio]; if (preset) { document.getElementById('width-slider').value = preset.w; document.getElementById('width-input').value = preset.w; document.getElementById('height-slider').value = preset.h; document.getElementById('height-input').value = preset.h; } }); }); function clearActiveRatio() { document.querySelectorAll('.ratio-btn').forEach(b => b.classList.remove('active')); } document.getElementById('width-slider').addEventListener('input', clearActiveRatio); document.getElementById('width-input').addEventListener('input', clearActiveRatio); document.getElementById('height-slider').addEventListener('input', clearActiveRatio); document.getElementById('height-input').addEventListener('input', clearActiveRatio); function setUIState(isGenerating) { const btn = document.getElementById('generate-btn'); if (!backendState.isReady) { btn.disabled = true; btn.innerText = "⏳ Waiting for Backend..."; } else if (isGenerating) { btn.disabled = true; btn.innerText = "⏳ Generating..."; } else { btn.disabled = false; btn.innerText = "Generate"; } } // 获取 Checkbox 元素 const autoScrollToggle = document.getElementById('auto-scroll-toggle'); function smartScroll() { // 只有用户勾选了“自动滚动”,才强制贴底 if (autoScrollToggle && autoScrollToggle.checked) { const logContent = document.getElementById('log-content'); // 用 requestAnimationFrame 确保在 DOM 高度更新后再执行滚动 requestAnimationFrame(() => { logContent.scrollTop = logContent.scrollHeight; }); } } function addLog(message, type = "normal") { const logContent = document.getElementById('log-content'); const newLine = document.createElement('div'); newLine.className = `log-line ${type === "normal" ? "" : type}`; const time = new Date().toLocaleTimeString('en-US', { hour12: false }); newLine.innerText = `[${time}] ${message}`; logContent.appendChild(newLine); if (logContent.childElementCount > 100) { logContent.firstElementChild.remove(); } // 插入后调用智能滚动 smartScroll(); } // ========================================================================== // 💓 Health Check & Initialization // ========================================================================== async function performHealthCheck() { if (backendState.isChecking) return; backendState.isChecking = true; const dot = document.getElementById('backend-status-dot'); const wrapper = document.getElementById('backend-status-wrapper'); if (!backendState.isReady) { setUIState(false); dot.className = "status-dot loading"; wrapper.title = "Connecting to backend..."; } const isHealthy = await BackendAPI.checkHealth(); if (isHealthy) { if (!backendState.isReady) { backendState.isReady = true; setUIState(false); dot.className = "status-dot online"; wrapper.title = "Backend online, ready to generate!"; await loadInitialData(); } } else { backendState.isReady = false; setUIState(false); dot.className = "status-dot offline"; wrapper.title = "Backend offline, trying to reconnect..."; } backendState.isChecking = false; } function startHealthPolling() { if (backendState.healthInterval) return; backendState.healthInterval = setInterval(async () => { if (backendState.isReady) { clearInterval(backendState.healthInterval); backendState.healthInterval = null; return; } await performHealthCheck(); }, 3000); } async function loadInitialData() { if (isInitializing) return; isInitializing = true; console.log("🟢 [Init] 开始初始化..."); document.getElementById('unet-select').disabled = true; const config = await BackendAPI.getConfig(); if (!config) console.error("❌ 获取配置失败,请检查后端 API 是否正常"); const finalSamplers = config?.samplers || ["euler", "euler_ancestral", "euler_a", "dpmpp_2m", "dpmpp_2m_sde", "dpmpp_sde", "heun", "lms", "lcm"]; const finalSchedulers = config?.schedulers || ["simple", "beta", "normal", "karras", "exponential", "sgm_uniform"]; const rootMapping = config?.root_mapping || {}; if (config && config.default_prompt) { document.getElementById('prompt').value = config.default_prompt; } document.getElementById('sampler').innerHTML = finalSamplers.map(s => ``).join(''); document.getElementById('scheduler').innerHTML = finalSchedulers.map(s => ``).join(''); const selectEl = document.getElementById('unet-select'); try { const data = await BackendAPI.getModels(); console.log("🟢 [Init] Models 数据已拉取"); if (data.models) { globalModelData = data.models; selectEl.innerHTML = ""; Object.keys(data.models).forEach(modelName => { const opt = document.createElement("option"); opt.value = modelName; if (modelName.includes(" | ")) { const parts = modelName.split(" | "); const enginePart = toUnicodeBold(parts[0]); const modelPart = parts[1]; opt.textContent = `${enginePart} | ${modelPart}`; } else { opt.textContent = toUnicodeBold(modelName); } selectEl.appendChild(opt); }); let modelParam = null; let loraParam = null; if (window.location.hash) { const hashStr = window.location.hash.substring(1); const hashParams = new URLSearchParams(hashStr); modelParam = hashParams.get('model'); loraParam = hashParams.get('lora'); } if (!modelParam) { const urlParams = new URLSearchParams(window.location.search); modelParam = urlParams.get('model'); loraParam = urlParams.get('lora'); } let targetModel = Object.keys(data.models)[0]; if (modelParam) { const targetEngine = rootMapping[modelParam] || modelParam; console.log(`🔗 URL解析: ${modelParam} -> ${targetEngine}`); for (const key of Object.keys(data.models)) { const modelEngine = data.models[key].engine; if (modelEngine === targetEngine) { targetModel = key; console.log(`✅ 成功匹配到模型: ${key} (Engine: ${modelEngine})`); break; } } } console.log(`🟢 [Init] 准备选中模型: ${targetModel}`); selectEl.value = targetModel; window._currentSelectedModel = targetModel; window._lastUsedArchitecture = globalModelData[targetModel]?.engine || "unknown"; const modelDefaults = globalModelData[targetModel]?.defaults || {}; if (globalModelData[targetModel]?.default_prompt !== undefined) { document.getElementById('prompt').value = globalModelData[targetModel].default_prompt; } if (modelDefaults.cfg !== undefined) { document.getElementById('cfg-slider').value = modelDefaults.cfg; document.getElementById('cfg-input').value = Number(modelDefaults.cfg).toFixed(1); } if (modelDefaults.steps !== undefined) { document.getElementById('steps-slider').value = modelDefaults.steps; document.getElementById('steps-input').value = modelDefaults.steps; } if (modelDefaults.sampler !== undefined) document.getElementById('sampler').value = modelDefaults.sampler; if (modelDefaults.scheduler !== undefined) document.getElementById('scheduler').value = modelDefaults.scheduler; if (modelDefaults.char_lora_strength !== undefined) { document.getElementById('char-strength').value = Number(modelDefaults.char_lora_strength).toFixed(1); } selectedLora = ""; document.getElementById('selected-lora-name').innerText = "None"; document.getElementById('selected-lora-name').style.color = 'var(--text-muted)'; const bigImg = document.getElementById('selected-lora-big-img'); const placeholder = document.getElementById('preview-placeholder'); bigImg.src = ""; bigImg.style.display = 'none'; placeholder.style.display = 'flex'; document.querySelectorAll('.lora-card').forEach(c => c.classList.remove('selected')); renderBaseLoras(targetModel); window._isDataReady = true; // 🌟 核心修改:如果 URL 没有指定 lora 参数,说明是普通进入,开启动画 const isPlainEntry = !loraParam; await fetchAndRenderGallery(targetModel, isPlainEntry); if (loraParam) { if (!loraParam.endsWith(".safetensors")) { console.warn(`⚠️ [Init] 忽略无效的 LoRA 参数(缺少 .safetensors 后缀): ${loraParam}`); } else { const targetIndex = currentGalleryData.findIndex(item => item.filename === loraParam); if (targetIndex !== -1) { const targetItem = currentGalleryData[targetIndex]; console.log(`🟢 [Init] 数据层成功找到 LoRA: ${loraParam},位于第 ${targetIndex + 1} 个`); selectedLora = targetItem.filename; const nameEl = document.getElementById('selected-lora-name'); nameEl.innerText = targetItem.title; nameEl.style.color = 'var(--accent)'; const bigImg = document.getElementById('selected-lora-big-img'); const placeholder = document.getElementById('preview-placeholder'); const previewWrapper = document.querySelector('.preview-wrapper'); if (previewWrapper) previewWrapper.classList.add('shimmer-container'); bigImg.src = targetItem.webp; bigImg.onload = () => { bigImg.style.display = 'block'; placeholder.style.display = 'none'; if (previewWrapper) previewWrapper.classList.remove('shimmer-container'); bigImg.onload = null; }; bigImg.onerror = () => { if (previewWrapper) previewWrapper.classList.remove('shimmer-container'); bigImg.onerror = null; }; if (targetIndex >= renderedCount) { const requiredRenderCount = Math.ceil((targetIndex + 1) / BATCH_SIZE) * BATCH_SIZE; const tempData = currentGalleryData.slice(renderedCount, requiredRenderCount); const grid = document.getElementById('gallery-grid'); tempData.forEach(item => { const card = document.createElement('div'); card.className = 'lora-card'; card.dataset.filename = item.filename; card.innerHTML = ` ${item.title}
${item.title}
`; const cardImg = card.querySelector('img'); applyCardShimmer(card, cardImg); bindCardClick(card, item); grid.appendChild(card); }); renderedCount = requiredRenderCount; } setTimeout(() => { const targetCard = document.querySelector(`.lora-card[data-filename="${loraParam}"]`); if (targetCard) { console.log(`🟢 [Init] DOM 已渲染,执行滚动和高亮`); document.querySelectorAll('.lora-card').forEach(c => c.classList.remove('selected')); targetCard.classList.add('selected'); targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' }); } else { console.warn(`⚠️ 极度异常:数据存在,但强制渲染后依然找不到 DOM`); } }, 150); } else { console.warn(`⚠️ [Init] 在返回的数据中不存在该 LoRA: ${loraParam}`); } } } } } catch (e) { console.error("❌ 初始化加载失败:", e); selectEl.innerHTML = ``; } finally { document.getElementById('unet-select').disabled = false; // 🌟 智能移除遮罩:如果数据没就绪,就等它就绪 const waitForDataAndHideMask = setInterval(() => { if (window._isDataReady) { clearInterval(waitForDataAndHideMask); const mask = document.getElementById('global-loading-mask'); if (mask) { setTimeout(() => { mask.style.opacity = '0'; setTimeout(() => mask.remove(), 300); }, 100); // 给 DOM 留 100ms 的渲染时间 } } }, 100); } } // ========================================================================== // 🖼️ Gallery & Card Rendering // ========================================================================== function renderBaseLoras(selectedModel) { const container = document.getElementById('base-lora-container'); container.innerHTML = ""; const loras = globalModelData[selectedModel]?.base_loras || []; if (loras.length === 0) { container.innerHTML = `
No Base LoRA for current model
`; return; } loras.forEach((lora) => { const div = document.createElement('div'); div.className = 'lora-row'; div.innerHTML = ` `; container.appendChild(div); }); } // 在全局变量区加一个请求锁 let isGalleryFetching = false; async function fetchAndRenderGallery(modelName, triggerAnim = false) { // 🌟 核心修复 2:如果已经在请求中,直接锁死,不准并发 if (isGalleryFetching) { console.log("⚠️ 拦截并发请求,当前图库正在加载中..."); return; } isGalleryFetching = true; const grid = document.getElementById('gallery-grid'); const countEl = document.getElementById('lora-count'); // 🌟 注意:这里不再清空 grid.innerHTML,因为我们在 unet-select 里已经清空了 // 但为了首次加载的安全,如果 grid 里没有 Loading 提示,还是补一个 if (!grid.innerHTML.includes("Loading") && !grid.innerHTML.includes("Switching")) { grid.innerHTML = `
⏳ Loading character list...
`; } try { const modelInfo = globalModelData[modelName]; if (!modelInfo || !modelInfo.engine) { grid.innerHTML = `
⏳ Backend data syncing...
`; return; } const engineKey = modelInfo.engine; // 🌟 别忘了你之前给 getLoras 加了 10 秒超时 const data = await BackendAPI.getLoras(); const lorasDict = data.loras || {}; const lorasForModel = lorasDict[engineKey]; if (!lorasForModel || Object.keys(lorasForModel).length === 0) { grid.innerHTML = `
No character LoRA for this model
`; if (countEl) countEl.innerText = "0 Models"; return; } currentGalleryData = Object.entries(lorasForModel).map(([filename, info]) => ({ filename: filename, title: filename.replace(".safetensors", ""), webp: info.webp })); renderedCount = 0; shouldAnimateNextRender = triggerAnim; if (countEl) countEl.innerText = `${currentGalleryData.length} Models`; // 🌟 核心:把 flex 居中还原成原来的网格布局 grid.style.display = 'grid'; grid.style.height = 'auto'; grid.innerHTML = ""; loadMoreCards(); const container = document.getElementById('gallery-container'); container.onscroll = () => { if (container.scrollTop + container.clientHeight >= container.scrollHeight - 50) { loadMoreCards(); } }; } catch (error) { console.error("Severe error in gallery request:", error); grid.innerHTML = `
❌ Failed to load gallery, backend may be restarting
`; if (countEl) countEl.innerText = "Error"; } finally { // 🌟 核心修复 3:无论成功失败,必须释放锁 isGalleryFetching = false; } } function applyCardShimmer(card, imgElement) { const overlay = document.createElement('div'); overlay.className = 'shimmer-overlay'; card.appendChild(overlay); const removeShimmer = () => overlay.remove(); if (imgElement.complete) { setTimeout(removeShimmer, 400); } else { imgElement.addEventListener('load', removeShimmer); imgElement.addEventListener('error', removeShimmer); } } function bindCardClick(card, item) { card.addEventListener('click', () => { document.querySelectorAll('.lora-card').forEach(c => c.classList.remove('selected')); card.classList.add('selected'); selectedLora = item.filename; const nameEl = document.getElementById('selected-lora-name'); nameEl.innerText = item.title; nameEl.style.color = item.title ? 'var(--accent)' : 'var(--text-muted)'; const bigImg = document.getElementById('selected-lora-big-img'); const placeholder = document.getElementById('preview-placeholder'); bigImg.style.display = 'none'; placeholder.style.display = 'flex'; bigImg.classList.add('shimmer-active'); bigImg.src = item.webp; bigImg.onload = () => { bigImg.style.display = 'block'; placeholder.style.display = 'none'; bigImg.onload = null; }; bigImg.onerror = () => { bigImg.style.display = 'none'; placeholder.style.display = 'flex'; bigImg.onerror = null; }; setTimeout(() => { bigImg.classList.remove('shimmer-active'); }, 400); }); } function loadMoreCards() { const grid = document.getElementById('gallery-grid'); const nextBatch = currentGalleryData.slice(renderedCount, renderedCount + BATCH_SIZE); if (nextBatch.length === 0) return; // 🌟 注意这里要加 (item, index) nextBatch.forEach((item, index) => { const card = document.createElement('div'); card.className = 'lora-card'; card.dataset.filename = item.filename; // 🌟 动画初始状态 if (shouldAnimateNextRender) { card.style.opacity = '0'; card.style.transform = 'translateY(20px)'; card.dataset.animateEntry = "true"; card.dataset.index = index; } card.innerHTML = ` ${item.title}
${item.title}
`; const cardImg = card.querySelector('img'); applyCardShimmer(card, cardImg); bindCardClick(card, item); grid.appendChild(card); // 🌟 动画执行 if (shouldAnimateNextRender) { // 错开延迟触发,利用你配置里的 staggerDelay (30ms) setTimeout(() => { applySpringAnimation(card); }, index * ANIM_CONFIG.staggerDelay); } }); renderedCount += nextBatch.length; // 🌟 重要:动画只管第一屏。渲染完之后立刻关掉,防止滚动加载时还会闪烁。 shouldAnimateNextRender = false; } // ========================================== // 🪄 Animation: 物理弹簧动画 // ========================================== function applySpringAnimation(element) { if (!element || element.dataset.animating === 'true') return; element.dataset.animating = 'true'; const startY = ANIM_CONFIG.startY; const omega0 = Math.sqrt(ANIM_CONFIG.stiffness); const zeta = ANIM_CONFIG.damping; const omegaD = omega0 * Math.sqrt(1 - zeta * zeta); const startTime = performance.now(); element.style.transition = ''; element.style.opacity = '0'; element.style.transform = `translate3d(0, ${startY}px, 0)`; element.style.willChange = 'transform, opacity'; element.style.backfaceVisibility = 'hidden'; void element.offsetWidth; function step(currentTime) { const t = (currentTime - startTime) / 1000; const envelope = Math.exp(-zeta * omega0 * t); if (Math.abs(envelope * startY) < 0.1) { // 🌟 核心修复:不要用 translate3d,直接清空 transform,让父级彻底“干干净净” element.style.transform = ''; element.style.opacity = 1; element.style.willChange = 'auto'; element.style.transition = ''; element.dataset.animating = 'false'; return; } const positionY = startY * envelope * Math.cos(omegaD * t); const opacity = Math.min(t / 0.25, 1); element.style.transform = `translate3d(0, ${positionY}px, 0)`; element.style.opacity = opacity; requestAnimationFrame(step); } requestAnimationFrame(step); } // ========================================================================== // ⚡ Event Listeners (Generate, Switch, Random Seed) // ========================================================================== document.getElementById('random-seed-btn').addEventListener('click', (e) => { e.preventDefault(); document.getElementById('seed').value = Math.floor(Math.random() * 4294967295); }); document.getElementById('unet-select').addEventListener('change', async (e) => { const selectedModel = e.target.value; const currentEngine = globalModelData[selectedModel]?.engine || "unknown"; const lastEngine = window._lastUsedArchitecture || null; const isCrossArchitecture = (lastEngine !== null && lastEngine !== currentEngine); // 🌟 核心修复 1:如果是跨架构切换,第一时间强杀旧 DOM,别等网络请求 if (isCrossArchitecture) { const grid = document.getElementById('gallery-grid'); // 🌟 核心:强行把 grid 改成 flex 居中 grid.style.display = 'flex'; grid.style.justifyContent = 'center'; grid.style.alignItems = 'center'; grid.style.height = '100%'; // 撑满父容器高度 grid.innerHTML = `
⏳ Loading LoRAs...
`; document.getElementById('lora-count').innerText = "Loading..."; currentGalleryData = []; renderedCount = 0; } // 1. 处理 Prompt 覆盖 const promptEl = document.getElementById('prompt'); const lastModelName = window._currentSelectedModel || null; const previousDefaultPrompt = lastModelName ? (globalModelData[lastModelName]?.default_prompt || "") : ""; if (!promptEl.value.trim() || promptEl.value === previousDefaultPrompt) { if (globalModelData[selectedModel]?.default_prompt) { promptEl.value = globalModelData[selectedModel].default_prompt; } } // 2. 处理参数更新 (不管跨不跨架构,参数肯定要更新) const modelDefaults = globalModelData[selectedModel]?.defaults || {}; if (modelDefaults.cfg !== undefined) { document.getElementById('cfg-slider').value = modelDefaults.cfg; document.getElementById('cfg-input').value = Number(modelDefaults.cfg).toFixed(1); } if (modelDefaults.steps !== undefined) { document.getElementById('steps-slider').value = modelDefaults.steps; document.getElementById('steps-input').value = modelDefaults.steps; } if (modelDefaults.sampler !== undefined) { document.getElementById('sampler').value = modelDefaults.sampler; } if (modelDefaults.scheduler !== undefined) { document.getElementById('scheduler').value = modelDefaults.scheduler; } if (modelDefaults.char_lora_strength !== undefined && (isCrossArchitecture || !selectedLora)) { document.getElementById('char-strength').value = Number(modelDefaults.char_lora_strength).toFixed(1); } // ======================================================== // 🌟 核心分支:只有跨架构才执行渲染,同架构直接躺平 // ======================================================== if (isCrossArchitecture) { // 跨架构:清空已选角色,换 Base LoRA,重新拉数据(并传 true 触发入场动画) selectedLora = ""; document.getElementById('selected-lora-name').innerText = "None"; document.getElementById('selected-lora-name').style.color = 'var(--text-muted)'; const bigImg = document.getElementById('selected-lora-big-img'); const placeholder = document.getElementById('preview-placeholder'); bigImg.src = ""; bigImg.style.display = 'none'; placeholder.style.display = 'flex'; document.querySelectorAll('.lora-card').forEach(c => c.classList.remove('selected')); renderBaseLoras(selectedModel); fetchAndRenderGallery(selectedModel, true); // 👈 后台异步加载,不阻塞事件 // 强制滚动到图库顶部 const galleryContainer = document.getElementById('gallery-container'); if (galleryContainer) galleryContainer.scrollTop = 0; } else { // 🌟 同架构:不请求网络,不动画廊,完美保留 DOM 和滚动位置 console.log("🔄 Same architecture, keeping gallery and Base LoRAs intact."); } // 更新全局记录 window._currentSelectedModel = selectedModel; window._lastUsedArchitecture = currentEngine; }); document.getElementById('generate-btn').addEventListener('click', async () => { if (!backendState.isReady) { alert("Backend is waking up. Please wait a few seconds and try again!"); return; } const resultImage = document.getElementById('result-image'); const placeholderText = document.getElementById('placeholder-text'); const generateBtn = document.getElementById('generate-btn'); const prompt = document.getElementById('prompt').value.trim(); const unet_choice = document.getElementById('unet-select').value; const custom_w = parseInt(document.getElementById('width-input').value); const custom_h = parseInt(document.getElementById('height-input').value); const steps = parseInt(document.getElementById('steps-input').value); const cfg = parseFloat(document.getElementById('cfg-input').value); const sampler = document.getElementById('sampler').value; const scheduler = document.getElementById('scheduler').value; const seedVal = document.getElementById('seed').value; const seed = seedVal === "" ? -1 : parseInt(seedVal); if (!unet_choice || unet_choice.includes('Loading') || unet_choice.includes('Backend Offline')) { alert("Please select a valid Base Model first."); return; } if (!prompt) { alert("Please enter a prompt."); return; } const baseLoraStates = []; document.querySelectorAll('#base-lora-container .lora-row').forEach(row => { const isChecked = row.querySelector('.base-lora-cb').checked; const strength = parseFloat(row.querySelector('.base-lora-strength').value) || 1.0; baseLoraStates.push(isChecked, strength); }); const charEnable = document.getElementById('char-enable').checked; const charStrength = parseFloat(document.getElementById('char-strength').value) || 1.0; const requestData = { prompt: prompt, unet_choice: unet_choice, custom_w: custom_w, custom_h: custom_h, steps: steps, cfg: cfg, seed: seed, sampler: sampler, scheduler: scheduler, char_enable: charEnable, char_lora: selectedLora || "", char_strength: charStrength, base_lora_states: baseLoraStates }; console.log("🚀 Submitting parameters:", requestData); if (!requestData.prompt || !requestData.unet_choice || !requestData.custom_w) { alert("Missing parameters. Please check Prompt / Model / Dimensions."); return; } generateBtn.disabled = true; generateBtn.innerText = "⏳ Submitting..."; resultImage.style.display = 'none'; placeholderText.style.display = 'block'; generateStartTime = Date.now(); currentStageStartTime = generateStartTime; currentStageName = "Task Queued"; document.getElementById('log-content').innerHTML = ""; if (window._lastGeneratedModel !== unet_choice) { const currentEngine = globalModelData[unet_choice]?.engine || "unknown"; const lastModel = window._lastGeneratedModel; const lastEngine = lastModel ? (globalModelData[lastModel]?.engine || "unknown") : null; if (lastEngine && lastEngine !== currentEngine) { addLog(`🔄 Arch Switch: Loading UNet/VAE/TE (Full Swap)...`, "highlight"); } else if (lastEngine === currentEngine) { addLog(`🔄 Model Update: Swapping UNet (Keep VAE/TE)...`, "highlight"); } else { addLog(`🔄 Initializing: Loading UNet/VAE/TE (First Load)...`, "highlight"); } window._lastGeneratedModel = unet_choice; } addLog("Task submitted to backend.", "highlight"); try { const taskId = await BackendAPI.submitGenerate(requestData); addLog(`Task ID: ${taskId}`); BackendAPI.pollStatus(taskId, { onProcessing: (msg) => { let safeMsg = msg || "🎨 Processing..."; let newStage = "Processing"; if (safeMsg.includes("Rendering") || safeMsg.includes("steps") || safeMsg.includes("Sampling") || safeMsg.includes("KSampler")) { newStage = "Comfy Backend - Sampling"; } else if (safeMsg.includes("Downloading")) { newStage = "Downloading"; } else if (safeMsg.includes("Requesting") || safeMsg.includes("Waiting") || safeMsg.includes("Queue")) { newStage = "Queueing"; } else if (safeMsg.includes("Allocating") || safeMsg.includes("GPU Acquired") || safeMsg.includes("Loading") || safeMsg.includes("Executing") || safeMsg.includes("Workflow")) { newStage = "Comfy Backend - Model Loading"; } if ((currentStageName === "Comfy Backend - Model Loading" || currentStageName === "Comfy Backend - Sampling") && (safeMsg.includes("Requesting") || safeMsg.includes("Waiting") || safeMsg.includes("Queue"))) { return; } if (newStage !== currentStageName && newStage !== "Processing") { const now = Date.now(); const prevDuration = ((now - currentStageStartTime) / 1000).toFixed(1); if (currentStageName !== "Task Queued" && currentStageName !== "Processing") { addLog(`⏱️ ${currentStageName} finished in ${prevDuration}s.`); } currentStageName = newStage; currentStageStartTime = now; addLog(`🚀 Starting ${newStage}...`); } const logContent = document.getElementById('log-content'); const lastLog = logContent.lastElementChild; const lastText = lastLog ? lastLog.innerText : ""; if (safeMsg.includes("Rendering image (GPU processing)") && !safeMsg.includes("steps")) { const elapsed = ((Date.now() - currentStageStartTime) / 1000).toFixed(0); const lastLine = logContent.lastElementChild ? logContent.lastElementChild.innerText : ""; const lastElapsedMatch = lastLine.match(/\((\d+)s\)/); if (!lastLine.includes("Rendering image") || !lastElapsedMatch || parseInt(lastElapsedMatch[1]) !== parseInt(elapsed)) { addLog(`🎨 Rendering image (GPU processing)... (${elapsed}s)`); } smartScroll(); return; } if (safeMsg.includes("steps")) { if (currentStageName !== "Comfy Backend - Sampling") { const now = Date.now(); const prevDuration = ((now - currentStageStartTime) / 1000).toFixed(1); if (currentStageName === "Comfy Backend - Model Loading") { addLog(`⏱️ Comfy Backend - Model Loading finished in ${prevDuration}s.`, "normal"); addLog(`🚀 Starting Comfy Backend - Sampling...`, "normal"); } currentStageName = "Comfy Backend - Sampling"; currentStageStartTime = now; } const lastLine = logContent.lastElementChild; if (!lastLine || !lastLine.innerText.includes(safeMsg)) { addLog(`\u2003\u2003${safeMsg}`, "normal"); } smartScroll(); return; } if (currentStageName === "Loading VRAM" && !safeMsg.includes("Executing") && (safeMsg.includes("Waiting for GPU") || safeMsg.includes("Queue for GPU"))) { return; } if (safeMsg.includes("Loading") || safeMsg.includes("Swapping") || safeMsg.includes("Waiting") || safeMsg.includes("GPU Acquired") || safeMsg.includes("Queue") || safeMsg.includes("Executing") || safeMsg.includes("Sampling") || safeMsg.includes("KSampler") || currentStageName === "Comfy Backend - Sampling") { const elapsed = ((Date.now() - currentStageStartTime) / 1000).toFixed(0); const lastLine = logContent.lastElementChild; let isNewSecond = true; if (lastLine && lastLine.dataset.lastElapsed === elapsed) { isNewSecond = false; } if (isNewSecond) { const newLine = document.createElement('div'); newLine.className = 'log-line'; let displayMsg = safeMsg; if (currentStageName === "Comfy Backend - Model Loading") { displayMsg = "📦 Loading tensors to VRAM..."; } else if (currentStageName === "Comfy Backend - Sampling") { displayMsg = "🎨 Running KSampler..."; } newLine.innerText = `[${new Date().toLocaleTimeString('en-US', { hour12: false })}] ${displayMsg} (Elapsed: ${elapsed}s)`; newLine.dataset.lastElapsed = elapsed; logContent.appendChild(newLine); if (logContent.childElementCount > 100) { logContent.firstElementChild.remove(); } } } else { if (!lastText.includes(safeMsg)) { addLog(safeMsg); } } smartScroll() }, onSuccess: async (imageBase64, returnedSeed, metrics) => { placeholderText.style.display = 'none'; console.log("🚨 DEBUG METRICS:", JSON.stringify(metrics)); const now = Date.now(); const totalDuration = ((now - generateStartTime) / 1000).toFixed(1); const nowForStage = Date.now(); const prevStageDuration = ((nowForStage - currentStageStartTime) / 1000).toFixed(1); if (currentStageName === "Comfy Backend - Model Loading") { addLog(`⏱️ Comfy Backend - Model Loading finished in ${prevStageDuration}s.`, "normal"); addLog(`🚀 Starting Comfy Backend - Sampling...`, "normal"); const realSteps = metrics?.steps || "?"; addLog(`🎨 Rendering: ${realSteps}/${realSteps} steps (100%)`, "normal"); const pureRender = metrics ? (metrics.workflow - metrics.load_time) : 0; addLog(`⏱️ Comfy Backend - Sampling finished in ${pureRender > 0 ? pureRender.toFixed(2) : "0.0"}s.`, "normal"); } else if (currentStageName === "Comfy Backend - Sampling") { addLog(`⏱️ Comfy Backend - Sampling finished in ${prevStageDuration}s.`, "normal"); } else if (currentStageName === "Queueing" || currentStageName === "Comfy Workflow") { addLog(`⏱️ Task execution completed. Syncing final stages...`, "normal"); } addLog("✅ Generation successful!", "highlight"); addLog(`⏱️ Total Wait Time: ${totalDuration}s`, "highlight"); if (metrics) { addLog(`----------------------------------------`, "normal"); addLog(`📊 [Backend Precise Metrics]`, "highlight"); const dl = Number(metrics.download || 0); const q = Number(metrics.queue || 0); const wf = Number(metrics.workflow || 0); const load = Number(metrics.load_time || 0); const pureRender = wf - load > 0 ? (wf - load) : wf; const serverComputeTotal = (dl + q + wf).toFixed(2); const networkOverhead = Math.max(0, totalDuration - serverComputeTotal).toFixed(1); const secPerStep = pureRender > 0 && metrics.steps > 0 ? (pureRender / metrics.steps).toFixed(2) : 0; const metricsLines = [ `\u2003\u2003⬇️ Resource Download: ${dl.toFixed(2)}s`, `\u2003\u2003⏳ GPU Queue: ${q.toFixed(2)}s`, load > 0 ? `\u2003\u2003📦 Model Loading: ${load.toFixed(2)}s` : null, `\u2003\u2003🎨 Pure Compute (Sampler & Decode): ${pureRender.toFixed(2)}s (${metrics.steps} steps)`, secPerStep > 0 ? `\u2003\u2003🚀 Average Speed: ${secPerStep}s / step` : null, `\u2003\u2003🌐 Network Overhead: ~${networkOverhead}s`, `\u2003\u2003⚙️ Server Compute Total: ${serverComputeTotal}s` ].filter(Boolean); // 启动“打字机” let currentIndex = 0; const typeMetrics = () => { if (currentIndex < metricsLines.length) { requestAnimationFrame(() => { addLog(metricsLines[currentIndex], "normal"); smartScroll(); // 🌟 强制触发一次滚动检查 currentIndex++; setTimeout(typeMetrics, 120); }); } else { setTimeout(() => { addLog(`----------------------------------------`, "normal"); smartScroll(); // 🌟 收尾也滚一下 if (returnedSeed !== undefined && returnedSeed !== null) { addLog(`🎲 Random Seed resolved to: ${returnedSeed}`, "highlight"); smartScroll(); // 🌟 Seed 出来也滚一下 } }, 300); } }; typeMetrics(); } else { addLog(`⚠️ [Backend Metrics Missing]`, "error"); } // ========================================== // 🌟 第三阶段:图像渲染与收尾(异步无阻塞解码) // ========================================== setTimeout(async () => { try { // 使用 URL.createObjectURL 而不是 Blob 读取,速度更快 const res = await fetch(imageBase64); const blob = await res.blob(); // 释放上一次的内存 if (window.currentBlobUrl) { URL.revokeObjectURL(window.currentBlobUrl); } window.currentBlobUrl = URL.createObjectURL(blob); // 强制让出主线程,等图片加载完成再显示 resultImage.onload = () => { resultImage.style.display = 'block'; setUIState(false); // 只有图片真正显示出来了,才恢复按钮 resultImage.onload = null; }; resultImage.src = window.currentBlobUrl; } catch (e) { console.error("图片渲染异常", e); setUIState(false); } }, 100); // 延迟 100ms 让打字机先起跑 }, onError: (msg) => { let shortMsg = msg; if (msg && msg.length > 60) { shortMsg = msg.substring(0, 60) + "..."; } placeholderText.style.display = 'block'; placeholderText.innerText = `❌ Failed: ${shortMsg}`; setUIState(false); addLog(`❌ Error: ${msg}`, "error"); } }); } catch (error) { placeholderText.style.display = 'block'; placeholderText.innerText = `❌ Request failed: ${error.message}`; setUIState(false); addLog(`❌ Request Error: ${error.message}`, "error"); } }); // ========================================================================== // 🚀 Initialization (带双重执行拦截锁) // ========================================================================== let isAppInitialized = false; // 🌟 全局锁 document.addEventListener("DOMContentLoaded", async () => { // 🌟 核心修复:如果已经初始化过了,直接 return,拦截掉第二次的幽灵执行 if (isAppInitialized) { console.log("⚠️ [Init] 拦截到重复的 DOMContentLoaded,已忽略。"); return; } isAppInitialized = true; setTimeout(() => { window._isDataReady = true; // 10秒后强行标记为就绪 }, 10000); console.log("🔥 触发初始化..."); setUIState(false); try { const isHealthy = await BackendAPI.checkHealth(); if (isHealthy) { console.log("✅ 后端在线,开始拉取数据"); backendState.isReady = true; document.getElementById('backend-status-dot').className = "status-dot online"; setUIState(false); await loadInitialData(); } else { console.warn("⚠️ 首次心跳失败,启动后台轮询..."); document.getElementById('backend-status-dot').className = "status-dot offline"; startHealthPolling(); await loadInitialData(); } } catch (error) { console.error("❌ 初始化发生严重错误:", error); document.getElementById('backend-status-dot').className = "status-dot offline"; startHealthPolling(); } });