feat(theme-builder): 图片处理——透明度 / 压白 / 去色 / 裁边 / 去白底

素材卡片左上角 fx 按钮进处理面板,非破坏:
- orig 存上传时的原始字节(不重编码),fx 只存参数,每次从 orig 重算。
  「压白 16% → 20%」不会叠加两次压缩;打开看一眼就关一个像素都不动
  (参数没变直接跳过,有损编码反复走会累积模糊)。
- 预览按 420px 宽算(滑杆要跟手),完成时用全分辨率重算;加了序号丢弃过期结果,
  否则快速拖动时旧渲染会覆盖新的。

功能:
- 不透明度 0~100%(CSS 背景图没法单独设 opacity,只能烘焙)
- 压白 v×(1-k)+255×k,与主题里场景的雾度同一公式
- 去色、裁掉透明边
- 去白底:四边漫水 + 封闭白孔检测 + 可选 1px 收边

去白底两个坑(都写进注释了):
- 手内侧 / 两腿之间是闭合白区,四边漫水进不去,得按连通域单独掏(面积≥24)
- 容差默认 16 而不是 22:背景 253、白袜子 237 只差 16,
  容差 30 会把袜子吃穿成一个透明洞

验证(都是跑出来的,不是看缩略图):
- ?dump=fx 13 项算法断言全过:封闭孔开/关、容差 16 保袜子/30 吃穿、
  收边后轮廓 1px、alpha 50%→128、压白 20% 时 237→241、去色后 R=G=B、裁边 80×40
- ?dump=fxpipe 链路:弹窗开关、预览 300×960、参数重开后仍在、
  改参后 dataURL 变、无改动时 dataURL 一个字节不变
- ?dump=fxkey 端到端:白底立绘去白底后透明占比 46.3%,
  与手写 floodfill 抠的 46.6% 差 0.3%
- 生成 CSS 仍通过 TestThemeCSSComplete + TestDecoSlotCount

