Files
BiRefNet_WebUI/web/app.js
T
2026-10-08 09:46:47 +08:00

1128 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =========================================================================
BiRefNet WebUI 前端逻辑
- 上传:拖拽 / 点击 / 剪贴板粘贴,单槽(每次只处理一张,新图替换旧图)
- 任务:POST /api/tasks 建任务 → 轮询 GET /api/tasks/<id> 拿进度
- 结果:跨任务累积成历史列表,任何操作(换参数 / 传新图 / 再次处理)
都不会清空已有记录;仅「×」单条移除与「清空」会减少记录
- 参数:每条记录携带处理时的参数快照;按文件名记住「最近一次」的参数,
重新处理时自动重新赋值给表单
========================================================================= */
(() => {
'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(), // 图片序号 -> <li> 行节点
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();
})();