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:
+649
-19
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user