From c5c18f0617ef19771911c4a51e822b22b531d157 Mon Sep 17 00:00:00 2001 From: 13244431027 <13244431027@163.com> Date: Sat, 22 Aug 2026 21:16:25 +0800 Subject: [PATCH] Update backgroundremoverpanel.js --- extension/backgroundremoverpanel.js | 722 +++++++++++++++++++++------- 1 file changed, 537 insertions(+), 185 deletions(-) diff --git a/extension/backgroundremoverpanel.js b/extension/backgroundremoverpanel.js index 51a8108..5d7b7eb 100644 --- a/extension/backgroundremoverpanel.js +++ b/extension/backgroundremoverpanel.js @@ -2,160 +2,282 @@ 'use strict'; if (!Scratch.extensions.unsandboxed) { - throw new Error('“图片去背景”扩展需要在非沙盒模式下加载'); + throw new Error('图片去背景扩展必须以非沙箱模式运行'); } + const BlockType = Scratch.BlockType; + const ArgumentType = Scratch.ArgumentType; + const Cast = Scratch.Cast; + const EXT_ID = 'bgRemoverPanel'; - const CDN = 'https://cdn.jsdelivr.net/npm/@imgly/background-removal@1.5.8/dist/'; - const vm = Scratch.vm; + + const runtime = Scratch.vm.runtime; + const vm = Scratch.vm; + const state = { root: null, - lib: null, - libPromise: null, - modelStatus: 'idle', // idle | loading | ready | fallback - modelProgress: 0, busy: false, - statusText: '等待上传图片', - sourceURL: '', resultURL: '', + sourceURL: '', fileName: 'image', - lastError: '' + modelProgress: 0, + statusText: '就绪', + lastError: '', + libLoaded: false, + libLoading: false, + removeBackground: null, + mode: 'external' + }; + + const ui = { + status: null, + bar: null, + download: null, + toCostume: null, + input: null, + before: null, + after: null, + modeSelect: null }; - const ui = {}; + + function loadImage(src) { + return new Promise(function (resolve, reject) { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = function () { + resolve(img); + }; + img.onerror = function () { + reject(new Error('图片加载失败')); + }; + img.src = src; + }); + } + + function blobToDataURL(blob) { + return new Promise(function (resolve, reject) { + const reader = new FileReader(); + reader.onload = function () { + resolve(reader.result); + }; + reader.onerror = function () { + reject(new Error('Blob 转 DataURL 失败')); + }; + reader.readAsDataURL(blob); + }); + } + + function dataURLToBytes(dataURL) { + const parts = dataURL.split(','); + const bstr = atob(parts[1]); + const n = bstr.length; + const u8 = new Uint8Array(n); + for (let i = 0; i < n; i++) { + u8[i] = bstr.charCodeAt(i); + } + return u8; + } + const LIB_CDN = 'https://cdn.jsdelivr.net/npm/@imgly/background-removal@1.7.0/+esm'; + function loadLib() { - if (state.lib) return Promise.resolve(state.lib); - if (state.libPromise) return state.libPromise; - state.modelStatus = 'loading'; - setStatus('正在加载本地 AI 模型运行库…'); - state.libPromise = import(/* webpackIgnore: true */ CDN + 'browser.mjs') - .then((mod) => { - state.lib = mod; - state.modelStatus = 'ready'; - setStatus('本地模型运行库已就绪'); - return mod; + if (state.libLoaded && state.removeBackground) { + return Promise.resolve(state.removeBackground); + } + if (state.libLoading) { + return new Promise(function (resolve, reject) { + const check = setInterval(function () { + if (state.libLoaded) { + clearInterval(check); + resolve(state.removeBackground); + } + if (state.lastError && !state.libLoading) { + clearInterval(check); + reject(new Error(state.lastError)); + } + }, 200); + }); + } + + state.libLoading = true; + setStatus('正在加载去背景模型运行库…'); + + return import(/* webpackIgnore: true */ LIB_CDN) + .then(function (mod) { + let fn = mod.default || mod.removeBackground || mod.imglyRemoveBackground || mod; + if (typeof fn !== 'function') { + const keys = Object.keys(mod); + for (let i = 0; i < keys.length; i++) { + if (typeof mod[keys[i]] === 'function') { + fn = mod[keys[i]]; + break; + } + } + } + if (typeof fn !== 'function') { + throw new Error('无法从库中提取 removeBackground 函数'); + } + state.removeBackground = fn; + state.libLoaded = true; + state.libLoading = false; + setStatus('模型运行库已就绪'); + return fn; }) - .catch((err) => { - state.libPromise = null; - state.modelStatus = 'fallback'; - state.lastError = String(err && err.message ? err.message : err); + .catch(function (err) { + state.libLoading = false; + state.lastError = err && err.message ? err.message : String(err); + setStatus('模型运行库加载失败:' + state.lastError); throw err; }); - return state.libPromise; } + function runModel(file) { - return loadLib().then((lib) => - lib.removeBackground(file, { - publicPath: CDN, + return loadLib().then(function (removeBackground) { + setStatus('模型运行中,请耐心等待…'); + const config = { model: 'isnet_fp16', - output: { format: 'image/png', quality: 1 }, - progress: (key, current, total) => { + output: { + format: 'image/png', + quality: 0.9, + type: 'foreground' + }, + progress: function (key, current, total) { if (total > 0) { - state.modelProgress = Math.round((current / total) * 100); - } - if (String(key).indexOf('fetch') === 0) { - setStatus('正在下载本地模型文件 ' + state.modelProgress + '%'); - } else { - setStatus('AI 正在识别主体 ' + state.modelProgress + '%'); + const pct = Math.round((current / total) * 100); + state.modelProgress = pct; + setStatus('外部模型处理中 (' + key + '):' + pct + '%'); } } - }) - ); + }; + return removeBackground(file, config); + }); } - function fallbackRemove(img) { - const w = img.naturalWidth; - const h = img.naturalHeight; + const w = img.naturalWidth || img.width; + const h = img.naturalHeight || img.height; const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); - ctx.drawImage(img, 0, 0); - const data = ctx.getImageData(0, 0, w, h); - const px = data.data; - const visited = new Uint8Array(w * h); - const tolerance = 42 * 42 * 3; - const stack = []; - const seeds = [0, w - 1, (h - 1) * w, h * w - 1]; - for (let i = 0; i < seeds.length; i++) { - const s = seeds[i] * 4; - stack.push({ idx: seeds[i], r: px[s], g: px[s + 1], b: px[s + 2] }); - } - while (stack.length) { - const node = stack.pop(); - const idx = node.idx; - if (visited[idx]) continue; - visited[idx] = 1; - const o = idx * 4; - const dr = px[o] - node.r; - const dg = px[o + 1] - node.g; - const db = px[o + 2] - node.b; - if (dr * dr + dg * dg + db * db > tolerance) continue; - px[o + 3] = 0; - const x = idx % w; - const y = (idx - x) / w; - if (x > 0) stack.push({ idx: idx - 1, r: node.r, g: node.g, b: node.b }); - if (x < w - 1) stack.push({ idx: idx + 1, r: node.r, g: node.g, b: node.b }); - if (y > 0) stack.push({ idx: idx - w, r: node.r, g: node.g, b: node.b }); - if (y < h - 1) stack.push({ idx: idx + w, r: node.r, g: node.g, b: node.b }); - } - ctx.putImageData(data, 0, 0); - return canvas.toDataURL('image/png'); - } + ctx.drawImage(img, 0, 0, w, h); + const imageData = ctx.getImageData(0, 0, w, h); + const data = imageData.data; - function blobToDataURL(blob) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(String(reader.result)); - reader.onerror = () => reject(new Error('读取结果失败')); - reader.readAsDataURL(blob); - }); - } + function getPixel(x, y) { + const idx = (y * w + x) * 4; + return [data[idx], data[idx + 1], data[idx + 2]]; + } - function loadImage(url) { - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = () => reject(new Error('图片解码失败')); - img.src = url; - }); + const corners = [ + getPixel(0, 0), + getPixel(w - 1, 0), + getPixel(0, h - 1), + getPixel(w - 1, h - 1) + ]; + + let bgR = 0, bgG = 0, bgB = 0; + for (let c = 0; c < 4; c++) { + bgR += corners[c][0]; + bgG += corners[c][1]; + bgB += corners[c][2]; + } + bgR = Math.round(bgR / 4); + bgG = Math.round(bgG / 4); + bgB = Math.round(bgB / 4); + + const tolerance = 45; + + for (let i = 0; i < data.length; i += 4) { + const dr = data[i] - bgR; + const dg = data[i + 1] - bgG; + const db = data[i + 2] - bgB; + const dist = Math.sqrt(dr * dr + dg * dg + db * db); + if (dist < tolerance) { + data[i + 3] = 0; + } else if (dist < tolerance + 20) { + const alpha = Math.round(((dist - tolerance) / 20) * 255); + data[i + 3] = Math.min(data[i + 3], alpha); + } + } + + ctx.putImageData(imageData, 0, 0); + return canvas.toDataURL('image/png'); } - function dataURLToBytes(dataURL) { - const base64 = String(dataURL).split(',')[1] || ''; - const bin = atob(base64); - const bytes = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); - return bytes; + function getEditingTarget() { + return vm.editingTarget || (runtime.getTargetForStage && runtime.getTargetForStage()) || null; } function uniqueCostumeName(target, base) { - const names = target.getCostumes().map((c) => c.name); + const names = (target.getCostumes() || []).map(function (c) { return c.name; }); if (names.indexOf(base) === -1) return base; let i = 2; while (names.indexOf(base + i) !== -1) i++; return base + i; } + function getStorage() { + return (runtime && runtime.storage) || vm.storage || null; + } + + function attachCostume(target, md5ext, costume) { + if (typeof vm.addCostume === 'function') { + return Promise.resolve(vm.addCostume(md5ext, costume, target.id)); + } + if (typeof target.addCostume !== 'function') { + return Promise.reject(new Error('当前 VM 不支持添加造型')); + } + return prepareCostumeForRender(costume).then(function () { + const index = target.getCostumes().length; + target.addCostume(costume, index); + if (target.setCostume) target.setCostume(index); + return costume; + }); + } + + function prepareCostumeForRender(costume) { + const renderer = runtime && runtime.renderer; + if (!renderer) return Promise.resolve(costume); + return loadImage(state.resultURL).then(function (img) { + costume.size = [img.naturalWidth, img.naturalHeight]; + const rotationCenter = [ + costume.rotationCenterX * costume.bitmapResolution, + costume.rotationCenterY * costume.bitmapResolution + ]; + costume.skinId = renderer.createBitmapSkin(img, costume.bitmapResolution, rotationCenter); + return costume; + }); + } + function saveResultToTarget(target, name) { + const storage = getStorage(); + if (!storage) { + return Promise.reject(new Error('当前环境无法访问项目资源库')); + } if (!state.resultURL) return Promise.reject(new Error('还没有去背景结果')); if (!target) return Promise.reject(new Error('找不到目标角色')); - const storage = vm.runtime.storage; - const asset = storage.createAsset( - storage.AssetType.ImageBitmap, - storage.DataFormat.PNG, - dataURLToBytes(state.resultURL), - null, - true // 自动计算 md5 assetId - ); + + let asset; + try { + asset = storage.createAsset( + storage.AssetType.ImageBitmap, + storage.DataFormat.PNG, + dataURLToBytes(state.resultURL), + null, + true + ); + } catch (e) { + return Promise.reject(new Error('创建图片资源失败:' + (e && e.message ? e.message : e))); + } + const md5ext = asset.assetId + '.' + storage.DataFormat.PNG; - return loadImage(state.resultURL).then((img) => { + return loadImage(state.resultURL).then(function (img) { const costume = { name: uniqueCostumeName(target, name || state.fileName + '-no-bg'), dataFormat: storage.DataFormat.PNG, @@ -166,32 +288,91 @@ rotationCenterX: img.naturalWidth / 2, rotationCenterY: img.naturalHeight / 2 }; - return Promise.resolve(vm.addCostume(md5ext, costume, target.id)).then(() => costume.name); + return attachCostume(target, md5ext, costume).then(function () { + if (typeof vm.emitTargetsUpdate === 'function') vm.emitTargetsUpdate(); + if (typeof runtime.requestTargetsUpdate === 'function') { + runtime.requestTargetsUpdate(target); + } + if (typeof runtime.requestRedraw === 'function') { + runtime.requestRedraw(); + } + return costume.name; + }); }); } + /* ============== UI 状态 ============== */ + function setStatus(text) { state.statusText = text; if (ui.status) ui.status.textContent = text; if (ui.bar) ui.bar.style.width = state.modelProgress + '%'; } + /* ============== 面板 UI ============== */ const CHECKER = 'linear-gradient(45deg,#e6e6e6 25%,transparent 25%,transparent 75%,#e6e6e6 75%),' + 'linear-gradient(45deg,#e6e6e6 25%,transparent 25%,transparent 75%,#e6e6e6 75%)'; + function resetResultDisplay() { + state.resultURL = ''; + if (ui.after) { + ui.after.img.style.display = 'none'; + ui.after.img.src = ''; + } + if (ui.download) { + ui.download.disabled = true; + ui.download.style.opacity = '.5'; + } + if (ui.toCostume) { + ui.toCostume.disabled = true; + ui.toCostume.style.opacity = '.5'; + } + state.modelProgress = 0; + if (ui.bar) ui.bar.style.width = '0%'; + } + + function updateModeSelector(mode) { + if (ui.modeSelect) { + ui.modeSelect.value = mode; + } + state.mode = mode; + resetResultDisplay(); + updatePreloadButton(); + setStatus('已切换至' + (mode === 'external' ? '外部模型' : '内置算法') + '模式'); + } + + function updatePreloadButton() { + const preloadBtn = state.root && state.root.querySelector('[data-action="preload"]'); + if (preloadBtn) { + if (state.mode === 'internal') { + preloadBtn.disabled = true; + preloadBtn.style.opacity = '.5'; + preloadBtn.title = '内置算法无需预加载'; + } else { + preloadBtn.disabled = false; + preloadBtn.style.opacity = '1'; + preloadBtn.title = ''; + } + } + } + function buildPanel() { const root = document.createElement('div'); + root.setAttribute('data-extension', EXT_ID); root.style.cssText = 'position:fixed;top:60px;left:60px;z-index:2147483000;width:520px;background:#fff;' + 'border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.28);font:14px/1.5 system-ui,sans-serif;color:#1f2937;overflow:hidden'; + /* --- 标题栏 --- */ const header = document.createElement('div'); header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 14px;' + 'background:#2563eb;color:#fff;cursor:move;user-select:none'; - header.innerHTML = '图片去背景(本地模型)'; + const title = document.createElement('strong'); + title.textContent = '图片去背景(本地模型)'; + header.appendChild(title); const close = document.createElement('button'); close.textContent = '×'; close.setAttribute('aria-label', '关闭面板'); @@ -200,9 +381,38 @@ close.onclick = closePanel; header.appendChild(close); + /* --- 主体 --- */ const body = document.createElement('div'); body.style.cssText = 'padding:14px;display:flex;flex-direction:column;gap:12px'; + /* 模式选择 */ + const modeRow = document.createElement('div'); + modeRow.style.cssText = 'display:flex;align-items:center;gap:10px;font-size:13px;'; + const modeLabel = document.createElement('span'); + modeLabel.textContent = '去背景模式:'; + modeLabel.style.fontWeight = '500'; + const modeSelect = document.createElement('select'); + modeSelect.style.cssText = 'padding:4px 8px;border:1px solid #d1d5db;border-radius:6px;background:#fff;'; + const optExternal = document.createElement('option'); + optExternal.value = 'external'; + optExternal.textContent = '外部模型 (@imgly)'; + const optInternal = document.createElement('option'); + optInternal.value = 'internal'; + optInternal.textContent = '内置算法 (色度阈值)'; + modeSelect.appendChild(optExternal); + modeSelect.appendChild(optInternal); + modeSelect.value = state.mode; + modeSelect.onchange = function () { + const newMode = modeSelect.value; + if (newMode !== state.mode) { + updateModeSelector(newMode); + } + }; + modeRow.appendChild(modeLabel); + modeRow.appendChild(modeSelect); + ui.modeSelect = modeSelect; + + /* 文件拖放区 */ const drop = document.createElement('label'); drop.style.cssText = 'display:block;padding:18px;text-align:center;border:2px dashed #93c5fd;border-radius:10px;' + @@ -213,23 +423,24 @@ input.accept = 'image/png,image/jpeg,image/webp'; input.style.display = 'none'; drop.appendChild(input); - input.onchange = () => { + input.onchange = function () { if (input.files && input.files[0]) handleFile(input.files[0]); }; - drop.ondragover = (e) => { + drop.ondragover = function (e) { e.preventDefault(); drop.style.background = '#eff6ff'; }; - drop.ondragleave = () => { + drop.ondragleave = function () { drop.style.background = '#f8fafc'; }; - drop.ondrop = (e) => { + drop.ondrop = function (e) { e.preventDefault(); drop.style.background = '#f8fafc'; const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (f) handleFile(f); }; + /* 预览区 */ const preview = document.createElement('div'); preview.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:10px'; ui.before = makeThumb('原图'); @@ -237,38 +448,53 @@ preview.appendChild(ui.before.box); preview.appendChild(ui.after.box); + /* 状态文字 */ const status = document.createElement('div'); status.style.cssText = 'font-size:12px;color:#475569;min-height:18px'; status.textContent = state.statusText; + /* 进度条 */ const track = document.createElement('div'); track.style.cssText = 'height:6px;border-radius:99px;background:#e5e7eb;overflow:hidden'; const bar = document.createElement('div'); bar.style.cssText = 'height:100%;width:0%;background:#2563eb;transition:width .2s'; track.appendChild(bar); + /* 操作按钮 */ const actions = document.createElement('div'); actions.style.cssText = 'display:flex;gap:10px;flex-wrap:wrap'; + const preload = mkButton('预加载模型', '#e5e7eb', '#111827'); - preload.onclick = () => { - loadLib().catch(() => setStatus('模型运行库加载失败,将使用本地兜底抠图:' + state.lastError)); + preload.setAttribute('data-action', 'preload'); + preload.onclick = function () { + if (state.mode === 'internal') { + setStatus('内置算法无需预加载'); + return; + } + loadLib().catch(function () { + setStatus('模型运行库加载失败:' + state.lastError); + }); }; + const toCostume = mkButton('存为当前角色造型', '#16a34a', '#fff'); toCostume.disabled = true; toCostume.style.opacity = '.5'; - toCostume.onclick = () => { - saveResultToTarget(vm.editingTarget, '') - .then((n) => setStatus('已添加造型:' + n)) - .catch((e) => setStatus('保存造型失败:' + (e && e.message ? e.message : e))); + toCostume.onclick = function () { + saveResultToTarget(getEditingTarget(), '') + .then(function (n) { setStatus('已添加造型:' + n); }) + .catch(function (e) { setStatus('保存造型失败:' + (e && e.message ? e.message : e)); }); }; + const download = mkButton('下载透明 PNG', '#2563eb', '#fff'); download.disabled = true; download.style.opacity = '.5'; download.onclick = downloadResult; + actions.appendChild(preload); actions.appendChild(toCostume); actions.appendChild(download); + body.appendChild(modeRow); body.appendChild(drop); body.appendChild(preview); body.appendChild(status); @@ -277,24 +503,24 @@ root.appendChild(header); root.appendChild(body); - // 保存UI引用 ui.status = status; ui.bar = bar; ui.download = download; - ui.toCostume = toCostume; // 新增 + ui.toCostume = toCostume; ui.input = input; + updatePreloadButton(); makeDraggable(root, header); document.body.appendChild(root); state.root = root; return root; } - function makeThumb(title) { + function makeThumb(titleText) { const box = document.createElement('div'); box.style.cssText = 'border:1px solid #e5e7eb;border-radius:10px;overflow:hidden'; const cap = document.createElement('div'); - cap.textContent = title; + cap.textContent = titleText; cap.style.cssText = 'padding:4px 8px;font-size:12px;color:#64748b;background:#f8fafc'; const holder = document.createElement('div'); holder.style.cssText = @@ -302,7 +528,7 @@ CHECKER + ';background-size:16px 16px;background-position:0 0,8px 8px'; const img = document.createElement('img'); - img.alt = title; + img.alt = titleText; img.style.cssText = 'max-width:100%;max-height:150px;display:none'; holder.appendChild(img); box.appendChild(cap); @@ -315,26 +541,21 @@ b.textContent = text; b.style.cssText = 'flex:1;padding:9px 12px;border:0;border-radius:8px;cursor:pointer;font-weight:600;background:' + - bg + - ';color:' + - color; + bg + ';color:' + color; return b; } function makeDraggable(root, handle) { - let sx = 0; - let sy = 0; - let ox = 0; - let oy = 0; - const move = (e) => { + let sx = 0, sy = 0, ox = 0, oy = 0; + const move = function (e) { root.style.left = ox + (e.clientX - sx) + 'px'; root.style.top = oy + (e.clientY - sy) + 'px'; }; - const up = () => { + const up = function () { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); }; - handle.addEventListener('mousedown', (e) => { + handle.addEventListener('mousedown', function (e) { if (e.target && e.target.tagName === 'BUTTON') return; sx = e.clientX; sy = e.clientY; @@ -345,13 +566,14 @@ }); } + /* ============== 结果展示 & 文件处理 ============== */ + function showResult(url) { state.resultURL = url; if (ui.after) { ui.after.img.src = url; ui.after.img.style.display = 'block'; } - // 启用下载和存为造型按钮 if (ui.download) { ui.download.disabled = false; ui.download.style.opacity = '1'; @@ -369,51 +591,68 @@ return; } state.busy = true; - state.resultURL = ''; + resetResultDisplay(); state.modelProgress = 0; state.fileName = (file.name || 'image').replace(/\.[^.]+$/, ''); - if (ui.download) { - ui.download.disabled = true; - ui.download.style.opacity = '.5'; - } - if (ui.toCostume) { - ui.toCostume.disabled = true; - ui.toCostume.style.opacity = '.5'; - } - if (ui.after) ui.after.img.style.display = 'none'; + if (state.sourceURL && state.sourceURL.indexOf('blob:') === 0) { + URL.revokeObjectURL(state.sourceURL); + } const srcURL = URL.createObjectURL(file); state.sourceURL = srcURL; if (ui.before) { ui.before.img.src = srcURL; ui.before.img.style.display = 'block'; } - setStatus('正在准备本地模型…'); - runModel(file) - .then(blobToDataURL) - .then((url) => { - state.modelProgress = 100; - showResult(url); - setStatus('去背景完成,可以下载或存为造型'); - }) - .catch(() => { - setStatus('本地模型不可用,改用内置兜底抠图…'); - return loadImage(srcURL).then((img) => { + if (state.mode === 'external') { + setStatus('正在使用外部模型…'); + runModel(file) + .then(blobToDataURL) + .then(function (url) { + state.modelProgress = 100; + showResult(url); + setStatus('外部模型去背景完成,可以下载或存为造型'); + }) + .catch(function () { + setStatus('外部模型失败,自动切换至内置算法…'); + state.mode = 'internal'; + if (ui.modeSelect) ui.modeSelect.value = 'internal'; + updatePreloadButton(); + return loadImage(srcURL) + .then(function (img) { + const url = fallbackRemove(img); + state.modelProgress = 100; + showResult(url); + setStatus('内置算法完成(因外部模型不可用)'); + }) + .catch(function (fallbackErr) { + setStatus('内置算法也失败:' + (fallbackErr && fallbackErr.message ? fallbackErr.message : fallbackErr)); + }); + }) + .then(function () { + state.busy = false; + if (ui.bar) ui.bar.style.width = state.modelProgress + '%'; + if (ui.input) ui.input.value = ''; + }); + } else { + setStatus('正在使用内置算法…'); + loadImage(srcURL) + .then(function (img) { const url = fallbackRemove(img); state.modelProgress = 100; showResult(url); - setStatus('已用内置兜底算法去背景(边缘精度较低)'); + setStatus('内置算法去背景完成,可以下载或存为造型'); + }) + .catch(function (err) { + setStatus('内置算法处理失败:' + (err && err.message ? err.message : err)); + }) + .then(function () { + state.busy = false; + if (ui.bar) ui.bar.style.width = state.modelProgress + '%'; + if (ui.input) ui.input.value = ''; }); - }) - .catch((err) => { - setStatus('处理失败:' + (err && err.message ? err.message : err)); - }) - .then(() => { - state.busy = false; - if (ui.bar) ui.bar.style.width = state.modelProgress + '%'; - if (ui.input) ui.input.value = ''; - }); + } } function downloadResult() { @@ -426,8 +665,10 @@ a.remove(); } + /* ============== 面板开关 ============== */ + function openPanel() { - if (state.root) { + if (state.root && state.root.isConnected) { state.root.style.display = 'block'; return; } @@ -439,6 +680,24 @@ if (state.root) state.root.style.display = 'none'; } + function disposePanel() { + if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); + state.root = null; + ui.status = null; + ui.bar = null; + ui.download = null; + ui.toCostume = null; + ui.input = null; + ui.modeSelect = null; + ui.before = null; + ui.after = null; + if (state.sourceURL && state.sourceURL.indexOf('blob:') === 0) { + URL.revokeObjectURL(state.sourceURL); + state.sourceURL = ''; + } + } + + /* ============== TurboWarp 扩展类 ============== */ class BackgroundRemoverPanel { getInfo() { @@ -447,26 +706,92 @@ name: '图片去背景', color1: '#2563eb', color2: '#1d4ed8', + color3: '#1e40af', blocks: [ - { opcode: 'open', blockType: Scratch.BlockType.COMMAND, text: '打开去背景面板' }, - { opcode: 'close', blockType: Scratch.BlockType.COMMAND, text: '关闭去背景面板' }, - { opcode: 'preload', blockType: Scratch.BlockType.COMMAND, text: '预加载本地模型' }, - { opcode: 'download', blockType: Scratch.BlockType.COMMAND, text: '下载去背景结果' }, - // ----- 新增积木:存为造型 ----- + { + opcode: 'open', + blockType: BlockType.COMMAND, + text: '打开去背景面板' + }, + { + opcode: 'close', + blockType: BlockType.COMMAND, + text: '关闭去背景面板' + }, + { + opcode: 'setMode', + blockType: BlockType.COMMAND, + text: '设置模式为 [MODE]', + arguments: { + MODE: { + type: ArgumentType.STRING, + menu: 'modeMenu', + defaultValue: 'external' + } + } + }, + { + opcode: 'preload', + blockType: BlockType.COMMAND, + text: '预加载外部模型' + }, + { + opcode: 'download', + blockType: BlockType.COMMAND, + text: '下载去背景结果' + }, { opcode: 'toCostume', - blockType: Scratch.BlockType.COMMAND, + blockType: BlockType.COMMAND, text: '把去背景结果存为造型 [NAME]', arguments: { - NAME: { type: Scratch.ArgumentType.STRING, defaultValue: '去背景结果' } + NAME: { + type: ArgumentType.STRING, + defaultValue: '去背景结果' + } } }, '---', - { opcode: 'progress', blockType: Scratch.BlockType.REPORTER, text: '模型/处理进度' }, - { opcode: 'status', blockType: Scratch.BlockType.REPORTER, text: '当前状态' }, - { opcode: 'result', blockType: Scratch.BlockType.REPORTER, text: '结果图片 data URI' }, - { opcode: 'done', blockType: Scratch.BlockType.BOOLEAN, text: '已有去背景结果?' } - ] + { + opcode: 'progress', + blockType: BlockType.REPORTER, + text: '模型处理进度' + }, + { + opcode: 'statusText', + blockType: BlockType.REPORTER, + text: '当前状态' + }, + { + opcode: 'result', + blockType: BlockType.REPORTER, + text: '结果图片 data URI' + }, + { + opcode: 'currentMode', + blockType: BlockType.REPORTER, + text: '当前模式' + }, + { + opcode: 'isBusy', + blockType: BlockType.BOOLEAN, + text: '正在处理中?' + }, + { + opcode: 'done', + blockType: BlockType.BOOLEAN, + text: '已有去背景结果?' + } + ], + menus: { + modeMenu: { + acceptReporters: true, + items: [ + { text: '外部模型', value: 'external' }, + { text: '内置算法', value: 'internal' } + ] + } + } }; } @@ -478,10 +803,23 @@ closePanel(); } + setMode(args) { + const mode = Cast.toString(args.MODE); + if (mode === 'external' || mode === 'internal') { + updateModeSelector(mode); + } + } + preload() { + if (state.mode === 'internal') { + setStatus('内置算法无需预加载'); + return; + } return loadLib().then( - () => {}, - () => {} + function () { }, + function () { + setStatus('模型运行库加载失败:' + state.lastError); + } ); } @@ -490,12 +828,13 @@ } toCostume(args, util) { - const target = (util && util.target) || vm.editingTarget; - return saveResultToTarget(target, Scratch.Cast.toString(args.NAME).trim()).then( - (name) => { - setStatus('已添加造型:' + name); + const target = util.target || getEditingTarget(); + const name = Cast.toString(args.NAME).trim(); + return saveResultToTarget(target, name).then( + function (costumeName) { + setStatus('已添加造型:' + costumeName); }, - (err) => { + function (err) { setStatus('保存造型失败:' + (err && err.message ? err.message : err)); } ); @@ -505,7 +844,7 @@ return state.modelProgress; } - status() { + statusText() { return state.statusText; } @@ -513,10 +852,23 @@ return state.resultURL; } + currentMode() { + return state.mode; + } + + isBusy() { + return state.busy; + } + done() { return !!state.resultURL; } + + dispose() { + disposePanel(); + } } Scratch.extensions.register(new BackgroundRemoverPanel()); + })(Scratch);