1128 lines
43 KiB
JavaScript
1128 lines
43 KiB
JavaScript
/* =========================================================================
|
||
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();
|
||
})();
|