顺带:工程文件升到 v3(存 orig + fx,未处理时不重复存 dataURL),
读 v2 旧工程时把 dataURL 当 orig 用。新增 ?loadurl= 把项目里的图直接当素材载入。
This commit is contained in:
tamakyi
2026-10-20 14:52:36 +08:00
parent bf1c994c8f
commit c3d34b53d2
2 changed files with 680 additions and 21 deletions
+649 -19
View File
@@ -108,6 +108,43 @@
position:absolute;inset:auto 0 0 0;font-size:9px;text-align:center;background:#000a;color:#cbd6e4;
padding:1px 0;
}
/* 处理入口常驻(不靠 hover):处理过的素材要能一眼看出来,否则
同一张图有「原图」和「压白 16% 版」两副样子,改完就分不清哪张是现在的 */
.as .fx{
position:absolute;top:2px;left:1px;font-size:10px;line-height:1;font-weight:600;
color:#cbd6e4;background:#0009;border-radius:4px;padding:2px 3px;cursor:pointer;
}
.as .fx:hover{background:var(--ui-accent);color:#08121c}
.as .fx.on{background:var(--ui-accent);color:#08121c}
/* ---------- 图片处理弹窗 ---------- */
.modal{position:fixed;inset:0;background:#000b;z-index:50;display:none;align-items:center;justify-content:center}
.modal.on{display:flex}
.mbox{
width:min(900px,94vw);max-height:90vh;display:flex;flex-direction:column;
background:var(--ui-panel);border:1px solid var(--ui-line);border-radius:10px;
overflow:hidden;box-shadow:0 24px 64px #000a;
}
.mbox header{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--ui-line)}
.mbox header .t{font-weight:600}
.mbox header .sub{font-size:11px;color:var(--ui-text2)}
.mbox header .spacer{flex:1}
.mbody{display:grid;grid-template-columns:minmax(0,1fr) 252px;flex:1;min-height:0}
.mprev{
display:flex;align-items:center;justify-content:center;padding:18px;min-height:0;min-width:0;
/* 棋盘格:不透明度是这一屏唯一看不出来的东西,必须有背景参照 */
background:conic-gradient(#1a2029 25%, #0d1117 0 50%, #1a2029 0 75%, #0d1117 0) 0 0/16px 16px;
}
.mprev canvas{max-width:100%;max-height:58vh;display:block;box-shadow:0 6px 20px #0006}
.mparams{padding:14px;border-left:1px solid var(--ui-line);overflow:auto}
.fxrow{margin-bottom:13px}
.fxrow .lab{display:flex;justify-content:space-between;align-items:baseline;font-size:11px;color:var(--ui-text2);margin-bottom:2px}
.fxrow .lab b{color:var(--ui-text);font-weight:600;font-variant-numeric:tabular-nums}
.fxrow input[type=range]{width:100%;accent-color:var(--ui-accent);margin:0}
.fxrow .sub{font-size:10px;color:#6f7c8d;margin-top:2px}
.fxrow.chkrow{margin-bottom:8px}
.mbox footer{display:flex;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--ui-line)}
.mbox footer .info{flex:1;font-size:11px;color:var(--ui-text2)}
.pal{display:flex;flex-direction:column;gap:5px}
.pal .p{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--ui-text2)}
@@ -208,6 +245,28 @@
</section>
</main>
<!-- 图片处理:非破坏。参数存在 fx 里,每次都从 orig 重算,
所以「压白 16% → 再改 20%」不会叠加两次压缩,也不会累积锯齿。 -->
<div class="modal" id="fxmodal">
<div class="mbox">
<header>
<span class="t">处理图片</span>
<span class="sub" id="fx-name"></span>
<span class="spacer"></span>
<button id="fx-close">×</button>
</header>
<div class="mbody">
<div class="mprev"><canvas id="fx-canvas"></canvas></div>
<div class="mparams" id="fx-params"></div>
</div>
<footer>
<span class="info" id="fx-info"></span>
<button id="fx-reset">重置</button>
<button id="fx-apply" class="primary">完成</button>
</footer>
</div>
</div>
<script>
'use strict';
@@ -883,9 +942,18 @@ function renderAssets(){
S.assets.forEach(a => {
const d = document.createElement('div');
d.className = 'as';
d.title = `${a.name} · ${a.w}×${a.h} · ${a.kb}KB`;
d.innerHTML = `<img src="${a.dataURL}" alt=""><span class="meta">${a.w}×${a.h}</span>
const fxOn = !fxIsEmpty(a.fx);
const ow = (a.orig && a.orig.w) || a.w, oh = (a.orig && a.orig.h) || a.h;
d.title = `${a.name} · ${a.w}×${a.h}(原始 ${ow}×${oh})· ${a.kb}KB`
+ (fxOn ? '\n已处理:' + fxSummary(a.fx) : '\n点击左上角 fx 可处理');
d.innerHTML = `<img src="${a.dataURL}" alt="">
<span class="fx${fxOn ? ' on' : ''}" data-fx="${a.id}" title="处理:透明度 / 压白 / 去白底 / 裁边">fx</span>
<span class="meta">${a.w}×${a.h}</span>
<span class="del" data-del="${a.id}">×</span>`;
d.querySelector('[data-fx]').onclick = e => {
e.stopPropagation();
openFx(a);
};
d.querySelector('[data-del]').onclick = e => {
e.stopPropagation();
S.assets = S.assets.filter(x => x.id !== a.id);
@@ -900,6 +968,17 @@ function renderAssets(){
});
}
/** 素材卡片的 tooltip 用:把 fx 说成人话,不然「压白 16%」这种参数要开弹窗才想得起来 */
function fxSummary(f){
const p = [];
if (f.alpha !== 100) p.push(`不透明 ${f.alpha}%`);
if (f.wash) p.push(`压白 ${f.wash}%`);
if (f.gray) p.push(`去色 ${f.gray}%`);
if (f.trim) p.push('裁透明边');
if (f.key && f.key.on) p.push(`去白底 容差 ${f.key.tol}`);
return p.length ? p.join(' · ') : '无';
}
function toHex(v){
if (!v) return '#000000';
if (v.startsWith('#')) return v.length === 4
@@ -1023,7 +1102,7 @@ let saveTimer = null, draftWarned = false;
function toJSON(){
return {
v: 2,
v: 3,
savedAt: new Date().toISOString(),
slug: S.slug,
scope: S.scope,
@@ -1032,15 +1111,25 @@ function toJSON(){
palette: S.palette,
dark: S.dark,
narrow: S.narrow,
// 图片直接内嵌 base64:少一层「图片放哪」的约定,导入时一个文件就够
assets: S.assets.map(a => ({ id: a.id, name: a.name, dataURL: a.dataURL, w: a.w, h: a.h, kb: a.kb })),
// 图片内嵌 base64:少一层「图片放哪」的约定,导入时一个文件就够。
// 存的是 orig(原始字节)+ fx(处理参数),dataURL 只在真有处理时才存 ——
// 未处理时它必然等于 orig,再存一份等于让 json 平白翻倍。
assets: S.assets.map(a => ({
id: a.id, name: a.name,
orig: a.orig || { dataURL: a.dataURL, w: a.w, h: a.h },
fx: fxIsEmpty(a.fx) ? null : normFx(a.fx),
dataURL: fxIsEmpty(a.fx) ? null : a.dataURL,
w: a.w, h: a.h, kb: a.kb,
})),
scopes: S.scopes,
};
}
function fromJSON(o){
if (!o || typeof o !== 'object' || o.v !== 2){
throw new Error('不是本工具导出的工程文件(版本不是 2)');
// v2 是加图片处理之前的格式(只有 dataURL):把 dataURL 当 orig 用,
// fx 全空,用户打开旧工程还能接着改参数。
if (!o || typeof o !== 'object' || (o.v !== 2 && o.v !== 3)){
throw new Error('不是本工具导出的工程文件(版本 ' + (o && o.v) + ',支持 2 / 3)');
}
S.slug = o.slug || 'my-theme';
S.scope = SCOPES.some(x => x.k === o.scope) ? o.scope : 'global';
@@ -1049,7 +1138,15 @@ function fromJSON(o){
S.palette = Object.assign({}, S.palette, o.palette || {});
S.dark = Object.assign({}, S.dark, o.dark || {});
S.narrow = Object.assign({ enabled: true, bg: null, hideDeco: true, maxWidth: 900 }, o.narrow || {});
S.assets = (o.assets || []).map(a => Object.assign({}, a, { bytes: dataURLToBytes(a.dataURL) }));
S.assets = (o.assets || []).map(a => {
const orig = a.orig || { dataURL: a.dataURL, w: a.w, h: a.h };
const dataURL = a.dataURL || orig.dataURL;
return {
id: a.id, name: a.name, orig, fx: normFx(a.fx),
dataURL, bytes: dataURLToBytes(dataURL),
w: a.w || orig.w, h: a.h || orig.h, kb: a.kb || 0,
};
});
S.scopes = {};
SCOPES.forEach(sc => {
@@ -1305,6 +1402,196 @@ function doExport(){
}
/* ---------------------------- 图片处理 ---------------------------- */
/* 设计:orig 保存上传时的原始字节(不重编码),fx 只存参数,每次处理都从 orig 重算。
这样「压白 16% → 改成 20%」不会叠加两次压缩,反复调参也不会把边缘越磨越糊。 */
function fxDefault(){
return { alpha:100, wash:0, gray:0, trim:false,
// 容差 16 → 门槛 239。经验值:背景常是 253,白袜子/白衬衫是 237,
// 容差给到 20 以上就会把白袜子一起判成背景(吃穿成一个透明洞)。
key:{ on:false, tol:16, holes:true, feather:true } };
}
/** 读进来的 fx 一律过一遍归一化:工程文件可能是别的版本写的,缺字段不能让渲染变成 NaN。 */
function normFx(f){
f = f || {};
const k = Object.assign(fxDefault().key, f.key || {});
return {
alpha: f.alpha == null ? 100 : clamp(Math.round(+f.alpha), 0, 100),
wash: clamp(Math.round(+(f.wash || 0)), 0, 60),
gray: clamp(Math.round(+(f.gray || 0)), 0, 100),
trim: !!f.trim,
key: { on: !!k.on, tol: clamp(Math.round(+k.tol || 16), 4, 60),
holes: k.holes !== false, feather: k.feather !== false },
};
}
function fxIsEmpty(f){
return !f || (f.alpha === 100 && !f.wash && !f.gray && !f.trim && !(f.key && f.key.on));
}
/* 去白底。两步:① 与画布边缘连通的近白像素才是背景;② 不与边缘连通的「封闭白孔」
(手内侧、两腿之间那种)按面积单独掏。
⚠️ 容差别贪:背景常常是 (253,253,253) 而白袜子是 (237,237,237),
容差过了 30 就会把袜子一起吃穿。 */
function keyWhite(imgData, tol, holes){
const { data, width: W, height: H } = imgData;
const N = W * H;
const thr = 255 - Math.max(1, tol);
const white = new Uint8Array(N);
for (let p = 0, i = 0; p < N; p++, i += 4){
white[p] = (data[i] >= thr && data[i+1] >= thr && data[i+2] >= thr) ? 1 : 0;
}
const mark = new Uint8Array(N); // 1 = 判为背景
const stack = new Int32Array(N);
let sp = 0;
const push = p => { if (white[p] && !mark[p]){ mark[p] = 1; stack[sp++] = p; } };
for (let x = 0; x < W; x++){ push(x); push((H - 1) * W + x); }
for (let y = 0; y < H; y++){ push(y * W); push(y * W + W - 1); }
while (sp > 0){
const p = stack[--sp];
const x = p % W, y = (p - x) / W;
if (x > 0) push(p - 1);
if (x < W - 1) push(p + 1);
if (y > 0) push(p - W);
if (y < H - 1) push(p + W);
}
if (holes){
// 面积 >= 24 才掏:更小的白色多半是眼睛高光/反光,掏了会变成透明洞
const seen = new Uint8Array(N);
const q = new Int32Array(N);
for (let p0 = 0; p0 < N; p0++){
if (!white[p0] || mark[p0] || seen[p0]) continue;
let qh = 0, qt = 0;
q[qt++] = p0; seen[p0] = 1;
const cells = [];
while (qh < qt){
const p = q[qh++];
cells.push(p);
const x = p % W, y = (p - x) / W;
if (x > 0 && white[p-1] && !mark[p-1] && !seen[p-1]){ seen[p-1] = 1; q[qt++] = p-1; }
if (x < W - 1 && white[p+1] && !mark[p+1] && !seen[p+1]){ seen[p+1] = 1; q[qt++] = p+1; }
if (y > 0 && white[p-W] && !mark[p-W] && !seen[p-W]){ seen[p-W] = 1; q[qt++] = p-W; }
if (y < H - 1 && white[p+W] && !mark[p+W] && !seen[p+W]){ seen[p+W] = 1; q[qt++] = p+W; }
}
if (cells.length >= 24) for (const p of cells) mark[p] = 1;
}
}
for (let p = 0, i = 3; p < N; p++, i += 4) if (mark[p]) data[i] = 0;
}
/** alpha 做一次 3×3 最小值腐蚀:把抠图边缘残留的半透明白边收掉 1px。
代价是轮廓会缩一点,细线条(光环一类)可能被吃掉,所以是开关而不是写死。 */
function erodeAlpha1(imgData){
const { data, width: W, height: H } = imgData;
const a = new Uint8Array(W * H);
for (let p = 0, i = 3; p < a.length; p++, i += 4) a[p] = data[i];
for (let y = 0; y < H; y++){
for (let x = 0; x < W; x++){
const p = y * W + x;
let m = a[p];
if (m === 0) continue;
if (x === 0 || y === 0 || x === W - 1 || y === H - 1) m = 0;
else {
for (let dy = -1; dy <= 1 && m; dy++){
const row = (y + dy) * W;
for (let dx = -1; dx <= 1; dx++){
const v = a[row + x + dx];
if (v < m){ m = v; if (m === 0) break; }
}
}
}
data[p * 4 + 3] = m;
}
}
}
function fxPixels(imgData, fx){
const d = imgData.data;
if (fx.key && fx.key.on) keyWhite(imgData, fx.key.tol, fx.key.holes);
const washK = (fx.wash || 0) / 100;
const grayK = (fx.gray || 0) / 100;
const aK = (fx.alpha == null ? 100 : fx.alpha) / 100;
for (let i = 0; i < d.length; i += 4){
let r = d[i], g = d[i+1], b = d[i+2];
if (grayK){
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
r += (lum - r) * grayK; g += (lum - g) * grayK; b += (lum - b) * grayK;
}
if (washK){
r += (255 - r) * washK; g += (255 - g) * washK; b += (255 - b) * washK;
}
d[i] = r; d[i+1] = g; d[i+2] = b;
if (aK < 1) d[i+3] = d[i+3] * aK;
}
if (fx.key && fx.key.on && fx.key.feather) erodeAlpha1(imgData);
}
/** 只保留不透明的外接矩形。生成图四周留白多时用它裁掉,
否则 CSS 里按高度定尺寸会让主体看起来比实际小一圈。 */
function trimCanvas(cv){
const g = cv.getContext('2d');
const W = cv.width, H = cv.height;
const d = g.getImageData(0, 0, W, H).data;
let x0 = W, y0 = H, x1 = -1, y1 = -1;
for (let y = 0; y < H; y++){
for (let x = 0; x < W; x++){
if (d[(y * W + x) * 4 + 3] > 8){
if (x < x0) x0 = x;
if (x > x1) x1 = x;
if (y < y0) y0 = y;
if (y > y1) y1 = y;
}
}
}
if (x1 < 0 || (x0 === 0 && y0 === 0 && x1 === W - 1 && y1 === H - 1)) return cv;
const w = x1 - x0 + 1, h = y1 - y0 + 1;
const out = document.createElement('canvas');
out.width = w; out.height = h;
out.getContext('2d').drawImage(cv, x0, y0, w, h, 0, 0, w, h);
return out;
}
/** 按 fx 出一张 canvas。maxW > 0 时先等比缩到该宽度(预览用,拖动滑杆才跟手)。 */
function renderFxCanvas(img, fx, maxW){
fx = normFx(fx);
const W0 = img.naturalWidth || img.width, H0 = img.naturalHeight || img.height;
const k = maxW && W0 > maxW ? maxW / W0 : 1;
let cv = document.createElement('canvas');
cv.width = Math.max(1, Math.round(W0 * k));
cv.height = Math.max(1, Math.round(H0 * k));
const g = cv.getContext('2d');
g.drawImage(img, 0, 0, cv.width, cv.height);
// 没有任何处理时直接跳过 getImageData:2M 像素图省下的这一趟够明显
if (!fxIsEmpty(fx)){
const id = g.getImageData(0, 0, cv.width, cv.height);
fxPixels(id, fx);
g.putImageData(id, 0, 0);
}
if (fx.trim) cv = trimCanvas(cv);
return cv;
}
async function decodeAsset(a){
if (a._img) return a._img;
const src = (a.orig && a.orig.dataURL) || a.dataURL;
a._img = await new Promise((res, rej) => {
const im = new Image();
im.onload = () => res(im);
im.onerror = () => rej(new Error('无法解码素材 ' + a.id));
im.src = src;
});
return a._img;
}
/** 把处理结果写回 asset:dataURL/bytes/w/h/kb 全部换成处理后的。 */
async function bakeAsset(a){
const img = await decodeAsset(a);
const cv = renderFxCanvas(img, a.fx);
Object.assign(a, canvasAsset(cv, 0.9));
}
function loadImage(file){
return new Promise((res, rej) => {
const img = new Image();
@@ -1314,6 +1601,16 @@ function loadImage(file){
});
}
/** 原文件原样读成 dataURL:orig 要保真,绝不能在这里就重编码。 */
function readAsDataURL(file){
return new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result);
r.onerror = () => rej(new Error('读取失败 ' + file.name));
r.readAsDataURL(file);
});
}
/** 画布尺寸 = 原图尺寸:不缩放、不留透明边,导出后的宽高比才与实际一致。 */
function canvasAsset(cv, q){
const url = cv.toDataURL('image/webp', q);
@@ -1324,23 +1621,246 @@ function canvasAsset(cv, q){
async function addFiles(list){
for (const f of list){
if (!f.type.startsWith('image/')) continue;
let img;
try { img = await loadImage(f); }
catch (e){ log(e.message, 'e'); continue; }
const cv = document.createElement('canvas');
cv.width = img.naturalWidth; cv.height = img.naturalHeight;
cv.getContext('2d').drawImage(img, 0, 0);
const conv = canvasAsset(cv, 0.9);
let img, origURL;
try {
img = await loadImage(f);
origURL = await readAsDataURL(f);
} catch (e){ log(e.message, 'e'); continue; }
const base = f.name.replace(/\.[^.]+$/, '').toLowerCase()
.replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'img';
let id = base, n = 2;
while (S.assets.some(a => a.id === id)) id = base + '-' + (n++);
S.assets.push({ id, name: f.name, ...conv });
log(`加入 ${f.name} → ${id}.webp(${conv.w}×${conv.h}, ${conv.kb}KB)`, 'ok');
const a = {
id, name: f.name,
orig: { dataURL: origURL, w: img.naturalWidth, h: img.naturalHeight },
fx: fxDefault(),
_img: img,
};
const cv = renderFxCanvas(img, a.fx);
Object.assign(a, canvasAsset(cv, 0.9));
S.assets.push(a);
log(`加入 ${f.name} → ${id}.webp(${a.w}×${a.h}, ${a.kb}KB)`, 'ok');
}
renderAll();
}
/** 从站点路径直接载入素材:不用先下载到本地再上传,
也方便把已有主题的图拉回来重新调参数。 */
async function addUrlAsset(url, id){
const blob = await (await fetch(url)).blob();
const origURL = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result);
r.onerror = () => rej(new Error('读取失败 ' + url));
r.readAsDataURL(blob);
});
const img = await new Promise((res, rej) => {
const im = new Image();
im.onload = () => res(im);
im.onerror = () => rej(new Error('无法解码 ' + url));
im.src = origURL;
});
let n = 2, use = id;
while (S.assets.some(a => a.id === use)) use = id + '-' + (n++);
const a = {
id: use, name: url.split('/').pop(),
orig: { dataURL: origURL, w: img.naturalWidth, h: img.naturalHeight },
fx: fxDefault(), _img: img,
};
Object.assign(a, canvasAsset(renderFxCanvas(img, a.fx), 0.9));
S.assets.push(a);
return a;
}
/* ---------------------- 图片处理弹窗 ---------------------- */
let fxTarget = null;
let fxSeq = 0; // 拖动滑杆会连续触发预览:只让最后一次落地,避免旧结果覆盖新结果
let fxSnapshot = ''; // 打开时的参数快照
async function fxPreview(){
if (!fxTarget) return;
const my = ++fxSeq;
const a = fxTarget;
const img = await decodeAsset(a);
if (my !== fxSeq) return; // 已经有更新的调用了,这次结果作废
// 预览按 420px 宽算:滑杆要能实时跟手,全分辨率(可能 2M 像素)拖一次卡一次
const cv = renderFxCanvas(img, a.fx, 420);
const out = $('fx-canvas');
out.width = cv.width; out.height = cv.height;
const g = out.getContext('2d');
g.clearRect(0, 0, cv.width, cv.height);
g.drawImage(cv, 0, 0);
$('fx-info').textContent = `输出 ${cv.width}×${cv.height} · ` +
(fxIsEmpty(a.fx) ? '未处理' : '预览已按比例缩小,完成时用全分辨率重算');
}
function renderFxParams(){
const el = $('fx-params');
el.innerHTML = '';
const f = fxTarget.fx;
const slider = (label, val, min, max, unit, sub, onChange) => {
const box = document.createElement('div');
box.className = 'fxrow';
const lab = document.createElement('div');
lab.className = 'lab';
const t = document.createElement('span'); t.textContent = label;
const b = document.createElement('b'); b.textContent = val + (unit || '');
lab.append(t, b);
const r = document.createElement('input');
r.type = 'range'; r.min = min; r.max = max; r.step = 1; r.value = val;
r.oninput = () => { b.textContent = r.value + (unit || ''); onChange(+r.value); fxPreview(); };
box.append(lab, r);
if (sub){
const s = document.createElement('div'); s.className = 'sub'; s.textContent = sub;
box.append(s);
}
el.appendChild(box);
};
const check = (label, val, onChange) => {
const box = document.createElement('div');
box.className = 'fxrow chkrow';
const l = document.createElement('label');
l.className = 'chk';
l.style.cssText = 'font-size:11.5px;color:var(--ui-text);display:flex;gap:6px;align-items:center;cursor:pointer';
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = val;
cb.onchange = () => onChange(cb.checked);
l.append(cb, document.createTextNode(label));
box.appendChild(l);
el.appendChild(box);
};
slider('不透明度', f.alpha, 0, 100, '%', '背景图没法单独设 opacity,只能烘焙进像素', v => f.alpha = v);
slider('压白 / 雾度', f.wash, 0, 60, '%',
'v×(1-k)+255×k。场景压白 38%,人物常用 12~26% 才不显突兀', v => f.wash = v);
slider('去色', f.gray, 0, 100, '%', '往灰度混。彩色素材压不住素场景时用它', v => f.gray = v);
check('裁掉透明边', f.trim, v => { f.trim = v; fxPreview(); });
check('去白底', f.key.on, v => { f.key.on = v; renderFxParams(); fxPreview(); });
if (f.key.on){
slider('容差', f.key.tol, 4, 60, '', '背景常是 253、白袜子 237 —— 容差过 18 就把袜子吃穿', v => f.key.tol = v);
check('掏掉封闭白孔(手内侧、腿之间)', f.key.holes, v => { f.key.holes = v; fxPreview(); });
check('边缘收 1px(去白边)', f.key.feather, v => { f.key.feather = v; fxPreview(); });
}
}
async function openFx(a){
fxTarget = a;
a.fx = normFx(a.fx);
fxSnapshot = JSON.stringify(a.fx);
$('fx-name').textContent = `${a.id}.webp · 原始 ${a.orig.w}×${a.orig.h}`;
renderFxParams();
$('fxmodal').classList.add('on');
$('fx-info').textContent = '处理中…';
await fxPreview();
}
/** 关闭一律保存:预览是所见即所得,点 × 若不落盘,用户会以为已经改了。 */
async function closeFx(){
const a = fxTarget;
$('fxmodal').classList.remove('on');
fxTarget = null;
if (!a) return;
// 参数没动就一次像素都别碰:只是打开看一眼,不该让图被重新编码一遍(有损,会累积)
if (JSON.stringify(normFx(a.fx)) === fxSnapshot){
renderAll();
return;
}
const before = `${a.w}×${a.h}`;
await bakeAsset(a);
renderAll();
log(fxIsEmpty(a.fx)
? `${a.id}.webp 恢复原图(${a.w}×${a.h})`
: `${a.id}.webp 已处理:${before} → ${a.w}×${a.h},${a.kb}KB`, 'ok');
}
/* ---------------------- 处理算法自检(?dump=fx) ---------------------- */
/* 这几个断言都是踩过的坑,不能靠肉眼在缩略图上看:
- 封闭白孔(手内侧、两腿之间)四边漫水进不去,必须单独掏;
- 白袜子 237 与背景 253 只差 16,容差稍大就吃穿;
- 压白要按 v×(1-k)+255×k 算,否则和场景的雾度对不上。 */
function fxSelfTest(){
const W = 200, H = 220;
const mk = () => {
const cv = document.createElement('canvas');
cv.width = W; cv.height = H;
const g = cv.getContext('2d');
g.fillStyle = '#fdfdfd'; g.fillRect(0, 0, W, H); // 背景:真实场景就是 253
g.fillStyle = '#3b6ea5';
g.beginPath(); g.arc(100, 110, 60, 0, Math.PI * 2); g.fill(); // 主体
g.fillStyle = '#fdfdfd';
g.beginPath(); g.arc(100, 110, 12, 0, Math.PI * 2); g.fill(); // 封闭白孔(应被掏)
g.fillStyle = '#ededed'; // 白袜子 / 白衬衫 237
g.fillRect(70, 150, 20, 14);
return g;
};
const run = fx => {
const g = mk();
const id = g.getImageData(0, 0, W, H);
fxPixels(id, normFx(fx));
const at = (x, y) => {
const i = (y * W + x) * 4;
return { r: id.data[i], g: id.data[i+1], b: id.data[i+2], a: id.data[i+3] };
};
return { at, id };
};
const key = (tol, holes, feather) => ({ key: { on: true, tol, holes, feather } });
const checks = [];
const T = (name, got, want) => checks.push({ name, got, want, ok: JSON.stringify(got) === JSON.stringify(want) });
// 1) 四边漫水:角落应透明,主体内应不透明
const a = run(key(16, true, false));
T('角落(与边缘连通的白)', a.at(2, 2).a, 0);
T('主体内部', a.at(100, 60).a, 255);
// 2) 封闭白孔:开 holes 时要被掏掉
T('封闭白孔(holes 开)', a.at(100, 110).a, 0);
const b = run(key(16, false, false));
T('封闭白孔(holes 关,应保留)', b.at(100, 110).a, 255);
// 3) 白袜子 237 在容差 16 下保住、容差 30 下被吃穿
T('白袜子 237(容差 16)', a.at(80, 157).a, 255);
const c = run(key(30, true, false));
T('白袜子 237(容差 30,应被吃穿)', c.at(80, 157).a, 0);
// 4) 边缘收 1px:轮廓外扩一圈变透明,主体中心不受影响
const f = run(key(16, true, true));
T('收边后轮廓 1px', f.at(100, 50).a, 0);
T('收边后中心仍实心', f.at(100, 80).a, 255);
// 5) 不透明度 50% → alpha 减半
T('不透明度 50%', run({ alpha: 50 }).at(100, 80).a, 128);
// 6) 压白 20%:237 → round(237×0.8 + 255×0.2) = 241
const w = run({ wash: 20 });
T('压白 20%(237)', w.at(80, 157).r, 241);
T('压白 20%(背景 253)', w.at(2, 2).r, 253);
// 7) 去色 100%:蓝块变灰(三通道相等)
const gr = run({ gray: 100 }).at(100, 60);
T('去色 100% 后 R=G=B', [gr.r === gr.g, gr.g === gr.b], [true, true]);
// 8) 裁透明边:四周透明 20px 的图应裁到内容尺寸
const tc = document.createElement('canvas');
tc.width = 200; tc.height = 160;
const tg = tc.getContext('2d');
tg.fillStyle = '#000'; tg.fillRect(50, 60, 80, 40);
const cut = trimCanvas(tc);
T('裁透明边尺寸', [cut.width, cut.height], [80, 40]);
return {
pass: checks.every(c => c.ok),
failed: checks.filter(c => !c.ok).length,
checks,
};
}
/* ---------------------------- 示例 ---------------------------- */
function makeDemo(){
// 重复点「载入示例」不该把素材叠起来(会撞 id,导出时取到的是判断顺序里靠前的那个)
@@ -1377,8 +1897,11 @@ function makeDemo(){
c.beginPath(); c.ellipse(127, 862, 27, 15, 0, 0, Math.PI * 2); c.fill(); // 鞋
c.beginPath(); c.ellipse(173, 862, 27, 15, 0, 0, Math.PI * 2); c.fill();
S.assets.push({ id:'bg', name:'示例背景', ...canvasAsset(bg, 0.82) });
S.assets.push({ id:'char', name:'示例人物', ...canvasAsset(ch, 0.9) });
// 示例素材也走完整结构(orig + fx):否则打开弹窗时 decodeAsset 找不到原图
const bgA = canvasAsset(bg, 0.82);
const chA = canvasAsset(ch, 0.9);
S.assets.push({ id:'bg', name:'示例背景', orig:{ dataURL:bgA.dataURL, w:bgA.w, h:bgA.h }, fx: fxDefault(), ...bgA });
S.assets.push({ id:'char', name:'示例人物', orig:{ dataURL:chA.dataURL, w:chA.w, h:chA.h }, fx: fxDefault(), ...chA });
const gc = S.scopes.global;
gc.bg = 'bg';
@@ -1463,6 +1986,20 @@ function boot(){
};
$('add-asset').onclick = () => $('file').click();
$('file').onchange = e => { addFiles([...e.target.files]); e.target.value = ''; };
// 图片处理弹窗:× / 完成 / 点遮罩都保存(预览即所见,取消会让人以为已经改了)
$('fx-close').onclick = () => closeFx();
$('fx-apply').onclick = () => closeFx();
$('fxmodal').onclick = e => { if (e.target === $('fxmodal')) closeFx(); };
$('fx-reset').onclick = () => {
if (!fxTarget) return;
fxTarget.fx = fxDefault();
renderFxParams();
fxPreview();
};
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && fxTarget) closeFx();
});
$('inherit').onchange = e => {
if (S.scope === 'global') return;
S.scopes[S.scope].inherit = e.target.checked;
@@ -1471,6 +2008,22 @@ function boot(){
window.addEventListener('resize', fitStage);
if (params.get('demo')) makeDemo();
// ?loadurl=<站点路径> 直接把项目里的图当素材载入(不必先下载再上传)
const loadURL = params.get('loadurl');
if (loadURL){
const want = params.get('name') || loadURL.split('/').pop().replace(/\.[^.]+$/, '');
addUrlAsset(loadURL, want).then(a => {
// ?fxjson={"key":{"on":true}} 用来直接给出初始处理参数(截图 / 复现某组参数用)
const fj = params.get('fxjson');
if (fj){ try { a.fx = normFx(JSON.parse(fj)); } catch (_) {} }
renderAll();
if (params.get('fx')) openFx(a);
}).catch(e => log('载入失败:' + e.message, 'e'));
} else if (params.get('fx')){
// ?fx=<素材id> 直接打开处理弹窗(截图和人工核对用)
const a0 = assetById(params.get('fx'));
if (a0) setTimeout(() => openFx(a0), 60);
}
// 自检钩子:?dump=css 导出最终 CSS,?dump=zip 导出 zip 的 base64。
// 无头浏览器里用 --dump-dom 抓这两个值,就能在没有 UI 交互的情况下做回归。
@@ -1484,6 +2037,83 @@ function boot(){
pre.textContent = JSON.stringify(validate(), null, 1);
} else if (dump === 'install'){
pre.textContent = installTxt(S.slug);
} else if (dump === 'fx'){
pre.textContent = JSON.stringify(fxSelfTest(), null, 1);
} else if (dump === 'fxkey'){
// 端到端:把「白底立绘」载入 → 去白底 → 统计透明像素比例。
// 与手写 floodfill 抠出来的那张(46.6% 透明)对比,差值应很小。
(async () => {
const r = {};
try {
const a = await addUrlAsset(params.get('src') || '/outputs/fxtest-whitebg.png', 'cut');
r.origSize = [a.w, a.h];
const stat = (tol, holes, feather) => {
const cv = renderFxCanvas(a._img, { key:{ on:true, tol, holes, feather } });
const d = cv.getContext('2d').getImageData(0, 0, cv.width, cv.height).data;
let t = 0;
for (let i = 3; i < d.length; i += 4) if (d[i] < 8) t++;
return { size:[cv.width, cv.height], clearPct: +(t / (d.length/4) * 100).toFixed(1) };
};
r.tol16 = stat(16, true, false);
r.tol16_noHoles = stat(16, false, false);
r.tol16_feather = stat(16, true, true);
r.tol30 = stat(30, true, false);
r.expect = 46.6;
r.ok = Math.abs(r.tol16.clearPct - 46.6) < 4;
} catch (e){
r.error = e.message;
r.ok = false;
}
pre.textContent = JSON.stringify(r, null, 1);
})();
} else if (dump === 'fxpipe'){
// 异步链路冒烟:弹窗打开 → 改参数 → 预览渲染 → 关闭落盘。
// 结论写进 pre,靠 --dump-dom 在 virtual-time-budget 结束后抓。
(async () => {
const r = {};
try {
makeDemo();
const a = assetById('char');
const sig = x => x.dataURL.slice(-48);
const before = sig(a), bw = a.w;
await openFx(a);
r.modalOn = $('fxmodal').classList.contains('on');
r.previewW = $('fx-canvas').width;
r.previewH = $('fx-canvas').height;
r.sliderCount = $('fx-params').querySelectorAll('input[type=range]').length;
a.fx.wash = 20;
a.fx.gray = 30;
await fxPreview();
r.previewW2 = $('fx-canvas').width;
await closeFx();
r.modalOff = !$('fxmodal').classList.contains('on');
r.wash = a.fx.wash;
r.fxOn = !fxIsEmpty(a.fx);
r.summary = fxSummary(a.fx);
r.rerendered = before !== sig(a);
r.sizeKept = a.w === bw;
r.kb = a.kb;
// 再打开一次:参数要还在(normFx 不能把值吃掉)
const sigKeep = sig(a);
await openFx(a);
r.reopenWash = (() => {
const s = [...$('fx-params').querySelectorAll('input[type=range]')];
return s.length ? +s[1].value : -1;
})();
await closeFx();
// 打开看一眼就关:像素必须一个字节都不变(否则有损编码会累积)
r.untouchedKept = sigKeep === sig(a);
r.ok = r.modalOn && r.modalOff && r.rerendered && r.reopenWash === 20
&& r.previewW > 0 && r.untouchedKept;
} catch (e){
r.error = e.message + ' | ' + (e.stack || '').split('\n')[1];
r.ok = false;
}
pre.textContent = JSON.stringify(r, null, 1);
})();
} else {
const files = [{ name: `${S.slug}.css`, data: enc(buildCSS(false)) }];
const bytes = zipStore(files);