/* ========================================================================= BiRefNet WebUI 前端逻辑 - 上传:拖拽 / 点击 / 剪贴板粘贴,单槽(每次只处理一张,新图替换旧图) - 任务:POST /api/tasks 建任务 → 轮询 GET /api/tasks/ 拿进度 - 结果:跨任务累积成历史列表,任何操作(换参数 / 传新图 / 再次处理) 都不会清空已有记录;仅「×」单条移除与「清空」会减少记录 - 参数:每条记录携带处理时的参数快照;按文件名记住「最近一次」的参数, 重新处理时自动重新赋值给表单 ========================================================================= */ (() => { 'use strict'; const $ = (id) => document.getElementById(id); const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/bmp', 'image/tiff', 'image/gif']; const IMAGE_EXT = /\.(png|jpe?g|webp|bmp|tiff?|gif)$/i; const LS_KEY = 'birefnet.webui.options'; // 表单当前值 const LS_PARAMS = 'birefnet.webui.imageParams'; // 文件名 -> 最近一次参数 // 本地存档结构版本:v2 起「同时输出遮罩」默认关闭、输出尺寸参数改名 final_longest_side。 // 老存档里被显式存下来的 output_mask 不能覆盖新默认值,否则用户永远关不掉。 const LS_SCHEMA = 2; const MAX_PARAM_ENTRIES = 200; const FINAL_STATES = ['done', 'partial', 'failed', 'canceled']; // 批量任务动辄几百张,轮询只拉最后 N 行:处理是按序串行的,窗口外的一定已是终态 const BATCH_TAIL = 40; const state = { pending: null, // 单槽待处理:{ file, url, key(文件名) } models: [], defaults: {}, env: null, activeTaskId: null, // 正在轮询的单图任务 timer: null, view: 'cutout', records: new Map(), // uid -> record(跨任务累积,uid = taskId#index) dismissed: new Set(), // uid,用户显式移除过,轮询不再推回来 selected: null, // 当前选中的 uid running: false, paramsByImage: {}, // 文件名 -> 最近一次使用的参数 taskOptions: new Map(), // taskId -> 该任务使用的参数 // 目录批量任务:与单图任务共用同一个服务端串行队列,但前端独立进度与清单 batch: { taskId: null, running: false, timer: null, rows: new Map(), // 图片序号 ->
  • 行节点 reportVisible: false, // 批量清单是否展开(空状态判断要用它,不能嗅 DOM 属性) }, }; let noteTimer = null; /* ----------------------------- 工具函数 ----------------------------- */ function el(tag, attrs = {}, ...children) { const node = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === 'class') node.className = v; else if (k === 'text') node.textContent = v; else if (k.startsWith('on')) node.addEventListener(k.slice(2), v); else if (v !== null && v !== undefined) node.setAttribute(k, v); } for (const child of children) if (child) node.append(child); return node; } function fmtBytes(n) { if (!n && n !== 0) return '—'; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; return `${(n / 1024 / 1024).toFixed(1)} MB`; } function setError(msg) { const box = $('error-text'); if (!msg) { box.hidden = true; box.textContent = ''; return; } box.hidden = false; box.textContent = msg; } function setNote(msg) { const box = $('pending-note'); if (box) box.textContent = msg || ''; if (noteTimer) clearTimeout(noteTimer); if (msg) noteTimer = setTimeout(() => { if (box) box.textContent = ''; }, 6000); } async function api(path, options) { const res = await fetch(path, options); const text = await res.text(); let data; try { data = text ? JSON.parse(text) : {}; } catch { data = { error: text }; } if (!res.ok || data.ok === false) throw new Error(data.error || `请求失败 ${res.status}`); return data; } function scrollTo(node) { if (node && typeof node.scrollIntoView === 'function') { try { node.scrollIntoView({ block: 'nearest' }); } catch { /* 忽略 */ } } } /* ----------------------------- 初始化 ----------------------------- */ async function init() { bindEvents(); restoreOptions(); try { await loadState(); } catch (err) { setError(`读取服务状态失败:${err.message}`); } try { await loadHistory(); // 恢复历史结果(服务端仍在内存里的任务) } catch (err) { setError(`恢复历史结果失败:${err.message}`); } } async function loadState() { const data = await api('/api/state'); state.models = data.models || []; state.defaults = data.defaults || {}; state.env = data.environment || {}; const device = state.env.cuda ? (state.env.devices?.[0]?.name || 'CUDA') + ` · ${state.env.devices?.[0]?.free_mem || '?'}G 空闲` : `CPU · torch ${state.env.torch}`; $('meta-device').textContent = device; $('meta-models').textContent = `${state.models.length} 个权重`; const nodePath = data.node_dir || ''; const nodeEl = $('meta-node'); nodeEl.textContent = nodePath; nodeEl.title = nodePath; renderModelOptions(); toggleDeviceOptions(); // 批量处理的默认输出目录 = 项目 outputs(服务端返回的绝对路径) const outInput = $('batch-output-dir'); if (data.output_dir) { outInput.placeholder = `默认:${data.output_dir}`; if (!outInput.value) outInput.value = data.output_dir; } } function renderModelOptions() { const select = $('opt-model'); const previous = select.value; select.textContent = ''; if (!state.models.length) { select.append(el('option', { value: '', text: '未找到模型权重' })); $('model-hint').textContent = '请将 *.safetensors 放入模型目录'; return; } for (const m of state.models) { const label = `${m.name} · ${m.size_mb}MB · ${m.backbone}`; select.append(el('option', { value: m.key, text: label, title: m.path })); } const want = previous || state.defaults.model; const exists = state.models.some((m) => m.key === want); select.value = exists ? want : state.models[0].key; onModelChange(); } function onModelChange() { const model = state.models.find((m) => m.key === $('opt-model').value); $('model-hint').textContent = model ? `${model.file} · ${model.arch === 'old' ? '旧版' : '新版'}` : ''; } function toggleDeviceOptions() { const select = $('opt-device'); for (const opt of select.querySelectorAll('option')) { if (opt.value === 'auto') opt.disabled = !state.env?.cuda; } if (!state.env?.cuda && select.value === 'auto') select.value = 'cpu'; } /* ----------------------------- 事件绑定 ----------------------------- */ function bindEvents() { const dropzone = $('dropzone'); const input = $('file-input'); dropzone.addEventListener('click', () => input.click()); dropzone.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } }); input.addEventListener('change', () => { addFiles(input.files); input.value = ''; }); ['dragenter', 'dragover'].forEach((evt) => dropzone.addEventListener(evt, (e) => { e.preventDefault(); dropzone.classList.add('over'); })); ['dragleave', 'drop'].forEach((evt) => dropzone.addEventListener(evt, (e) => { e.preventDefault(); dropzone.classList.remove('over'); })); dropzone.addEventListener('drop', (e) => addFiles(e.dataTransfer?.files)); document.addEventListener('dragover', (e) => e.preventDefault()); document.addEventListener('drop', (e) => e.preventDefault()); document.addEventListener('paste', (e) => addFiles(e.clipboardData?.files)); $('btn-run').addEventListener('click', run); $('btn-cancel').addEventListener('click', cancelTask); $('btn-batch').addEventListener('click', batchRun); $('btn-batch-cancel').addEventListener('click', () => cancelTaskById(state.batch.taskId)); $('btn-batch-hide').addEventListener('click', () => { $('batch-report').hidden = true; state.batch.reportVisible = false; updateStageTools(); }); for (const id of ['batch-input-dir', 'batch-output-dir']) { $(id).addEventListener('keydown', (e) => { if (e.key === 'Enter') batchRun(); }); } $('btn-clear').addEventListener('click', () => clearResults(false)); $('btn-zip').addEventListener('click', downloadZip); $('btn-unload').addEventListener('click', async () => { try { await api('/api/engine/unload', { method: 'POST' }); flash($('btn-unload'), '已卸载'); } catch (err) { setError(err.message); } }); $('btn-reload-models').addEventListener('click', async () => { try { const data = await api('/api/models/reload', { method: 'POST' }); state.models = data.models || []; renderModelOptions(); flash($('btn-reload-models'), '已刷新'); } catch (err) { setError(err.message); } }); $('opt-model').addEventListener('change', onModelChange); $('opt-resolution-mode').addEventListener('change', syncSizeRows); $('opt-background').addEventListener('change', syncBgRow); $('opt-refine').addEventListener('change', syncBlurRow); $('opt-bgcolor').addEventListener('input', () => saveOptions()); $('swatches').addEventListener('click', (e) => { const btn = e.target.closest('button[data-color]'); if (!btn) return; $('opt-bgcolor').value = btn.dataset.color; saveOptions(); }); $('view-switch').addEventListener('click', (e) => { const btn = e.target.closest('button[data-view]'); if (!btn) return; setView(btn.dataset.view); }); for (const node of document.querySelectorAll('select, input')) { if (node.id === 'file-input') continue; node.addEventListener('change', saveOptions); } document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !$('btn-run').disabled) run(); if (e.key === 'Escape' && state.selected !== null) toggleSelect(state.selected); }); // 初始化只同步行的显隐,**不能**顺手写存档:那会把用户上次的参数覆盖成默认值, // 后面的 restoreOptions() 就再也读不回来了。 syncSizeRows(false); syncBgRow(false); syncBlurRow(false); } function flash(btn, text) { if (!btn) return; if (btn.dataset.timer) clearTimeout(Number(btn.dataset.timer)); const origin = btn.dataset.label || btn.textContent; btn.dataset.label = origin; btn.textContent = text; btn.dataset.timer = String(setTimeout(() => { btn.textContent = origin; }, 1200)); } /** 同步「预处理尺寸」相关行的显隐;persist=false 时只改界面不写存档。 */ function syncSizeRows(persist = true) { const mode = $('opt-resolution-mode').value; $('row-longest').classList.toggle('hidden', mode !== 'longest'); $('row-wh').classList.toggle('hidden', mode !== 'custom'); if (persist) saveOptions(); } function syncBgRow(persist = true) { $('row-bgcolor').classList.toggle('hidden', $('opt-background').value !== 'color'); if (persist) saveOptions(); } function syncBlurRow(persist = true) { $('row-blur').classList.toggle('hidden', !$('opt-refine').checked); if (persist) saveOptions(); } /* --------------------------- 待处理(单槽) --------------------------- */ /** 图片身份 = 文件名(参数按此记忆;不同图片只要文件名不同就互不影响)。 */ function imageKey(file) { return (file && file.name) || ''; } /** 待处理槽去重用:文件名 + 字节数。 */ function fileSig(file) { return `${file.name}|${file.size}`; } function addFiles(list) { if (!list || !list.length) return; const items = Array.from(list); if (items.length > 1) { setNote(`一次只处理一张图片,已保留最后一张(忽略前 ${items.length - 1} 张)`); } offerImage(items[items.length - 1], { keepNote: items.length > 1 }); } /** * 放入待处理槽(单槽:直接替换旧图)。 * @param {File} file * @param {{keepNote?: boolean, params?: object|null}} opts * params === undefined → 按文件名自动套用「最近一次」的参数 * params === null → 不动表单(调用方自己决定) * params === {…} → 套用给定参数 * @returns {boolean} 是否放入成功(重复图片返回 false) */ function offerImage(file, opts = {}) { const { keepNote = false, params = undefined } = opts; if (!file) return false; if (!ACCEPTED_TYPES.includes(file.type) && !IMAGE_EXT.test(file.name)) { setError(`已忽略非图片文件:${file.name}`); return false; } const sig = fileSig(file); if (state.pending && state.pending.sig === sig) { setNote(`「${file.name}」已在待处理中,无需重复添加`); return false; } const replaced = !!state.pending; if (state.pending) URL.revokeObjectURL(state.pending.url); const key = imageKey(file); state.pending = { file, url: URL.createObjectURL(file), key, sig }; setError(''); // 参数:默认自动套用这张图上一次用过的参数 let applied = null; if (params !== null) { applied = params !== undefined ? params : lookupParams(key); if (applied) applyOptions(applied); } const notes = []; if (replaced) notes.push(`已用「${file.name}」替换上一张待处理图片`); if (applied) notes.push(`已套用「${file.name}」上次保存的参数`); if (!keepNote && notes.length) setNote(notes.join(';')); else if (!keepNote && !notes.length) setNote(''); renderPending(); return true; } function removePending() { if (!state.pending) return; URL.revokeObjectURL(state.pending.url); state.pending = null; setNote(''); renderPending(); } function renderPending() { const ul = $('thumbs'); ul.textContent = ''; const p = state.pending; if (p) { ul.append(el('li', { class: 'pending-item' }, el('div', { class: 'thumb-wrap' }, el('img', { src: p.url, alt: '' })), el('div', { class: 'pending-meta' }, el('span', { class: 'tname', text: p.file.name, title: p.file.name }), el('span', { class: 'tsize', text: fmtBytes(p.file.size) }), ), el('button', { class: 'tremove', type: 'button', title: '移除待处理图片', text: '×', onclick: removePending }), )); } $('file-count').textContent = p ? '待处理 1 张' : '未选择'; $('btn-run').disabled = !p || state.running || !state.models.length; } /* ----------------------------- 参数 ----------------------------- */ function collectOptions() { return { model: $('opt-model').value, device: $('opt-device').value, dtype: $('opt-dtype').value, arch: $('opt-arch').value, resolution_mode: $('opt-resolution-mode').value, width: Number($('opt-width').value) || 1024, height: Number($('opt-height').value) || 1024, longest_side: Number($('opt-longest-side').value) || 1024, upscale_method: $('opt-upscale').value, mask_threshold: Number($('opt-threshold').value) || 0, refine_foreground: $('opt-refine').checked, blur_size: Number($('opt-blur1').value) || 90, blur_size_two: Number($('opt-blur2').value) || 6, background: $('opt-background').value, bg_color: $('opt-bgcolor').value, output_mask: $('opt-mask').checked, final_longest_side: Number($('opt-final-side').value) || 0, }; } const OPTION_FIELDS = { 'opt-device': 'device', 'opt-dtype': 'dtype', 'opt-arch': 'arch', 'opt-resolution-mode': 'resolution_mode', 'opt-width': 'width', 'opt-height': 'height', 'opt-longest-side': 'longest_side', 'opt-upscale': 'upscale_method', 'opt-threshold': 'mask_threshold', 'opt-blur1': 'blur_size', 'opt-blur2': 'blur_size_two', 'opt-background': 'background', 'opt-bgcolor': 'bg_color', 'opt-final-side': 'final_longest_side', }; /** 把一份参数重新赋值到表单上(重新处理的关键动作)。 */ function applyOptions(src) { if (!src || typeof src !== 'object') return false; let touched = false; for (const [id, key] of Object.entries(OPTION_FIELDS)) { let v = src[key]; // 兼容 v1 存档里的旧参数名(max_output_side = 输出长边上限) if (v === undefined && key === 'final_longest_side') v = src.max_output_side; if (v === undefined || v === null || v === '') continue; $(id).value = String(v); touched = true; } // 模型权重可能已经被删掉/改名,只在仍然存在时才切 if (src.model && state.models.some((m) => m.key === src.model)) { $('opt-model').value = src.model; touched = true; } if (typeof src.refine_foreground === 'boolean') $('opt-refine').checked = src.refine_foreground; if (typeof src.output_mask === 'boolean') $('opt-mask').checked = src.output_mask; onModelChange(); syncSizeRows(false); syncBgRow(false); syncBlurRow(false); saveOptions(); return touched; } function saveOptions() { try { localStorage.setItem(LS_KEY, JSON.stringify({ ...collectOptions(), _schema: LS_SCHEMA })); } catch { /* 忽略隐私模式 */ } } function restoreOptions() { const saved = readJson(LS_KEY); if (!saved || typeof saved !== 'object') return; // 老存档(v1,没有 _schema 字段):丢掉 output_mask,让它在 v2 里回落到「默认不输出遮罩」 if ((Number(saved._schema) || 0) < LS_SCHEMA) delete saved.output_mask; applyOptions(saved); } function readJson(key) { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } } /** 记住某张图「最近一次」使用的参数(同一张图多次处理 → 覆盖为最新)。 */ function rememberParams(fileKey, options) { if (!fileKey || !options) return; state.paramsByImage[fileKey] = options; const keys = Object.keys(state.paramsByImage); if (keys.length > MAX_PARAM_ENTRIES) { for (const stale of keys.slice(0, keys.length - MAX_PARAM_ENTRIES)) delete state.paramsByImage[stale]; } try { localStorage.setItem(LS_PARAMS, JSON.stringify(state.paramsByImage)); } catch { /* 忽略 */ } } function lookupParams(fileKey) { const hit = fileKey ? state.paramsByImage[fileKey] : null; return hit && typeof hit === 'object' ? hit : null; } function loadParams() { const saved = readJson(LS_PARAMS); if (saved && typeof saved === 'object') state.paramsByImage = saved; } /** 参数摘要(卡片上那一行);完整 JSON 放在 title 里。 */ function paramsSummary(o) { if (!o) return ''; const size = o.resolution_mode === 'custom' ? `${o.width}×${o.height}` : o.resolution_mode === 'longest' ? `长边 ${o.longest_side}` : '方形 1024'; const bits = [o.model || '默认模型', size, o.background === 'color' ? `纯色 ${o.bg_color}` : '透明']; if (o.refine_foreground) bits.push('精修'); if (o.mask_threshold) bits.push(`阈值 ${o.mask_threshold}`); if (o.dtype && o.dtype !== 'auto') bits.push(o.dtype); if (o.device === 'cpu') bits.push('CPU'); if (o.final_longest_side) bits.push(`最长边 ${o.final_longest_side}`); return bits.join(' · '); } /* ----------------------------- 任务 ----------------------------- */ async function run() { if (state.running) return; if (!state.pending) { setError('请先选择图片'); return; } if (!$('opt-model').value) { setError('没有可用模型,请检查模型目录'); return; } setError(''); state.running = true; $('btn-run').disabled = true; $('btn-run').textContent = '处理中…'; $('progress').hidden = false; setProgress(0, '上传中…'); // 注意:这里绝不清理结果区 —— 新任务的结果会追加到历史列表末尾 const options = collectOptions(); const fileKey = state.pending.key; const form = new FormData(); form.append('files', state.pending.file, state.pending.file.name); form.append('options', JSON.stringify(options)); try { const data = await api('/api/tasks', { method: 'POST', body: form }); rememberParams(fileKey, options); // 记录该图「最新」参数 state.taskOptions.set(data.task_id, options); state.activeTaskId = data.task_id; preventSleep(false); poll(); } catch (err) { finishRun(err.message); } } function poll() { clearTimeout(state.timer); state.timer = setTimeout(async () => { const taskId = state.activeTaskId; if (!taskId) return; try { const data = await api(`/api/tasks/${taskId}`); renderTask(data.task); if (FINAL_STATES.includes(data.task.state)) { const counts = data.task.counts || {}; const msg = data.task.state === 'canceled' ? '已取消' : `完成 ${counts.done || 0} 张${counts.failed ? `,失败 ${counts.failed} 张` : ''}`; finishRun(data.task.state === 'failed' ? (data.task.error || '处理失败') : null, msg); return; } poll(); } catch (err) { finishRun(err.message); } }, 400); } function finishRun(errorMsg, okMsg) { state.running = false; $('btn-run').disabled = !state.pending || !state.models.length; $('btn-run').textContent = '开始抠图'; preventSleep(true); if (errorMsg) { setError(errorMsg); setProgress(0, '出错'); } else if (okMsg) setProgress(1, okMsg); } let wakeLock = null; async function preventSleep(allow) { try { if (!allow) { if (!wakeLock && 'wakeLock' in navigator) wakeLock = await navigator.wakeLock.request('screen'); } else if (wakeLock) { await wakeLock.release(); wakeLock = null; } } catch { /* 浏览器不支持则忽略 */ } } async function cancelTask() { await cancelTaskById(state.activeTaskId); } /** 取消一个任务(单图与批量共用同一套任务队列)。 */ async function cancelTaskById(taskId) { if (!taskId) return; try { await api(`/api/tasks/${taskId}/cancel`, { method: 'POST' }); } catch { /* 忽略 */ } } /* ----------------------------- 批量处理 ----------------------------- */ /* 与单图模式共用「模型 / 参数」面板;差异只在输入来源(目录)与输出落点 (用户指定目录 + RMBG_ 命名)。批量任务不铺成结果卡片,只在结果区顶部 显示一张逐文件清单 —— 几百张图铺卡片既拖慢页面也没有对比价值。 */ function setBatchError(msg) { const box = $('batch-error'); if (!msg) { box.hidden = true; box.textContent = ''; return; } box.hidden = false; box.textContent = msg; } function setBatchProgress(ratio, stage) { $('batch-progress-bar').style.width = `${Math.max(0, Math.min(1, ratio)) * 100}%`; $('batch-progress-stage').textContent = stage || ''; } async function batchRun() { if (state.batch.running) return; const inputDir = $('batch-input-dir').value.trim(); const outputDir = $('batch-output-dir').value.trim(); // 留空 → 服务端用项目 outputs if (!inputDir) { setBatchError('请先填写图片目录(模板路径)'); return; } if (!state.models.length) { setBatchError('没有可用模型,请检查模型目录'); return; } setBatchError(''); state.batch.running = true; $('btn-batch').disabled = true; $('btn-batch').textContent = '提交中…'; $('batch-progress').hidden = false; setBatchProgress(0, '扫描目录…'); const options = collectOptions(); try { const data = await api('/api/batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input_dir: inputDir, output_dir: outputDir, recursive: $('batch-recursive').checked, options, }), }); resetBatchReport(data.input_dir || inputDir, data.output_dir || outputDir); state.batch.taskId = data.task_id; preventSleep(false); setBatchProgress(0.01, `已提交 ${data.count} 张,排队中…`); pollBatch(true); } catch (err) { batchFinish(err.message, null); } } /** 清空并显示批量清单(新的批量任务开始 / 刷新页面恢复时调用)。 */ function resetBatchReport(inputDir, outputDir) { state.batch.rows.clear(); $('batch-rows').textContent = ''; $('batch-report').hidden = false; state.batch.reportVisible = true; const badge = $('batch-badge'); badge.textContent = '排队中'; badge.className = 'badge'; const path = $('batch-report-path'); path.textContent = `${inputDir} → ${outputDir}`; path.title = `输入目录:${inputDir}\n输出目录:${outputDir}`; $('batch-report-sum').textContent = ''; updateStageTools(); } function pollBatch(full) { clearTimeout(state.batch.timer); state.batch.timer = setTimeout(async () => { const taskId = state.batch.taskId; if (!taskId) return; try { const data = await api(`/api/tasks/${taskId}?tail=${full ? 0 : BATCH_TAIL}`); renderBatchTask(data.task); if (FINAL_STATES.includes(data.task.state)) { // 收尾时再拉一次全量,确保清单里每一行都是最终状态 if (!full) { pollBatch(true); return; } batchFinish(null, data.task); return; } pollBatch(false); } catch (err) { batchFinish(err.message, null); } }, 400); } const BATCH_BADGE = { queued: ['排队中', ''], running: ['处理中', ''], done: ['完成', 'ok'], partial: ['部分完成', 'warn'], failed: ['失败', 'err'], canceled: ['已取消', 'warn'], }; function renderBatchTask(task) { const [text, cls] = BATCH_BADGE[task.state] || [task.state, '']; const badge = $('batch-badge'); badge.textContent = text; badge.className = `badge ${cls}`.trim(); setBatchProgress(task.progress || 0, task.stage || ''); const counts = task.counts || {}; const bits = [ `共 ${task.images_total || 0} 张`, `完成 ${counts.done || 0}`, ]; if (counts.failed) bits.push(`失败 ${counts.failed}`); if (counts.pending) bits.push(`待处理 ${counts.pending}`); if (task.elapsed) bits.push(`${Math.round(task.elapsed)}s`); $('batch-report-sum').textContent = bits.join(' · '); for (const img of task.images || []) upsertBatchRow(img); } function upsertBatchRow(img) { let row = state.batch.rows.get(img.index); if (!row) { const cells = { idx: el('span', { class: 'bi', text: String(img.index + 1) }), from: el('span', { class: 'bs' }), to: el('span', { class: 'bo' }), time: el('span', { class: 'bt' }), badge: el('span', { class: 'badge' }), }; row = el('li', { class: 'batch-row' }, cells.idx, cells.from, cells.to, cells.time, cells.badge); row.cells = cells; state.batch.rows.set(img.index, row); $('batch-rows').append(row); } const cells = row.cells; const [text, cls] = BATCH_BADGE[img.state] || [img.state, '']; cells.from.textContent = img.name || ''; cells.from.title = img.name || ''; cells.to.textContent = img.output_name || ''; cells.to.title = img.output_name || ''; cells.time.textContent = img.state === 'done' ? `${img.elapsed || 0}s` : ''; cells.badge.textContent = text; cells.badge.className = `badge ${cls}`.trim(); row.className = `batch-row ${img.state}`; row.title = img.error ? `${img.name}:${img.error}` : (img.name || ''); } function batchFinish(errorMsg, task) { clearTimeout(state.batch.timer); state.batch.running = false; state.batch.taskId = null; $('btn-batch').disabled = false; $('btn-batch').textContent = '开始批量抠图'; preventSleep(true); if (errorMsg) { setBatchError(errorMsg); setBatchProgress(0, '出错'); return; } const counts = task.counts || {}; const okMsg = task.state === 'canceled' ? '已取消' : `已输出 ${counts.done || 0} 张${counts.failed ? `,失败 ${counts.failed} 张` : ''}`; setBatchProgress(1, okMsg); setBatchError(''); flash($('btn-batch'), `已输出 ${counts.done || 0} 张`); } function setProgress(ratio, stage) { $('progress-bar').style.width = `${Math.max(0, Math.min(1, ratio)) * 100}%`; $('progress-stage').textContent = stage || ''; } /* --------------------------- 结果历史 --------------------------- */ function recordUid(taskId, index) { return `${taskId}#${index}`; } /** 服务端内存里还留着的历史任务 → 渲染成结果记录(刷新页面不丢)。 */ async function loadHistory() { let list = []; try { const data = await api('/api/tasks'); list = data.tasks || []; } catch { return; // 拿不到就当没有历史,不打扰用户 } if (!list.length) return; // 批量任务不铺结果卡片(几百张图铺卡片既慢又没有对比价值), // 只在结果区顶部恢复最近一次的批量清单 restoreBatchReport(list.filter((task) => task.mode === 'batch')); const singles = list.filter((task) => task.mode !== 'batch'); if (!singles.length) return; // 列表按创建时间倒序返回,这里反转为「旧 → 新」,追加式渲染 const ordered = singles.slice().reverse(); const details = await Promise.all(ordered.map(async (task) => { if (task.options) return task; // 列表里已带参数 try { return (await api(`/api/tasks/${task.id}`)).task || task; } catch { return task; } })); for (const task of details) { if (task.options) state.taskOptions.set(task.id, task.options); for (const img of task.images || []) upsertCard(task.id, img); } updateStageTools(); scrollResultsToBottom(); // 刷新页面时若还有任务在跑,接着轮询 const newest = ordered[ordered.length - 1]; if (newest && (newest.state === 'queued' || newest.state === 'running')) { state.activeTaskId = newest.id; state.running = true; $('btn-run').disabled = true; $('btn-run').textContent = '处理中…'; $('progress').hidden = false; poll(); } } /** 恢复最近一次批量任务的清单;若它还在跑,顺手接着轮询。 */ function restoreBatchReport(batchTasks) { if (!batchTasks.length) return; const newest = batchTasks[0]; // 列表已按创建时间倒序 resetBatchReport(newest.input_dir || '(未记录)', newest.output_dir || ''); renderBatchTask(newest); $('batch-progress').hidden = false; if (newest.state === 'queued' || newest.state === 'running') { state.batch.taskId = newest.id; state.batch.running = true; $('btn-batch').disabled = true; $('btn-batch').textContent = '处理中…'; preventSleep(false); pollBatch(true); // 先拉全量把清单补齐,之后只拉尾巴 } } function renderTask(task) { if (task.mode === 'batch') return; // 批量任务由批量清单自己渲染 const options = state.taskOptions.get(task.id) || task.options || null; if (options) state.taskOptions.set(task.id, options); for (const img of task.images || []) upsertCard(task.id, img); updateStageTools(); setProgress(task.progress || 0, task.stage || ''); } function upsertCard(taskId, img) { const uid = recordUid(taskId, img.index); if (state.dismissed.has(uid)) return null; // 用户已移除,轮询不再推回来 let rec = state.records.get(uid); if (!rec) { rec = buildRecord(taskId, img); state.records.set(uid, rec); $('results').append(rec.root); if (img.state === 'pending' || img.state === 'running') scrollTo(rec.root); } rec.image = img; if (!rec.options) rec.options = state.taskOptions.get(taskId) || null; paintRecord(rec); return rec; } function paintRecord(rec) { const img = rec.image; const { refs } = rec; const badgeMap = { pending: ['等待', ''], running: ['处理中', ''], done: ['完成', 'ok'], failed: ['失败', 'err'], canceled: ['已取消', 'warn'], }; const [text, cls] = badgeMap[img.state] || [img.state, '']; refs.badge.textContent = text; refs.badge.className = `badge ${cls}`.trim(); refs.statusText.textContent = `${img.stage || '处理中'} · ${Math.round((img.progress || 0) * 100)}%`; refs.root.className = `card ${img.state}${state.selected === rec.uid ? ' selected' : ''}`; if (rec.options) { refs.params.textContent = paramsSummary(rec.options); refs.params.title = `任务 ${rec.taskId}\n参数:${JSON.stringify(rec.options, null, 2)}`; } if (img.state === 'done' && !rec.built) { rec.built = true; buildCompare(rec); refs.stats.append( stat('尺寸', `${img.width}×${img.height}`), stat('输入', `${img.input_size?.[0]}×${img.input_size?.[1]}`), stat('前景', `${(img.coverage * 100).toFixed(1)}%`), stat('耗时', `${img.elapsed}s`), ); const links = [ downloadLink(img.urls.cutout, `${img.label}_cutout.png`, '下载抠图'), img.urls.mask ? downloadLink(img.urls.mask, `${img.label}_mask.png`, '下载遮罩') : null, el('button', { class: 'btn sm ghost', type: 'button', text: '复制到剪贴板', onclick: (e) => copyImage(rec, e.target) }), ].filter(Boolean); refs.actions.append(...links); if (img.warning) refs.note.textContent = `⚠ ${img.warning}`; } if (img.state === 'failed' && img.error && !refs.error.textContent) { refs.error.textContent = img.error; } } function buildRecord(taskId, img) { const rec = { uid: recordUid(taskId, img.index), taskId, index: img.index, image: img, options: state.taskOptions.get(taskId) || null, built: false, refs: {}, }; const badge = el('span', { class: 'badge', text: '等待' }); const statusText = el('span', { text: '排队中' }); const params = el('p', { class: 'card-params' }); const stats = el('div', { class: 'card-stats' }); const actions = el('div', { class: 'card-actions' }); const note = el('p', { class: 'card-note' }); const error = el('p', { class: 'card-error' }); const compare = el('div', { class: 'compare' }); // 文件名 = 回填待处理 + 套用该图参数的入口 const nameBtn = el('button', { class: 'name', type: 'button', title: '点击把这张图的原图放回待处理,并套用记录下来的参数', text: `${img.index + 1}. ${img.name}`, onclick: (e) => { e.stopPropagation(); reuseRecord(rec, e.currentTarget); }, }); const delBtn = el('button', { class: 'card-del', type: 'button', title: '从结果列表移除这一条', text: '×', onclick: (e) => { e.stopPropagation(); dismissCard(rec.uid); }, }); const reuseBtn = el('button', { class: 'btn sm ghost', type: 'button', text: '重新处理', title: '把这张图放回待处理,并把它上次用的参数重新赋值到左侧面板', onclick: (e) => { e.stopPropagation(); reuseRecord(rec, e.currentTarget); }, }); actions.append(reuseBtn); const root = el('article', { class: 'card pending', tabindex: '0', 'aria-label': `结果 ${img.index + 1}:${img.name}` }, el('div', { class: 'card-head' }, nameBtn, badge, delBtn), params, el('div', { class: 'status' }, el('span', { class: 'spinner' }), statusText), compare, el('div', { class: 'card-body' }, stats, actions, note, error), ); // 点击卡片本体选中(按钮/链接/滑块等交互元素不触发) root.addEventListener('click', (e) => { if (e.target.closest('button, a, input, .compare')) return; toggleSelect(rec.uid); }); root.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSelect(rec.uid); } }); rec.root = root; rec.refs = { root, badge, statusText, params, stats, actions, note, error, compare, nameBtn, delBtn }; return rec; } /** 选中/取消选中一条结果记录(主题色描边)。 */ function toggleSelect(uid) { state.selected = state.selected === uid ? null : uid; for (const [key, rec] of state.records) { rec.root.classList.toggle('selected', key === state.selected); } } /** 从结果列表删除一条记录(文件留在磁盘上)。 */ function dismissCard(uid) { const rec = state.records.get(uid); if (!rec) return; rec.root.remove(); state.records.delete(uid); state.dismissed.add(uid); if (state.selected === uid) state.selected = null; updateStageTools(); } /** 把某条结果的原图放回待处理槽 + 套用它记录下来的参数。 */ async function reuseRecord(rec, btn) { const img = rec.image; try { const res = await fetch(img.urls.original); if (!res.ok) throw new Error(`取原图失败(HTTP ${res.status})`); const blob = await res.blob(); const name = img.name || `image_${rec.index + 1}.png`; const file = new File([blob], name, { type: blob.type || 'image/png', lastModified: 0 }); // 优先用这条记录自己的参数;没有就退回该图最近一次的记录 const params = rec.options || lookupParams(imageKey(file)) || null; if (offerImage(file, { params })) { flash(btn, '已就绪 ✓'); setNote(`已把「${name}」放回待处理,并套用它上次使用的参数${params ? '' : '(无参数记录)'}`); } else { flash(btn, '已在待处理'); } } catch (err) { setError(`加入待处理失败:${err.message}`); } } function updateStageTools() { const n = state.records.size; $('result-count').textContent = String(n); $('btn-clear').disabled = n === 0; $('btn-zip').disabled = n === 0; // 批量清单展开时不再显示「还没有结果」(它自己就是结果) $('empty').classList.toggle('hidden', n > 0 || state.batch.reportVisible); } /** 清空整个结果列表(显式操作;被清掉的记录不会因轮询复活)。 */ function clearResults(silent) { for (const uid of state.records.keys()) state.dismissed.add(uid); state.records.clear(); state.selected = null; $('results').textContent = ''; updateStageTools(); if (!silent) setError(''); } function scrollResultsToBottom() { const grid = $('results'); if (grid && typeof grid.scrollHeight === 'number') grid.scrollTop = grid.scrollHeight; } /* ----------------------------- 结果渲染 ----------------------------- */ function buildCompare(rec) { const { compare } = rec.refs; const img = rec.image; const base = el('img', { class: 'before', alt: '原图', src: img.urls.original }); const after = el('img', { class: 'after', alt: '抠图结果', src: viewUrl(img) }); const divider = el('div', { class: 'divider' }); const slider = el('input', { type: 'range', min: '0', max: '100', value: '50', 'aria-label': '对比分割线' }); slider.addEventListener('input', () => compare.style.setProperty('--p', `${slider.value}%`)); compare.style.setProperty('--p', '50%'); compare.append( base, after, divider, slider, el('span', { class: 'tag left', text: '原图' }), el('span', { class: 'tag right', text: '结果' }), ); rec.refs.afterImg = after; } function viewUrl(img) { const urls = img.urls || {}; return urls[state.view] || urls.cutout || urls.original; } function setView(view) { state.view = view; for (const btn of $('view-switch').querySelectorAll('button')) { btn.classList.toggle('active', btn.dataset.view === view); } for (const rec of state.records.values()) { const img = rec.image; if (!rec.refs.afterImg || !img) continue; if (view === 'mask' && !img.urls?.mask) continue; rec.refs.afterImg.src = viewUrl(img); } } function stat(label, value) { return el('span', {}, el('b', { text: `${label} ` }), document.createTextNode(String(value))); } function downloadLink(url, filename, text) { return el('a', { class: 'btn sm ghost', href: url, download: filename, text }); } async function copyImage(rec, button) { try { const res = await fetch(rec.image.urls.cutout); const blob = await res.blob(); await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); flash(button, '已复制'); } catch (err) { setError(`复制失败(浏览器需支持 Clipboard API):${err.message}`); } } /* ----------------------------- 打包下载 ----------------------------- */ function uniqueName(used, name) { let candidate = name; let n = 2; while (used.has(candidate)) { const dot = name.lastIndexOf('.'); candidate = dot > 0 ? `${name.slice(0, dot)}-${n}${name.slice(dot)}` : `${name}-${n}`; n += 1; } used.add(candidate); return candidate; } function stamp() { const d = new Date(); const p = (v) => String(v).padStart(2, '0'); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; } function saveBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; if (document.body) document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 4000); } /** 把结果列表里所有记录打成一个 ZIP(记录可能来自多个任务)。 */ async function downloadZip() { const recs = [...state.records.values()]; if (!recs.length) return; if (!window.BiRefNetZip) { setError('打包模块未加载(web/zip.js)'); return; } const btn = $('btn-zip'); const origin = btn.dataset.label || btn.textContent; btn.dataset.label = origin; btn.disabled = true; btn.textContent = '打包中…'; setError(''); let okMsg = null; try { const kinds = state.view === 'mask' ? ['cutout', 'mask'] : ['cutout']; const used = new Set(); const entries = []; for (const rec of recs) { const urls = rec.image.urls || {}; for (const kind of kinds) { if (!urls[kind]) continue; const res = await fetch(urls[kind]); if (!res.ok) continue; const data = new Uint8Array(await res.arrayBuffer()); const label = rec.image.label || `image_${rec.index + 1}`; const name = `${String(rec.index + 1).padStart(3, '0')}_${label}_${kind}.png`; entries.push({ name: uniqueName(used, name), data }); } } if (!entries.length) throw new Error('没有可打包的结果文件'); saveBlob(new Blob([window.BiRefNetZip.buildZip(entries)], { type: 'application/zip' }), `birefnet_results_${stamp()}.zip`); okMsg = `已打包 ${entries.length} 个文件`; } catch (err) { setError(`打包失败:${err.message}`); } finally { btn.textContent = origin; updateStageTools(); } if (okMsg) flash(btn, okMsg); } loadParams(); init(); })();